feat(station): Abschlussbild beim Herunterfahren und beim Neustart

E-Paper behaelt sein Bild dauerhaft, auch stromlos. Faehrt der Pi
herunter, wird der Anzeigedienst mit abgeraeumt und das zuletzt
gezeichnete Bild bleibt fuer immer stehen - bei der Abnahme stand dort
die Rueckfrage des Tastermenues, und der ausgeschaltete Pi sah aus, als
warte er noch auf eine Eingabe.

Zwei neue Units, ninlight-poweroff.service (WantedBy=poweroff.target)
und ninlight-reboot.service (WantedBy=reboot.target). Sie haengen am
System, nicht am Tasterdienst: so greifen sie bei jedem Herunterfahren -
Menue, "poweroff" per SSH, "reboot" - und nicht nur auf einem von drei
Wegen. Zwei Units statt einer Fallunterscheidung, weil systemd einem
ExecStart nicht sagt, wohin die Reise geht.

Die Texte unterscheiden sich: "AUSGESCHALTET / Strom kann getrennt
werden" gegen "NEUSTART / Strom NICHT trennen!" samt Hinweis auf die
rund zwei Minuten, die der Zero W bis zum Anzeigedienst braucht. An
diesem Pi hat ein Stromzug in genau dieser Wartezeit schon eine
SD-Karte gekostet.

Reihenfolge: die Units ordnen sich per After=ninlight-display.service
hinter dessen Stop ein, und "ninlight-station display final <kind>"
wartet zusaetzlich, bis systemctl is-active nicht mehr anschlaegt -
dasselbe Muster wie display_handover(), nur ohne Wiederanfahren. Gibt
der Dienst die Leitungen nicht frei, wird nicht gezeichnet (GPIO busy),
sondern gewarnt: ein Anzeigefehler haelt das Herunterfahren nie auf.

Nebenbei behoben: display_run() reichte nur "$1" an display.py weiter
und haette "--final poweroff" halbiert.
This commit is contained in:
Michael Sauer
2026-09-15 03:02:21 +02:00
parent 300edafc3e
commit 4e1a965cbb
12 changed files with 572 additions and 4 deletions
+113
View File
@@ -129,6 +129,21 @@ case "\$1" in
unit="\${!#}"
if [ "\$unit" = "ninlight-display" ]; then
code="\${FAKE_DISPLAY_SERVICE_ACTIVE_EXIT:-0}"
# Testschalter fuer den Abschlussbildschirm: der Anzeigedienst gilt fuer die
# ersten N Abfragen als AKTIV und danach als gestoppt - so, wie es beim
# Herunterfahren wirklich ablaeuft (systemd stoppt ihn, das dauert einen Moment).
# Gezaehlt wird in einer Datei, weil jeder Stub-Aufruf ein eigener Prozess ist.
if [ -n "\${FAKE_DISPLAY_ACTIVE_FOR:-}" ]; then
n=\$(cat "$rundir/.display-active-count" 2>/dev/null || echo 0)
n=\$((n + 1))
echo "\$n" >"$rundir/.display-active-count"
if [ "\$n" -le "\${FAKE_DISPLAY_ACTIVE_FOR}" ]; then
echo active
exit 0
fi
echo inactive
exit 3
fi
else
code="\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-0}"
fi
@@ -1308,6 +1323,104 @@ ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
set -e
echo "$ERR" | grep -q "keine Migrationsimages" && pass "nach dem Umschalten warnt sie" || fail "keine Warnung, obwohl der gewaehlte Satz leer ist: $ERR"
echo
echo "=== Abschluss A: 'display final' kennt zwei verschiedene Bilder ==="
# E-Paper behaelt sein Bild dauerhaft. Beim Ausschalten darf man den Stecker ziehen, beim
# Neustart gerade nicht - deshalb zwei Schalter, nicht einer.
SBX="$TMPROOT/fin1"
setup_sandbox "$SBX"
export FAKE_DISPLAY_SERVICE_ACTIVE_EXIT=3 # Anzeigedienst laeuft nicht
set +e
OUT=$(run_cli "$SBX" display final poweroff 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
grep -q "STUB display.py --final poweroff" "$SBX/cmd.log" && pass "display.py wird mit '--final poweroff' aufgerufen" || fail "falscher Aufruf: $(cat "$SBX/cmd.log")"
: >"$SBX/cmd.log"
run_cli "$SBX" display final reboot >/dev/null 2>&1
grep -q "STUB display.py --final reboot" "$SBX/cmd.log" && pass "und mit '--final reboot' fuer den Neustart" || fail "falscher Aufruf: $(cat "$SBX/cmd.log")"
grep -q "poweroff" "$SBX/cmd.log" && fail "der Neustart ruft das Ausschaltbild auf" || pass "die beiden Wege werden nicht verwechselt"
unset FAKE_DISPLAY_SERVICE_ACTIVE_EXIT
echo
echo "=== Abschluss B: ohne 'poweroff'/'reboot' passiert nichts ==="
SBX="$TMPROOT/fin2"
setup_sandbox "$SBX"
set +e
ERR=$(run_cli "$SBX" display final 2>&1 >/dev/null)
RC=$?
set -e
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0 ohne Spielart"
grep -q "STUB display.py" "$SBX/cmd.log" 2>/dev/null && fail "display.py wurde trotzdem aufgerufen" || pass "das Panel wurde nicht angefasst"
set +e
ERR=$(run_cli "$SBX" display final quatsch 2>&1 >/dev/null)
RC=$?
set -e
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 fuer eine unbekannte Spielart" || fail "Exit-Code 0 fuer 'quatsch'"
grep -q "STUB display.py" "$SBX/cmd.log" 2>/dev/null && fail "display.py wurde fuer 'quatsch' aufgerufen" || pass "und auch dafuer nicht"
echo
echo "=== Abschluss C: gezeichnet wird erst, NACHDEM der Anzeigedienst die Leitungen freigegeben hat ==="
# Zwei Prozesse auf denselben GPIO-/SPI-Leitungen enden in "lgpio.error: 'GPIO busy'" -
# in diesem Projekt schon zweimal passiert.
SBX="$TMPROOT/fin3"
setup_sandbox "$SBX"
export FAKE_DISPLAY_ACTIVE_FOR=2 # erst beim dritten Blick ist er gestoppt
set +e
OUT=$(NINLIGHT_DISPLAY_RELEASE_WAIT_S=5 run_cli "$SBX" display final poweroff 2>&1)
RC=$?
set -e
unset FAKE_DISPLAY_ACTIVE_FOR
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
grep -q "STUB display.py --final poweroff" "$SBX/cmd.log" && pass "das Abschlussbild wurde gezeichnet" || fail "gar nicht gezeichnet: $(cat "$SBX/cmd.log")"
# Die Reihenfolge in der GEMEINSAMEN Logdatei ist der Beleg: jede is-active-Abfrage steht
# vor dem display.py-Aufruf, keine danach.
# "|| true" ueberall: ohne Treffer liefert grep 1, und unter "set -e" wuerde die SUITE an
# dieser Stelle stillschweigend abbrechen, statt einen Fehler zu melden - eine Mutation,
# die das Warten ganz entfernt, waere dann scheinbar gruen (genau so passiert).
DRAW_LINE=$(grep -n "STUB display.py" "$SBX/cmd.log" 2>/dev/null | head -n1 | cut -d: -f1 || true)
LAST_CHECK=$(grep -n "systemctl is-active --quiet ninlight-display" "$SBX/cmd.log" 2>/dev/null | tail -n1 | cut -d: -f1 || true)
CHECKS=$(grep -c "systemctl is-active --quiet ninlight-display" "$SBX/cmd.log" 2>/dev/null || true)
CHECKS=${CHECKS:-0}
if [ -n "$DRAW_LINE" ] && [ -n "$LAST_CHECK" ] && [ "$LAST_CHECK" -lt "$DRAW_LINE" ]; then
pass "alle Abfragen des Anzeigedienstes stehen VOR dem Zeichnen"
else
fail "gezeichnet wurde nicht nach der Freigabe (Zeichnen Zeile '$DRAW_LINE', letzte Abfrage Zeile '$LAST_CHECK')"
fi
[ "$CHECKS" -ge 3 ] && pass "es wurde wirklich gewartet ($CHECKS Abfragen)" || fail "nur $CHECKS Abfrage(n) - es wurde gar nicht auf die Freigabe gewartet"
echo
echo "=== Abschluss D: ein Anzeigedienst, der nicht loslaesst, haelt das Herunterfahren nicht auf ==="
# Lieber kein Abschlussbild als ein haengendes Herunterfahren - und auf keinen Fall ein
# zweiter Prozess auf den belegten Leitungen.
SBX="$TMPROOT/fin4"
setup_sandbox "$SBX"
export FAKE_DISPLAY_ACTIVE_FOR=9999
set +e
ERR=$(NINLIGHT_DISPLAY_RELEASE_WAIT_S=2 run_cli "$SBX" display final poweroff 2>&1 >/dev/null)
RC=$?
set -e
unset FAKE_DISPLAY_ACTIVE_FOR
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (ein Anzeigefehler haelt das Herunterfahren nie auf)" || fail "Exit-Code $RC: $ERR"
grep -q "STUB display.py" "$SBX/cmd.log" && fail "es wurde auf belegte Leitungen gezeichnet - 'GPIO busy'" || pass "nicht gezeichnet, solange der Dienst haelt"
echo "$ERR" | grep -qi "warnung" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR"
# Und es wurde NICHT versucht, den Dienst von hier aus zu stoppen: das liefe mitten in
# der Abschalt-Transaktion.
grep -q "systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'systemctl stop' mitten im Herunterfahren" || pass "kein 'systemctl stop' aus dem Abschaltschritt heraus"
echo
echo "=== Abschluss E: ein scheiterndes display.py bleibt eine Warnung ==="
SBX="$TMPROOT/fin5"
setup_sandbox "$SBX"
export FAKE_DISPLAY_SERVICE_ACTIVE_EXIT=3
set +e
ERR=$(FAKE_DISPLAY_EXIT=1 run_cli "$SBX" display final reboot 2>&1 >/dev/null)
RC=$?
set -e
unset FAKE_DISPLAY_SERVICE_ACTIVE_EXIT
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC - ein Anzeigefehler wuerde das Herunterfahren rot machen: $ERR"
echo "$ERR" | grep -qi "warnung" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR"
echo
if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden."
+31
View File
@@ -53,6 +53,8 @@ setup_sandbox() {
echo "# dummy" >"$sandbox/tools/station/ninlight-station.service"
echo "# dummy" >"$sandbox/tools/station/ninlight-display.service"
echo "# dummy" >"$sandbox/tools/station/ninlight-button.service"
echo "# dummy" >"$sandbox/tools/station/ninlight-poweroff.service"
echo "# dummy" >"$sandbox/tools/station/ninlight-reboot.service"
# display/ wird ECHT kopiert, nicht durch Attrappen ersetzt: die Zusage aus Task 6b
# ist, dass die beiden uebernommenen Panel-Treiber UND der Herkunftsnachweis
# HERKUNFT.md mit auf den Pi gehen. Mit selbst erfundenen Dateinamen im Sandbox-Repo
@@ -185,6 +187,8 @@ run_deploy() {
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \
NINLIGHT_DISPLAY_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-display.service" \
NINLIGHT_BUTTON_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-button.service" \
NINLIGHT_POWEROFF_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-poweroff.service" \
NINLIGHT_REBOOT_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-reboot.service" \
NINLIGHT_SIGN_DIR="$sign_dir" \
"$sandbox/tools/station/deploy.sh" "$@"
}
@@ -1240,6 +1244,33 @@ set -e
&& pass "der Altbestand images/migrate wurde entfernt" \
|| fail "images/migrate liegt noch da"
echo
echo "=== Szenario 32: die beiden Abschlussbild-Units werden mit ausgeliefert ==="
# Ohne sie bliebe auf dem E-Paper nach dem Herunterfahren fuer immer das letzte Bild
# stehen - bei der Abnahme war das die Rueckfrage des Tastermenues.
SANDBOX="$TMPROOT/s32"; STUBBIN="$TMPROOT/bin32"; LOG="$TMPROOT/s32.log"; SANDBOX_STATE="$TMPROOT/s32-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo"
echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
for u in ninlight-poweroff ninlight-reboot; do
[ -f "$SANDBOX_STATE/etc/systemd/system/$u.service" ] \
&& pass "$u.service installiert" || fail "$u.service fehlt - sie waere toter Text im Repo"
done
# Ein Deploy darf keine Unit EINSCHALTEN, die vorher aus war - das macht setup_pi.sh.
grep -Fq "systemctl enable ninlight-poweroff" "$LOG" \
&& fail "der Deploy aktiviert die Abschlussbild-Unit" \
|| pass "der Deploy aktiviert sie nicht (das ist setup_pi.sh's Aufgabe)"
# Und die Ablage ist hinterher leer.
[ ! -e /tmp/ninlight-poweroff.service ] && [ ! -e /tmp/ninlight-reboot.service ] \
&& pass "die Ablage wurde aufgeraeumt" || fail "Unit-Dateien blieben unter /tmp liegen"
echo
if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden."
+123
View File
@@ -1865,3 +1865,126 @@ def test_the_display_and_the_server_agree_on_the_set_names():
spec.loader.exec_module(server)
assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS
assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart -----------------------
#
# E-Paper ist bistabil: das zuletzt gezeichnete Bild bleibt fuer immer stehen, auch
# stromlos. Ohne dieses Bild behauptet das Panel nach dem Ausschalten weiter den Zustand
# von vorher - bei der Abnahme stand dort die Rueckfrage des Tastermenues, und der Pi sah
# aus, als warte er noch auf eine Eingabe.
def _final_text(kind):
return " ".join(render.lines({"mode": "final", "final": kind}))
def test_the_shutdown_and_the_reboot_screen_differ():
"""Der wichtigste Unterschied im ganzen Bild: beim Ausschalten DARF man den Stecker
ziehen, beim Neustart NICHT (an diesem Pi hat ein Stromzug schon eine SD-Karte
gekostet)."""
off, reboot = _final_text("poweroff"), _final_text("reboot")
assert off != reboot
assert "AUSGESCHALTET" in off and "NEUSTART" in reboot
# Ausschalten: erlaubt das Trennen ausdruecklich.
assert "kann getrennt werden" in off
# Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil.
assert "NICHT trennen" in reboot
assert "kann getrennt werden" not in reboot
def test_the_reboot_screen_says_how_long_it_stays():
"""Ein Zero W braucht gut anderthalb Minuten bis zum Anzeigedienst - ohne diesen Satz
sieht das stehende Bild nach "haengt" aus, und genau dann greift jemand zum Stecker."""
assert "min" in _final_text("reboot")
def test_the_final_screen_carries_nothing_else():
"""Kein Bildsatz, keine Fusszeile, kein Zaehler: die Station ist im Begriff zu
verschwinden, und die einzige Auskunft, auf die es jetzt ankommt, ist "Stecker?"."""
state = {"mode": "final", "final": "poweroff", "imageset": "mit-wlan",
"imageset_ok": True, "served": 7}
blocks = render.blocks(state)
assert not any(role == render.ROLE_FOOTER for _, role in blocks)
assert not any("Bildsatz" in text or "Ausgeliefert" in text for text, _ in blocks)
def test_an_unknown_final_kind_claims_neither():
"""Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder
gebootet wird."""
text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"}))
assert "AUSGESCHALTET" not in text
assert "getrennt" not in text
def test_both_final_screens_are_actually_drawable():
for kind in ("poweroff", "reboot"):
image = render.compose({"mode": "final", "final": kind})
assert image.size == (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX)
assert _ink_box(image) is not None, kind
def test_run_final_draws_fully_and_sleeps(monkeypatch):
"""Ueber die ECHTE display.run_final(): Vollaufbau (kein Teilaufbau - dieses eine Bild
ueberdauert alle anderen und darf keine Reste des vorigen tragen) und danach schlafen
legen, damit das Panel nicht unter Bias in den Ruhezustand geht."""
calls = []
class _Panel:
def __init__(self, revision):
calls.append(("init", revision))
def clear(self):
calls.append(("clear", None))
def draw_full(self, image):
calls.append(("full", image))
def draw_partial(self, image):
calls.append(("partial", image))
return False
def sleep(self):
calls.append(("sleep", None))
monkeypatch.setattr(display, "Panel", _Panel)
display.run_final("V3", "reboot")
kinds = [k for k, _ in calls]
assert kinds == ["init", "clear", "full", "sleep"], kinds
# ... und gezeichnet wurde wirklich das Neustart-Bild, nicht irgendeines.
image = dict((k, v) for k, v in calls)["full"]
expected = render.compose({"mode": "final", "final": "reboot"})
assert list(image.getdata()) == list(expected.getdata())
assert list(image.getdata()) != list(
render.compose({"mode": "final", "final": "poweroff"}).getdata())
def test_display_main_passes_the_final_kind_through(monkeypatch):
"""Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild
landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste
ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart
"Strom kann getrennt werden"."""
drawn = []
class _Panel:
def __init__(self, revision):
pass
def clear(self):
pass
def draw_full(self, image):
drawn.append(image)
def sleep(self):
pass
monkeypatch.setattr(display, "Panel", _Panel)
monkeypatch.setenv("EPD_REVISION", "V3")
for kind in ("reboot", "poweroff"):
drawn.clear()
monkeypatch.setattr(sys, "argv", ["display.py", "--final", kind])
display.main()
assert len(drawn) == 1
expected = render.compose({"mode": "final", "final": kind})
assert list(drawn[0].getdata()) == list(expected.getdata()), kind
+53
View File
@@ -146,6 +146,13 @@ echo "STUB systemctl \$*" >>"$log"
# ein Szenario NUR ihn scheitern lassen kann, ohne die Anzeige mitzureissen - sonst
# liesse sich nicht zeigen, dass ein fehlgeschlagenes enable/start des Tasters fuer
# sich genommen keinen Abbruch ausloest.
# Gezielt EINE Unit scheitern lassen (Abschlussbild): FAKE_ENABLE_FAIL_SUBSTR nennt den
# Teilstring des Unit-Namens, dessen "enable" fehlschlaegt.
if [ -n "\${FAKE_ENABLE_FAIL_SUBSTR:-}" ] && [ "\$1" = "enable" ]; then
case "\$*" in
*"\$FAKE_ENABLE_FAIL_SUBSTR"*) exit 1 ;;
esac
fi
case "\$*" in
*ninlight-button*)
case "\$1" in
@@ -217,6 +224,8 @@ run_setup() {
NINLIGHT_STATION_UNIT="$state/etc/systemd/system/ninlight-station.service" \
NINLIGHT_DISPLAY_UNIT="$state/etc/systemd/system/ninlight-display.service" \
NINLIGHT_BUTTON_UNIT="$state/etc/systemd/system/ninlight-button.service" \
NINLIGHT_POWEROFF_UNIT="$state/etc/systemd/system/ninlight-poweroff.service" \
NINLIGHT_REBOOT_UNIT="$state/etc/systemd/system/ninlight-reboot.service" \
NINLIGHT_NM_DIR="$state/etc/NetworkManager/system-connections" \
NINLIGHT_BOOT_CONFIG_NEW="$state/boot/firmware/config.txt" \
NINLIGHT_BOOT_CONFIG_OLD="$state/boot/config.txt" \
@@ -1080,6 +1089,50 @@ for d in migrate-wlan migrate-nocreds; do
|| fail "images/$d wurde leer angelegt"
done
echo
echo "=== Szenario: der Abschlussbildschirm wird installiert UND aktiviert ==="
# Der Abschlussbildschirm gehoert ins System, nicht in den Tasterdienst: so greift er bei
# jedem Herunterfahren (Menue, "poweroff" per SSH, Neustart) und nicht nur auf einem von
# drei Wegen. Zwei Units, weil sich die Texte unterscheiden muessen.
STATE="$TMPROOT/sfin-state"; STUBBIN="$TMPROOT/binfin"; LOG="$TMPROOT/sfin.log"
setup_state "$STATE"
setup_stubbin "$STUBBIN" "$LOG"
: >"$LOG"
set +e
OUT=$(run_setup "$STATE" "$STUBBIN" 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
for u in ninlight-poweroff ninlight-reboot; do
[ -f "$STATE/etc/systemd/system/$u.service" ] \
&& pass "$u.service installiert" || fail "$u.service fehlt"
grep -Fq "systemctl enable $u.service" "$LOG" \
&& pass "$u.service aktiviert" || fail "kein 'systemctl enable $u' im Log"
done
# "enable", aber KEIN "start": diese Units tun beim Systemstart nichts, sie gehoeren zur
# Abschalt-Transaktion. Ein "start" wuerde das Abschlussbild mitten im Betrieb zeichnen.
grep -Fq "systemctl restart ninlight-poweroff" "$LOG" \
&& fail "die Abschlussbild-Unit wird beim Einrichten gestartet - das Bild kaeme sofort" \
|| pass "sie wird nicht gestartet, nur aktiviert"
grep -Fq "systemctl start ninlight-poweroff" "$LOG" \
&& fail "die Abschlussbild-Unit wird beim Einrichten gestartet" \
|| pass "auch kein 'systemctl start'"
echo
echo "=== Szenario: ein fehlgeschlagenes enable des Abschlussbilds warnt nur ==="
# Eine Station, deren Panel nach dem Ausschalten das falsche Bild zeigt, ist aergerlich;
# eine, die sich gar nicht einrichten laesst, ist unbrauchbar.
STATE="$TMPROOT/sfin2-state"; STUBBIN="$TMPROOT/binfin2"; LOG="$TMPROOT/sfin2.log"
setup_state "$STATE"
setup_stubbin "$STUBBIN" "$LOG"
: >"$LOG"
set +e
OUT=$(FAKE_ENABLE_FAIL_SUBSTR=ninlight-poweroff run_setup "$STATE" "$STUBBIN" 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlgeschlagenem enable" || fail "Exit-Code $RC: $OUT"
echo "$OUT" | grep -qi "warnung" && pass "Warnung in der Ausgabe" || fail "keine Warnung: $OUT"
echo
if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden."