Files
ninlight-w600/tools/station/tests/test_cli.sh
T
Michael Sauer 021089a68b feat(station): Panel und CLI auf Englisch, IP-Zeile im Off-Bild
Die deutschen Texte sahen auf dem 2,13"-Panel merkwuerdig aus - jetzt sind
alle Texte, die der Bediener am Geraet oder auf der Konsole sieht, englisch:
Modus-, Phasen-, Bildsatz- und Abschlussbeschriftungen in render.py, die
Bedientexte des Tasterdienstes und saemtliche Ausgaben von ninlight-station
inklusive des Hilfetexts. Die Testbeschreibungen der Suiten bleiben deutsch -
die liest nur, wer die Tests laufen laesst.

Neu im Off-Bild: die Adresse, unter der der Pi gerade erreichbar ist. Ist die
Station aus, ist der Pi ein gewoehnlicher Rechner im Heimnetz, und die
einzige Auskunft, die man dann am Panel braucht, ist die fuer SSH. Die
Adresse kommt aus einem UDP-Socket, der nur verbunden und nie benutzt wird
(kein Datenverkehr, kein DNS, kein Unterprozess in einer 5-s-Schleife) und
liefert genau die Adresse der Standardroute. Ohne Netz sagt die Zeile
ausdruecklich "no network": eine fehlende Zeile wuerde offen lassen, warum
SSH gerade scheitert.

Die Zeile gibt es NUR im Off-Bild - im Betrieb traegt wlan0 die Adresse des
Zugangspunkts, die ohnehin auf jedem Statusbild steht.
2026-09-20 00:28:33 +02:00

1494 lines
77 KiB
Bash
Executable File

#!/usr/bin/env bash
# Stub-Tests fuer ninlight-station (das CLI, nicht server.py) - ohne Pi und ohne
# echte NetworkManager-/systemd-Rechte ausfuehrbar.
#
# nmcli, systemctl und sudo werden im PATH durch Stubs ersetzt, die ihre Aufrufe
# in eine Logdatei schreiben (Muster wie tests/test_deploy.sh fuer ssh/rsync).
# Der nmcli-Stub haelt zusaetzlich einen kleinen Zustand (welche Profile
# "bekannt" sind, welche gerade per "con up" aktiv sind) in Dateien, damit
# "con up"/"con down"/"device show wlan0" konsistent zueinander antworten -
# das ist die Grundlage fuer Fall 15, den wichtigsten Test hier. Der
# systemctl-Stub bildet zusaetzlich nach, dass systemd das RuntimeDirectory bei
# jedem Stop/Restart leert, sofern die Unit kein RuntimeDirectoryPreserve=restart
# traegt - ohne das haette Fixrunde 1's C1 (siehe unten) unsichtbar bleiben
# koennen, weil ein Stub, der nichts loescht, diese Klasse von Bug nicht zeigt.
#
# Aufruf: tools/station/tests/test_cli.sh
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
STATION_DIR=$(cd "$HERE/.." && pwd)
CLI="$STATION_DIR/ninlight-station"
REAL_UNIT="$STATION_DIR/ninlight-station.service"
TMPROOT=$(mktemp -d)
trap 'rm -rf "$TMPROOT"' EXIT
FAILED=0
pass() { echo "ok - $1"; }
fail() { echo "FEHLER - $1" >&2; FAILED=1; }
file_mode() {
# $1 = Pfad. BSD-stat (macOS) und GNU-stat (Linux/Pi) haben unterschiedliche
# Flags - hier reicht "irgendeine funktioniert". Reihenfolge ist wichtig
# (Fixrunde 2, N1): "stat -f '%Lp' datei" auf GNU-coreutils deutet "-f" als
# "Dateisystemstatus" und '%Lp' als ZWEITES Dateiargument - der Aufruf schreibt
# mehrere Zeilen Dateisystem-Infos auf stdout und scheitert (kein passendes
# zweites File), aber der "||"-Zweig haengt DANACH trotzdem noch "644" an, und
# der Vergleich "= 644" schlaegt fehl, obwohl die Rechte stimmen - auf dem Pi
# (echtes GNU-coreutils) also immer rot. BSD-"stat -c" dagegen bricht sofort
# mit einer Fehlermeldung auf stderr ab, ganz ohne Ausgabe auf stdout - der
# GNU-Aufruf muss deshalb ZUERST stehen.
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
}
# Erzwingt den GNU-Zweig von file_mode() (der auf dem Pi tatsaechlich laeuft),
# indem "stat" im PATH auf GNU-coreutils' "gstat" umgebogen wird, falls
# vorhanden (macOS mit "brew install coreutils"). Ohne diese Probe wuerde ein
# falsch herum sortiertes file_mode() auf dieser Entwicklungsmaschine (BSD-stat
# zuerst probiert und erfolgreich) unbemerkt bleiben, obwohl es auf dem Pi
# (nur GNU-stat) rot waere - genau der Fehler aus Fixrunde 2, N1.
if command -v gstat >/dev/null 2>&1; then
GSTAT_CHECK_DIR="$TMPROOT/gstat-check"
mkdir -p "$GSTAT_CHECK_DIR/bin"
ln -sf "$(command -v gstat)" "$GSTAT_CHECK_DIR/bin/stat"
GSTAT_CHECK_FILE="$TMPROOT/gstat-check/probe"
: >"$GSTAT_CHECK_FILE"
chmod 0644 "$GSTAT_CHECK_FILE"
GSTAT_MODE=$(PATH="$GSTAT_CHECK_DIR/bin:$PATH" file_mode "$GSTAT_CHECK_FILE")
[ "$GSTAT_MODE" = "644" ] && pass "file_mode() liefert unter erzwungenem GNU-stat korrekt '644'" || fail "file_mode() liefert unter GNU-stat '$GSTAT_MODE' statt '644' - siehe Fixrunde 2, N1"
else
echo "Hinweis: gstat nicht gefunden - GNU-Zweig von file_mode() wird nur auf dem Pi selbst geprueft" >&2
fi
# --- Stub-Kommandos fuer nmcli/systemctl/sudo im PATH ---
# $1 = bindir, $2 = logdatei, $3 = nmcli-Zustandsverzeichnis, $4 = RUN_DIR,
# $5 = Pfad zur systemd-Unit, deren RuntimeDirectoryPreserve-Zeile ueber das
# Leeren von $4 bei restart/stop entscheidet (wie beim echten systemd).
setup_stubbin() {
local bindir=$1 log=$2 nmstate=$3 rundir=$4 unitfile=$5
mkdir -p "$bindir" "$nmstate"
: >"$nmstate/active" # Reihenfolge der per "con up" aktiven Profile, eine Zeile je Profil
cat >"$bindir/nmcli" <<EOF
#!/usr/bin/env bash
echo "STUB nmcli \$*" >>"$log"
NMSTATE="$nmstate"
# "--escape no" abtrennen, BEVOR die Stellungen geprueft werden - sonst verschoebe der
# Schalter alle Positionen. Und der Stub maskiert wie das echte nmcli: OHNE den Schalter
# wird in den Werten von "-t" jeder Doppelpunkt zu "\\:" (Abschlussreview, M1). Nur so
# prueft der Test das Skript und nicht die Attrappe - ein Stub, der nie maskiert, waere
# mit und ohne den Fix gruen.
ESCAPE=yes
_args=()
while [ \$# -gt 0 ]; do
case "\$1" in
--escape) ESCAPE=\$2; shift 2 ;;
*) _args+=("\$1"); shift ;;
esac
done
set -- "\${_args[@]}"
emit() {
# \$1 = Feldname, \$2 = Rohwert
if [ "\$ESCAPE" = "no" ]; then
echo "\$1:\$2"
else
echo "\$1:\${2//:/\\\\:}"
fi
}
if [ "\$1" = "-t" ] && [ "\$2" = "-f" ] && [ "\$3" = "NAME" ] && [ "\$4" = "con" ] && [ "\$5" = "show" ]; then
while IFS= read -r _profile; do
if [ "\$ESCAPE" = "no" ]; then
echo "\$_profile"
else
echo "\${_profile//:/\\\\:}"
fi
done <"\$NMSTATE/profiles" 2>/dev/null
exit 0
fi
if [ "\$1" = "-t" ] && [ "\$2" = "-f" ] && [ "\$3" = "GENERAL.CONNECTION" ] && [ "\$4" = "device" ] && [ "\$5" = "show" ]; then
# Testschalter fuer Fall I3 (Fixrunde 1): erzwingt eine Antwort, die nicht zum
# zuletzt hochgefahrenen Profil passt, um ap_switch()'s Fehlerzweig zu pruefen.
if [ -n "\${FAKE_NMCLI_DEVSHOW:-}" ]; then
emit GENERAL.CONNECTION "\${FAKE_NMCLI_DEVSHOW}"
exit 0
fi
last=\$(tail -n1 "\$NMSTATE/active" 2>/dev/null)
emit GENERAL.CONNECTION "\${last:---}"
exit 0
fi
if [ "\$1" = "con" ] && [ "\$2" = "up" ]; then
profile=\$3
grep -qxF "\$profile" "\$NMSTATE/profiles" 2>/dev/null || { echo "Error: unknown connection '\$profile'." >&2; exit 1; }
grep -qxF "\$profile" "\$NMSTATE/active" 2>/dev/null || echo "\$profile" >>"\$NMSTATE/active"
exit 0
fi
if [ "\$1" = "con" ] && [ "\$2" = "down" ]; then
# Testschalter fuer Fall I2 (Fixrunde 1): simuliert einen haengenden
# NetworkManager - der Aufruf "gelingt" (Exit 0, wie ein echtes "nmcli con
# down" es bei einem haengenden Backend oft tut), aendert den Zustand aber
# nicht. ap_switch() darf das nicht als "aus" durchgehen lassen.
if [ -n "\${FAKE_NMCLI_STUCK:-}" ]; then
exit 0
fi
profile=\$3
grep -vxF "\$profile" "\$NMSTATE/active" 2>/dev/null >"\$NMSTATE/active.tmp" || true
mv "\$NMSTATE/active.tmp" "\$NMSTATE/active"
exit 0
fi
exit 0
EOF
cat >"$bindir/systemctl" <<EOF
#!/usr/bin/env bash
echo "STUB systemctl \$*" >>"$log"
RUN_DIR="$rundir"
UNIT_FILE="$unitfile"
case "\$1" in
is-active)
# Getrennt schaltbar je Unit: der Handbetrieb am Panel fragt nach ninlight-display
# (laeuft der Anzeigedienst?), alles andere nach ninlight-station. Die Unit ist
# immer das letzte Argument ("is-active --quiet <unit>" bzw. "is-active <unit>").
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
if [ "\$code" -eq 0 ]; then
echo active
exit 0
else
echo inactive
exit "\$code"
fi
;;
start)
# Testschalter fuer die Resilienz-Faelle: bildet nach, dass "ninlight-display"
# gar nicht installiert oder absichtlich maskiert ist (systemctl gibt dann einen
# Fehler zurueck statt still zu tun, als waere alles gut).
if [ "\$2" = "ninlight-display" ] && [ -n "\${FAKE_DISPLAY_SERVICE_MISSING:-}" ]; then
echo "Failed to start ninlight-display.service: Unit not found." >&2
exit 5
fi
exit 0
;;
restart|stop)
# Bildet systemds RuntimeDirectory-Verhalten nach (Fixrunde 1, C1): ohne
# RuntimeDirectoryPreserve=restart in der Unit leert systemd das
# RuntimeDirectory bei JEDEM Stop/Restart, nicht nur beim allerersten Start,
# und legt es leer wieder an.
#
# Die Regel gilt nur fuer ninlight-station - \$UNIT_FILE ist deren Unit-Datei.
# ninlight-display traegt gar kein RuntimeDirectory=, ein "stop ninlight-display"
# raeumt in Wirklichkeit nichts weg (frueher tat der Stub das trotzdem fuer JEDE
# Unit; harmlos, solange kein Testfall die Anzeige stoppt und danach noch Dateien
# in RUN_DIR erwartet - genau das tun 'display test'/'display clear' jetzt aber,
# die den Anzeigedienst waehrend des Handgriffs stoppen).
if [ "\$2" = "ninlight-station" ] \
&& ! grep -q '^RuntimeDirectoryPreserve=restart' "\$UNIT_FILE" 2>/dev/null; then
rm -rf "\$RUN_DIR"
mkdir -p "\$RUN_DIR"
fi
if [ "\$2" = "ninlight-display" ] && [ -n "\${FAKE_DISPLAY_SERVICE_MISSING:-}" ]; then
echo "Failed to stop ninlight-display.service: Unit not found." >&2
exit 5
fi
if [ "\$1" = "restart" ]; then
exit "\${FAKE_SYSTEMCTL_RESTART_EXIT:-0}"
fi
exit 0
;;
*) exit 0 ;;
esac
EOF
cat >"$bindir/sudo" <<EOF
#!/usr/bin/env bash
echo "STUB sudo \$*" >>"$log"
exec "\$@"
EOF
# Der Zeitschalter legt seinen Timer per "systemd-run --unit=..." an; der Stub
# protokolliert nur, was angefordert wurde (der ECHTE Beleg dafuer, dass ein
# Zeitschalter existiert bzw. nicht existiert, ist unten immer die Zustandsdatei
# $RUN_DIR/autooff.json und das Verhalten des Skripts, nicht diese Zeile).
cat >"$bindir/systemd-run" <<EOF
#!/usr/bin/env bash
echo "STUB systemd-run \$*" >>"$log"
exit "\${FAKE_SYSTEMD_RUN_EXIT:-0}"
EOF
# "ss" beantwortet die Frage "laeuft gerade eine Auslieferung?" - mit
# FAKE_SS_BUSY=1 gibt es eine offene Verbindung aus dem Stationsnetz auf Port 80.
cat >"$bindir/ss" <<EOF
#!/usr/bin/env bash
echo "STUB ss \$*" >>"$log"
if [ -n "\${FAKE_SS_BUSY:-}" ]; then
echo "ESTAB 0 0 192.168.8.1:80 192.168.8.42:52344"
fi
exit 0
EOF
chmod +x "$bindir"/nmcli "$bindir"/systemctl "$bindir"/sudo \
"$bindir"/systemd-run "$bindir"/ss
}
# --- Sandbox fuer einen Testfall: eigene conf/RUN_DIR/IMAGE_DIR/nmcli-Zustand ---
# $1 = sandbox-Verzeichnis, $2 = Unit-Datei fuer den systemctl-Stub (Default: die
# echte, gerade zu testende tools/station/ninlight-station.service).
setup_sandbox() {
local sandbox=$1 unitfile=${2:-$REAL_UNIT}
rm -rf "$sandbox"
mkdir -p "$sandbox/run" "$sandbox/images/migrate-nocreds" "$sandbox/images/migrate-wlan" \
"$sandbox/bin" "$sandbox/nmstate" "$sandbox/lib/display" "$sandbox/var"
# Je ein Image je Bildsatz - "vorhanden" heisst fuer CLI, Server und Anzeige
# gleichermassen "mindestens ein Image darin".
echo "ohne" >"$sandbox/images/migrate-nocreds/foo_dev.img"
echo "mit" >"$sandbox/images/migrate-wlan/foo_dev.img"
echo "dummy" >"$sandbox/images/ninlight_foo_ota.img"
# NINLIGHT_STATION_LIB zeigt auf diese Sandbox statt auf tools/station selbst.
# products.py wird gebraucht (arm prueft das Ziel dagegen) und deshalb echt kopiert;
# display/display.py ist ein Rekorder, der seine Argumente in DIESELBE cmd.log
# schreibt wie die nmcli-/systemctl-Stubs. Nur so laesst sich die REIHENFOLGE
# "erst Dienst stoppen, dann Panel leeren" ueberhaupt pruefen - mit zwei getrennten
# Logs waere sie nicht rekonstruierbar.
#
# Der Rekorder ersetzt das echte display.py absichtlich: geprueft wird hier, WAS
# ninlight-station aufruft (Pfad, Schalter, Reihenfolge, Umgang mit dem Rueckgabe-
# wert), nicht was display.py daraus macht - das deckt tests/test_render.py ab.
cp "$STATION_DIR/products.py" "$sandbox/lib/products.py"
cat >"$sandbox/lib/display/display.py" <<EOF
import os
import signal
import sys
with open("$sandbox/cmd.log", "a") as fh:
fh.write("STUB display.py %s (EPD_REVISION=%s)\n"
% (" ".join(sys.argv[1:]), os.environ.get("EPD_REVISION", "<unset>")))
# Testschalter fuer Fall D2c: stellt das Strg-C des Abnehmers nach, waehrend display.py
# laeuft (bei falscher Panel-Revision kehrt ReadBusy() nie zurueck - das ist der
# dokumentierte Weg, die Revision zu bestimmen, und damit der Regelfall fuer ein Strg-C).
#
# Nachgestellt wird, was das Terminal wirklich tut: das Signal geht an die GANZE
# Vordergrund-Prozessgruppe, also an die ninlight-station-Shell UND an diesen Prozess.
# Der Elternprozess ist dabei die Shell selbst ("sudo" und "env" sind Stub bzw.
# exec-Aufruf, der Python-Prozess haengt also direkt unter ihr).
#
# Dass dieser Prozess AM SIGNAL STIRBT (Standardbehandlung) statt sich mit 128+n zu
# beenden, ist kein Detail: eine Shell unterscheidet beides. Beendet sich das Kind
# normal, verwirft /bin/sh (bash im sh-Modus, macOS) das inzwischen zugestellte SIGINT
# und laeuft weiter - der Testfall waere dann auch ohne Signalhandler in
# display_handover() gruen und wuerde gar nichts pruefen (beim Schreiben dieses Tests
# genau so passiert).
signal_name = os.environ.get("FAKE_DISPLAY_SIGNAL", "")
if signal_name:
number = getattr(signal, "SIG" + signal_name)
os.kill(os.getppid(), number)
signal.signal(number, signal.SIG_DFL)
os.kill(os.getpid(), number)
sys.exit(int(os.environ.get("FAKE_DISPLAY_EXIT", "0")))
EOF
cat >"$sandbox/station.conf" <<EOF
SSID='test-recovery'
PSK='test-psk'
IMAGE_DIR='$sandbox/images'
HOME_CONNECTION='home-wifi'
AP_CONNECTION='ninlight-recovery'
MIGRATE_SSID='qconnex-firmware'
MIGRATE_PSK='qconnex-firmware'
MIGRATE_AP_CONNECTION='qconnex-firmware'
EPD_REVISION='V3'
EOF
printf 'ninlight-recovery\nqconnex-firmware\nhome-wifi\n' >"$sandbox/nmstate/profiles"
: >"$sandbox/nmstate/active"
setup_stubbin "$sandbox/bin" "$sandbox/cmd.log" "$sandbox/nmstate" "$sandbox/run" "$unitfile"
}
# Fuehrt ninlight-station mit gestubbtem PATH und ueberschriebenen Pfaden aus.
# $1 = sandbox-Verzeichnis, Rest = Argumente fuer ninlight-station.
run_cli() {
local sandbox=$1; shift
PATH="$sandbox/bin:$PATH" \
NINLIGHT_STATION_CONF="$sandbox/station.conf" \
NINLIGHT_RUN_DIR="$sandbox/run" \
NINLIGHT_STATION_LIB="$sandbox/lib" \
NINLIGHT_IMAGESET_FILE="$sandbox/var/imageset" \
"$CLI" "$@"
}
control_phase() {
# $1 = Pfad zu control.json
python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('phase','?'))" "$1" 2>/dev/null || echo "FEHLT"
}
control_target() {
python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('armed_target'))" "$1" 2>/dev/null || echo "FEHLT"
}
echo "=== Fall 1: 'on' faehrt ninlight-recovery hoch, startet den Dienst neu, meldet 'Station an' ==="
SBX="$TMPROOT/t1"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch: $(cat "$SBX/run/mode" 2>&1)"
echo "$OUT" | grep -q "Station on" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
echo
echo "=== Fall 2: 'recovery on' identisch zu 'on' (Rueckwaertskompatibilitaet) ==="
SBX="$TMPROOT/t2"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" recovery on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -q "nmcli con up ninlight-recovery" "$SBX/cmd.log" && pass "nmcli con up ninlight-recovery aufgerufen" || fail "nmcli con up ninlight-recovery fehlt im Log"
grep -q "systemctl restart ninlight-station" "$SBX/cmd.log" && pass "Dienst (neu) gestartet" || fail "kein systemctl restart im Log"
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "Modusdatei enthaelt 'recovery'" || fail "Modusdatei falsch"
echo "$OUT" | grep -q "Station on" && pass "Ausgabe enthaelt 'Station an'" || fail "Ausgabe fehlt 'Station an': $OUT"
echo
echo "=== Fall 3: 'migrate on' faehrt ninlight-recovery herunter, BEVOR qconnex-firmware hochkommt; control.json phase=auto ==="
SBX="$TMPROOT/t3"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
run_cli "$SBX" on >/dev/null 2>&1 # Recovery vorher an
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" migrate on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
DOWN_LINE=$(grep -n "nmcli con down ninlight-recovery" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true)
UP_LINE=$(grep -n "nmcli con up qconnex-firmware" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true)
if [ -n "$DOWN_LINE" ] && [ -n "$UP_LINE" ] && [ "$DOWN_LINE" -lt "$UP_LINE" ]; then
pass "ninlight-recovery wird VOR qconnex-firmware heruntergefahren"
else
fail "Reihenfolge falsch oder Aufrufe fehlen (down=$DOWN_LINE, up=$UP_LINE): $(cat "$SBX/cmd.log")"
fi
[ "$(cat "$SBX/run/mode")" = "migration" ] && pass "Modusdatei enthaelt 'migration'" || fail "Modusdatei falsch"
[ "$(control_phase "$SBX/run/control.json")" = "auto" ] && pass "control.json: phase=auto" || fail "control.json phase ist nicht 'auto': $(cat "$SBX/run/control.json" 2>&1)"
echo
echo "=== Fall 4: 'migrate on --listen' wie Fall 3, aber phase=listen ==="
SBX="$TMPROOT/t4"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
run_cli "$SBX" on >/dev/null 2>&1
set +e
OUT=$(run_cli "$SBX" migrate on --listen 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ "$(control_phase "$SBX/run/control.json")" = "listen" ] && pass "control.json: phase=listen" || fail "control.json phase ist nicht 'listen': $(cat "$SBX/run/control.json" 2>&1)"
echo
echo "=== Fall 5: 'migrate on' ohne Migrationsimages -> Warnung auf stderr, Rueckgabewert 0 ==="
SBX="$TMPROOT/t5"
setup_sandbox "$SBX"
# der gewaehlte Bildsatz ("ohne-wlan") wird geleert - setup_sandbox bestueckt beide
rm -f "$SBX/images/migrate-nocreds"/*.img
set +e
OUT=$(run_cli "$SBX" migrate on 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -eq 0 ] && pass "Exit-Code 0 trotz fehlender Migrationsimages" || fail "Exit-Code $RC, stderr: $ERR"
echo "$ERR" | grep -q "Warning" && pass "Warnung steht auf stderr" || fail "keine Warnung auf stderr: $ERR"
echo
echo "=== Fall 6: 'arm' ohne Argument liest last_seen.target aus status.json ==="
SBX="$TMPROOT/t6"
setup_sandbox "$SBX"
cat >"$SBX/run/status.json" <<'JSON'
{"mode": "migration", "phase": "listen", "last_seen": {"target": "gu10_rgb", "product_name": "Klyqa GU10 RGB CW/WW", "lamp_version": "V1.1.0.1138"}}
JSON
set +e
OUT=$(run_cli "$SBX" arm 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "control.json: phase=armed" || fail "phase ist nicht 'armed'"
[ "$(control_target "$SBX/run/control.json")" = "gu10_rgb" ] && pass "armed_target=gu10_rgb (aus last_seen)" || fail "armed_target falsch: $(cat "$SBX/run/control.json")"
[ "$(file_mode "$SBX/run/control.json")" = "644" ] && pass "control.json ist 0644" || fail "control.json hat falsche Rechte: $(file_mode "$SBX/run/control.json")"
echo
echo "=== Fall 7: 'arm gu10_rgb' mit explizitem Ziel ==="
SBX="$TMPROOT/t7"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" arm gu10_rgb 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "control.json: phase=armed" || fail "phase ist nicht 'armed'"
[ "$(control_target "$SBX/run/control.json")" = "gu10_rgb" ] && pass "armed_target=gu10_rgb" || fail "armed_target falsch: $(cat "$SBX/run/control.json")"
[ "$(file_mode "$SBX/run/control.json")" = "644" ] && pass "control.json ist 0644" || fail "control.json hat falsche Rechte: $(file_mode "$SBX/run/control.json")"
echo
echo "=== Fall 8: 'arm' ohne last_seen -> Fehler auf stderr, Rueckgabewert != 0, control.json unveraendert ==="
SBX="$TMPROOT/t8"
setup_sandbox "$SBX"
# keine status.json vorhanden
set +e
OUT=$(run_cli "$SBX" arm 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR"
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt: $(cat "$SBX/run/control.json")"
echo
echo "=== Fall 9: 'arm unbekannt' -> Fehler auf stderr, Rueckgabewert != 0 ==="
SBX="$TMPROOT/t9"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" arm unbekannt 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 ($RC)" || fail "Exit-Code war 0"
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung steht auf stderr" || fail "keine Fehlermeldung auf stderr: $ERR"
[ ! -e "$SBX/run/control.json" ] && pass "control.json wurde nicht angelegt" || fail "control.json wurde trotz Fehler angelegt"
echo
echo "=== Fall 10: 'listen' setzt phase=listen ==="
SBX="$TMPROOT/t10"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" listen 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ "$(control_phase "$SBX/run/control.json")" = "listen" ] && pass "control.json: phase=listen" || fail "phase ist nicht 'listen': $(cat "$SBX/run/control.json" 2>&1)"
[ "$(file_mode "$SBX/run/control.json")" = "644" ] && pass "control.json ist 0644" || fail "control.json hat falsche Rechte: $(file_mode "$SBX/run/control.json")"
echo
echo "=== Fall 11: 'off' faehrt beide Profile herunter, stoppt den Dienst, zurueck ins Heim-WLAN ==="
SBX="$TMPROOT/t11"
setup_sandbox "$SBX"
run_cli "$SBX" migrate on >/dev/null 2>&1
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" off 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -q "systemctl stop ninlight-station" "$SBX/cmd.log" && pass "Dienst gestoppt" || fail "kein systemctl stop im Log"
ACTIVE=$(cat "$SBX/nmstate/active")
echo "$ACTIVE" | grep -qx "ninlight-recovery" && fail "ninlight-recovery ist noch aktiv" || pass "ninlight-recovery ist nicht mehr aktiv"
echo "$ACTIVE" | grep -qx "qconnex-firmware" && fail "qconnex-firmware ist noch aktiv" || pass "qconnex-firmware ist nicht mehr aktiv"
echo "$ACTIVE" | grep -qx "home-wifi" && pass "zurueck im Heim-WLAN (home-wifi aktiv)" || fail "home-wifi ist nicht aktiv: $ACTIVE"
echo "$OUT" | grep -q "home Wi-Fi" && pass "Ausgabe erwaehnt Heim-WLAN" || fail "Ausgabe erwaehnt Heim-WLAN nicht: $OUT"
echo
echo "=== Fall 12: 'status' im Migrationsmodus zeigt Modus, Phase und zuletzt erkannte Lampe ==="
SBX="$TMPROOT/t12"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
run_cli "$SBX" migrate on >/dev/null 2>&1 # phase=auto in control.json, wie es der echte Ablauf waere
# status.json so, wie server.py sie beim Start geschrieben haette (mode/last_seen).
cat >"$SBX/run/status.json" <<'JSON'
{"mode": "migration",
"last_seen": {"target": "gu10_rgb", "product_name": "Klyqa GU10 RGB CW/WW", "lamp_version": "V1.1.0.1138"}}
JSON
set +e
OUT=$(run_cli "$SBX" status 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
echo "$OUT" | grep -q "Mode.*migration" && pass "Modus wird angezeigt" || fail "Modus fehlt: $OUT"
echo "$OUT" | grep -q "Phase.*auto" && pass "Phase wird angezeigt" || fail "Phase fehlt: $OUT"
echo "$OUT" | grep -q "Detected.*Klyqa GU10 RGB CW/WW" && pass "zuletzt erkannte Lampe wird angezeigt" || fail "erkannte Lampe fehlt: $OUT"
echo
echo "=== Fall 13: 'status' mit unlesbarer status.json meldet das ausdruecklich (4a-Fehler) ==="
SBX="$TMPROOT/t13"
setup_sandbox "$SBX"
echo '{"mode": "recovery"}' >"$SBX/run/status.json"
chmod 000 "$SBX/run/status.json"
set +e
OUT=$(run_cli "$SBX" status 2>&1)
RC=$?
set -e
chmod 644 "$SBX/run/status.json" # damit trap-Aufraeumen am Ende sicher klappt
[ "$RC" -eq 0 ] && pass "Exit-Code 0 (status liefert trotzdem eine Auskunft)" || fail "Exit-Code $RC, Ausgabe: $OUT"
echo "$OUT" | grep -q "not readable" && pass "unlesbare status.json wird ausdruecklich gemeldet" || fail "Meldung zu unlesbarer status.json fehlt: $OUT"
echo
echo "=== Fall 14: unbekannter Unterbefehl -> Aufrufhinweis, Rueckgabewert 2 ==="
SBX="$TMPROOT/t14"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" frobnicate 2>&1)
RC=$?
set -e
[ "$RC" -eq 2 ] && pass "Exit-Code 2" || fail "Exit-Code war $RC statt 2"
echo "$OUT" | grep -qi "usage" && pass "Aufrufhinweis vorhanden" || fail "kein Aufrufhinweis: $OUT"
echo
echo "=== Fall 15 (wichtigster Test): 'migrate on' bei laufendem Recovery-Modus laesst GENAU EIN Profil aktiv ==="
SBX="$TMPROOT/t15"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
run_cli "$SBX" on >/dev/null 2>&1
ACTIVE_BEFORE=$(cat "$SBX/nmstate/active")
echo "$ACTIVE_BEFORE" | grep -qx "ninlight-recovery" && pass "Vorbedingung: ninlight-recovery laeuft" || fail "Vorbedingung nicht erfuellt: $ACTIVE_BEFORE"
set +e
OUT=$(run_cli "$SBX" migrate on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
COUNT=$(echo "$ACTIVE_AFTER" | grep -c . || true)
if [ "$COUNT" -eq 1 ] && [ "$ACTIVE_AFTER" = "qconnex-firmware" ]; then
pass "genau ein Profil aktiv: qconnex-firmware"
else
fail "erwartet genau 'qconnex-firmware', tatsaechlich aktiv: [$ACTIVE_AFTER]"
fi
DEVSHOW=$(PATH="$SBX/bin:$PATH" nmcli -t -f GENERAL.CONNECTION device show wlan0)
[ "$DEVSHOW" = "GENERAL.CONNECTION:qconnex-firmware" ] && pass "nmcli device show wlan0 bestaetigt qconnex-firmware" || fail "device show liefert: $DEVSHOW"
echo
echo "=== Zusatzfall C1: ohne RuntimeDirectoryPreserve=restart wuerde der Neustart mode/control.json wegwischen - das CLI muss das erkennen und abbrechen ==="
SBX="$TMPROOT/tC1"
BROKEN_UNIT="$TMPROOT/broken-ninlight-station.service"
grep -v '^RuntimeDirectoryPreserve=restart' "$REAL_UNIT" >"$BROKEN_UNIT"
setup_sandbox "$SBX" "$BROKEN_UNIT"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
set +e
OUT=$(run_cli "$SBX" migrate on --listen 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - eine geleerte RuntimeDirectory wird nicht als Erfolg gemeldet" || fail "Exit-Code war 0, obwohl RUN_DIR beim Neustart geleert wurde: $OUT"
echo "$ERR" | grep -qi "RuntimeDirectoryPreserve" && pass "Fehlermeldung nennt RuntimeDirectoryPreserve als Ursache" || fail "Fehlermeldung nennt die Ursache nicht: $ERR"
echo "$OUT" | grep -q "Migration on" && fail "CLI meldete faelschlich 'Migration an', obwohl die Phase nach dem Neustart nicht mehr stimmte" || pass "kein falscher Erfolgsmeldung 'Migration an'"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
[ -z "$ACTIVE_AFTER" ] && pass "Zugangspunkt wurde nach dem erkannten Fehler wieder heruntergefahren" || fail "Zugangspunkt blieb aktiv: $ACTIVE_AFTER"
# Gegenprobe: dieselbe Sandbox, aber mit der ECHTEN (reparierten) Unit-Datei -
# jetzt darf RUN_DIR beim Neustart nicht geleert werden und der Aufruf muss
# durchlaufen. Das ist die eigentliche Bestaetigung, dass RuntimeDirectoryPreserve
# in tools/station/ninlight-station.service tatsaechlich steht und wirkt.
SBX2="$TMPROOT/tC1-fixed"
setup_sandbox "$SBX2"
echo "dummy" >"$SBX2/images/migrate-nocreds/gu10_rgb_prod.img"
set +e
OUT2=$(run_cli "$SBX2" migrate on --listen 2>&1)
RC2=$?
set -e
[ "$RC2" -eq 0 ] && pass "mit der echten (reparierten) Unit-Datei gelingt derselbe Aufruf" || fail "Gegenprobe schlug fehl - Exit-Code $RC2: $OUT2"
[ "$(control_phase "$SBX2/run/control.json")" = "listen" ] && pass "Gegenprobe: control.json ueberlebt den Neustart mit phase=listen" || fail "Gegenprobe: Phase nach Neustart falsch: $(cat "$SBX2/run/control.json" 2>&1)"
echo
echo "=== Zusatzfall I1: 'status' zeigt die Phase aus control.json, nicht die veraltete Phase aus status.json ==="
SBX="$TMPROOT/tI1"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
run_cli "$SBX" migrate on --listen >/dev/null 2>&1
# status.json wird nur beim Dienststart/bei einer Auslieferung geschrieben - hier
# simuliert, wie es nach dem Start aussehen wuerde (Phase "listen", wie beim Start).
cat >"$SBX/run/status.json" <<'JSON'
{"mode": "migration", "phase": "listen",
"last_seen": {"target": "gu10_rgb", "product_name": "Klyqa GU10 RGB CW/WW", "lamp_version": "V1.1.0.1138"}}
JSON
run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1 # startet den Dienst NICHT neu - status.json bleibt bei "listen"
OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -q "Phase.*armed" && pass "status zeigt 'armed' (aus control.json), nicht das veraltete 'listen' aus status.json" || fail "status zeigt die falsche/veraltete Phase: $OUT"
echo "$OUT" | grep -q "gu10_rgb" && pass "status nennt das freigegebene Ziel" || fail "status nennt das armed_target nicht: $OUT"
echo
echo "=== Zusatzfall I2: 'off' bricht mit Fehler ab, wenn ein Stationsprofil trotz 'con down' aktiv bleibt (haengender NetworkManager) ==="
SBX="$TMPROOT/tI2"
setup_sandbox "$SBX"
run_cli "$SBX" on >/dev/null 2>&1
# HOME_CONNECTION aus den bekannten Profilen entfernen, um zugleich den in I2
# genannten zweiten Umstand abzudecken (Zielprofil fuers Heim-WLAN fehlt).
grep -v '^home-wifi$' "$SBX/nmstate/profiles" >"$SBX/nmstate/profiles.tmp"
mv "$SBX/nmstate/profiles.tmp" "$SBX/nmstate/profiles"
set +e
OUT=$(FAKE_NMCLI_STUCK=1 run_cli "$SBX" off 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0 - ein haengender Zugangspunkt wird nicht als 'Station aus' gemeldet" || fail "Exit-Code war 0, obwohl ninlight-recovery weiterhin aktiv war: $OUT"
echo "$OUT" | grep -q "Station off" && fail "CLI behauptete faelschlich 'Station aus'" || pass "keine falsche 'Station aus'-Meldung"
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr vorhanden" || fail "keine Fehlermeldung auf stderr: $ERR"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
[ "$ACTIVE_AFTER" = "ninlight-recovery" ] && pass "ninlight-recovery ist (wie vom Stub simuliert) tatsaechlich noch aktiv" || fail "unerwarteter nmcli-Zustand: $ACTIVE_AFTER"
echo
echo "=== Zusatzfall M1 (Abschlussreview): ein Profilname MIT Doppelpunkt wird beim Nachpruefen richtig gelesen ==="
# "nmcli -t" maskiert Doppelpunkte in Werten als "\:", und "cut -d: -f2" schneidet am
# ersten Doppelpunkt ab. Ohne "--escape no" UND "-f2-" liest ap_switch() aus einem
# Profil "ninlight:recovery" nur "ninlight\" bzw. "ninlight" - der Vergleich schluege
# fehl, und zwar in die UNSICHERE Richtung: "Station aus" gemeldet, waehrend der
# Zugangspunkt weitersendet. Aufbau wie Zusatzfall I2, nur mit Doppelpunkt im Namen.
SBX="$TMPROOT/tM1"
setup_sandbox "$SBX"
sed -i.bak "s/^AP_CONNECTION=.*/AP_CONNECTION='ninlight:recovery'/" "$SBX/station.conf"
rm -f "$SBX/station.conf.bak"
printf 'ninlight:recovery\nqconnex-firmware\nhome-wifi\n' >"$SBX/nmstate/profiles"
set +e
OUT=$(run_cli "$SBX" on 2>"$SBX/stderr.log")
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "'on' kommt mit einem Doppelpunkt im Profilnamen zurecht" \
|| fail "'on' scheiterte an einem Doppelpunkt im Profilnamen (rc=$RC): $OUT $(cat "$SBX/stderr.log")"
[ "$(cat "$SBX/nmstate/active")" = "ninlight:recovery" ] \
&& pass "das Profil mit Doppelpunkt ist aktiv" || fail "unerwarteter nmcli-Zustand: $(cat "$SBX/nmstate/active")"
set +e
OUT=$(FAKE_NMCLI_STUCK=1 run_cli "$SBX" off 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] \
&& pass "Exit-Code != 0 - der haengende Zugangspunkt wird trotz Doppelpunkt im Namen erkannt" \
|| fail "Exit-Code war 0, obwohl 'ninlight:recovery' weiterhin aktiv war: $OUT"
echo "$OUT" | grep -q "Station off" && fail "CLI behauptete faelschlich 'Station aus'" \
|| pass "keine falsche 'Station aus'-Meldung"
echo "$ERR" | grep -q "ninlight:recovery" \
&& pass "die Fehlermeldung nennt den vollstaendigen Profilnamen, nicht den abgeschnittenen" \
|| fail "Fehlermeldung nennt den Profilnamen nicht vollstaendig: $ERR"
echo
echo "=== Zusatzfall I3: ap_switch bricht ab und faehrt beide Profile herunter, wenn 'device show wlan0' nach dem Umschalten nicht passt ==="
SBX="$TMPROOT/tI3"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
set +e
OUT=$(FAKE_NMCLI_DEVSHOW=fremdes-profil run_cli "$SBX" migrate on 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0, wenn wlan0 nach dem Umschalten ein fremdes Profil zeigt" || fail "Exit-Code war 0 trotz Profil-Mismatch: $OUT"
echo "$ERR" | grep -q "unexpectedly" && pass "Fehlermeldung nennt den Mismatch" || fail "Fehlermeldung fehlt: $ERR"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
[ -z "$ACTIVE_AFTER" ] && pass "beide Stationsprofile wurden nach dem erkannten Mismatch heruntergefahren" || fail "mindestens ein Profil blieb aktiv: $ACTIVE_AFTER"
echo
echo "=== Zusatzfall N2 (Fixrunde 2): 'status' zeigt keine veraltete Migrationsphase mehr, nachdem in den Rettungsmodus zurueckgeschaltet wurde ==="
SBX="$TMPROOT/tN2"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
run_cli "$SBX" migrate on --listen >/dev/null 2>&1
run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1 # control.json bleibt mit phase=armed liegen
run_cli "$SBX" on >/dev/null 2>&1 # zurueck in den Rettungsmodus - control.json wird bewusst NICHT aufgeraeumt
[ "$(control_phase "$SBX/run/control.json")" = "armed" ] && pass "Vorbedingung: control.json ist tatsaechlich noch mit phase=armed liegen geblieben" || fail "Vorbedingung nicht erfuellt (control.json wurde schon vorher veraendert)"
# status.json so, wie sie der Server nach dem Neustart im Rettungsmodus schreiben
# wuerde (mode=recovery, keine last_seen/phase-Felder - siehe server.py write_state()).
cat >"$SBX/run/status.json" <<'JSON'
{"mode": "recovery"}
JSON
OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -qE "Mode[[:space:]]*: recovery" && pass "Modus zeigt korrekt 'recovery'" || fail "Modus falsch: $OUT"
echo "$OUT" | grep -qi "armed" && fail "status zeigt weiterhin die veraltete Migrationsphase 'armed' im Rettungsmodus: $OUT" || pass "keine veraltete Migrationsphase 'armed' im Rettungsmodus"
echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt den neutralen Platzhalter '-' ausserhalb des Migrationsmodus" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT"
echo
echo "=== Zusatzfall N2b (Fixrunde 2): dieselbe veraltete Freigabe, aber ohne aktualisierte status.json (Modus '-') - genau das woertliche Repro aus dem Review ==="
SBX="$TMPROOT/tN2b"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
run_cli "$SBX" migrate on --listen >/dev/null 2>&1
run_cli "$SBX" arm gu10_rgb >/dev/null 2>&1
run_cli "$SBX" on >/dev/null 2>&1
rm -f "$SBX/run/status.json" # kein Serverprozess in diesem Test aktualisiert sie
OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -qE "Mode[[:space:]]*: -" && pass "Modus zeigt '-' (keine status.json vorhanden)" || fail "Modus unerwartet: $OUT"
echo "$OUT" | grep -qi "armed" && fail "status zeigt 'armed', obwohl der Modus gar nicht bekannt ist: $OUT" || pass "keine 'armed'-Phase bei unbekanntem Modus"
echo "$OUT" | grep -qE "Phase[[:space:]]*: -" && pass "Phase zeigt '-' statt einer Vermutung" || fail "Phase zeigt keinen neutralen Platzhalter: $OUT"
echo
echo "=== Fall D1 (Task 6b, Punkt 3): 'display test' ruft display.py mit --test und der konfigurierten Revision auf ==="
SBX="$TMPROOT/tD1"
setup_sandbox "$SBX"
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" display test 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -Fq "STUB display.py --test" "$SBX/cmd.log" && pass "display.py wurde mit --test aufgerufen" || fail "kein '--test'-Aufruf im Log: $(cat "$SBX/cmd.log")"
# Der Testbild-Aufruf ist die einzige Moeglichkeit, die Panel-Revision zu bestimmen -
# er ist wertlos, wenn er nicht die Revision aus der Konfiguration durchreicht:
# EPD_REVISION wird per ". $CONF" nur als Shell-Variable gesetzt, nicht exportiert.
grep -Fq "STUB display.py --test (EPD_REVISION=V3)" "$SBX/cmd.log" && pass "EPD_REVISION aus der Konfiguration erreicht den Python-Prozess" || fail "EPD_REVISION kam nicht durch: $(cat "$SBX/cmd.log")"
# Der Anzeigedienst haelt die GPIO-Leitungen; ohne ihn kurz zu stoppen scheitert der
# Handgriff am Geraet mit "lgpio.error: 'GPIO busy'" (Hardware-Abnahme). Frueher stand
# hier die umgekehrte Zusage ("laesst den Anzeigedienst in Ruhe") - die war genau der
# Grund, warum der erste Handgriff der Abnahme nicht funktionierte.
STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true)
TEST_LINE=$(grep -n "STUB display.py --test" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true)
START_LINE=$(grep -n "STUB systemctl start ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true)
if [ -n "$STOP_LINE" ] && [ -n "$TEST_LINE" ] && [ "$STOP_LINE" -lt "$TEST_LINE" ]; then
pass "der Anzeigedienst wird gestoppt, BEVOR display.py die GPIO-Leitungen anfasst"
else
fail "Anzeigedienst nicht (rechtzeitig) gestoppt (stop=$STOP_LINE, test=$TEST_LINE): $(cat "$SBX/cmd.log")"
fi
# Und danach wieder an: ein Abnahme-Handgriff darf die Station nicht dauerhaft ohne
# Anzeige zuruecklassen.
if [ -n "$START_LINE" ] && [ -n "$TEST_LINE" ] && [ "$TEST_LINE" -lt "$START_LINE" ]; then
pass "der Anzeigedienst laeuft danach wieder (start=$START_LINE)"
else
fail "Anzeigedienst wurde nicht wieder gestartet (test=$TEST_LINE, start=$START_LINE): $(cat "$SBX/cmd.log")"
fi
echo
echo "=== Fall D2: 'display clear' ruft display.py mit --clear auf ==="
SBX="$TMPROOT/tD2"
setup_sandbox "$SBX"
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" display clear 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -Fq "STUB display.py --clear" "$SBX/cmd.log" && pass "display.py wurde mit --clear aufgerufen" || fail "kein '--clear'-Aufruf im Log: $(cat "$SBX/cmd.log")"
grep -Fq "STUB display.py --test" "$SBX/cmd.log" && fail "'display clear' hat --test aufgerufen" || pass "kein --test bei 'display clear'"
# Dieselbe GPIO-Kollision wie bei 'display test' - auch 'display clear' braucht die
# Leitungen fuer sich.
grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" && pass "'display clear' stoppt den Anzeigedienst fuer die Dauer" || fail "'display clear' laesst den Anzeigedienst laufen - das gibt 'GPIO busy': $(cat "$SBX/cmd.log")"
grep -Fq "STUB systemctl start ninlight-display" "$SBX/cmd.log" && pass "'display clear' startet den Anzeigedienst danach wieder" || fail "'display clear' laesst den Anzeigedienst aus: $(cat "$SBX/cmd.log")"
echo
echo "=== Fall D2a: war der Anzeigedienst aus, bleibt er nach 'display test' aus ==="
# Sonst haette ein Abnahme-Handgriff die Station nebenbei umgeschaltet: nach
# 'ninlight-station off' ist der Anzeigedienst bewusst gestoppt, und 'display test'
# darf ihn nicht wieder hochziehen.
SBX="$TMPROOT/tD2a"
setup_sandbox "$SBX"
: >"$SBX/cmd.log"
set +e
OUT=$(FAKE_DISPLAY_SERVICE_ACTIVE_EXIT=3 run_cli "$SBX" display test 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -Fq "STUB display.py --test" "$SBX/cmd.log" && pass "Testbild wurde trotzdem gezeichnet" || fail "kein '--test'-Aufruf: $(cat "$SBX/cmd.log")"
grep -Fq "STUB systemctl start ninlight-display" "$SBX/cmd.log" && fail "'display test' hat den Anzeigedienst eingeschaltet, obwohl er aus war" || pass "kein 'start' fuer einen Dienst, der gar nicht lief"
grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'display test' stoppt einen Dienst, der gar nicht laeuft" || pass "kein ueberfluessiges 'stop'"
echo
echo "=== Fall D2b: scheitert das Panel, meldet 'display test' das - der Dienst laeuft trotzdem wieder ==="
# Der Rueckgabewert von display.py ist das Ergebnis des Handgriffs; ein erfolgreiches
# Wiederanfahren des Dienstes darf ihn nicht in eine Erfolgsmeldung verwandeln. Und
# umgekehrt darf ein gescheitertes Testbild den Dienst nicht dauerhaft aus lassen.
SBX="$TMPROOT/tD2b"
setup_sandbox "$SBX"
: >"$SBX/cmd.log"
set +e
OUT=$(FAKE_DISPLAY_EXIT=1 run_cli "$SBX" display test 2>&1)
RC=$?
set -e
[ "$RC" -eq 1 ] && pass "Exit-Code 1 wird durchgereicht" || fail "Exit-Code $RC statt 1: $OUT"
grep -Fq "STUB systemctl start ninlight-display" "$SBX/cmd.log" && pass "Anzeigedienst laeuft trotz Panel-Fehler wieder" || fail "Anzeigedienst blieb nach dem Fehler aus: $(cat "$SBX/cmd.log")"
echo
echo "=== Fall D2c: Strg-C waehrend 'display test' laesst den Anzeigedienst NICHT gestoppt zurueck ==="
# Der Ablauf aus der Abnahme: bei falscher Revision kehrt ReadBusy() nie zurueck
# (display.py-Docstring), der Abnehmer bricht mit Strg-C ab. Ohne Signalhandler zwischen
# "stop" und "start" stirbt display_handover mit - der Anzeigedienst bleibt aus, das
# Panel schwarz, und das sieht mitten in der Lampenabnahme aus wie "Panel kaputt".
# Geprueft wird fuer alle drei Signale, die das ausloesen koennen.
for SIG_NAME in INT TERM HUP; do
case "$SIG_NAME" in
INT) EXPECT_RC=130 ;;
TERM) EXPECT_RC=143 ;;
HUP) EXPECT_RC=129 ;;
esac
SBX="$TMPROOT/tD2c$SIG_NAME"
setup_sandbox "$SBX"
: >"$SBX/cmd.log"
set +e
OUT=$(FAKE_DISPLAY_SIGNAL="$SIG_NAME" run_cli "$SBX" display test 2>&1)
RC=$?
set -e
grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" \
&& pass "SIG$SIG_NAME: der Anzeigedienst war fuer den Handgriff gestoppt (Vorbedingung)" \
|| fail "SIG$SIG_NAME: Vorbedingung fehlt, der Dienst wurde gar nicht erst gestoppt: $(cat "$SBX/cmd.log")"
# GENAU einmal gestartet. Die Anzahl, nicht nur das Vorhandensein: der Handler muss
# selbst beenden (in diesem Projekt schon einmal ein Befund, setup_pi.sh). Ohne "exit"
# laeuft die Shell hinter dem unterbrochenen Kommando einfach weiter und faehrt den
# Dienst ein zweites Mal an - der Exit-Code sieht dabei zufaellig richtig aus (er ist
# dann der des am Signal gestorbenen Kindes), die doppelte Startzeile nicht. Genau
# diese Mutation ("trap ohne exit") war gegen eine blosse Vorhandensein-Pruefung
# gruen.
START_COUNT=$(grep -c "STUB systemctl start ninlight-display" "$SBX/cmd.log" || true)
[ "$START_COUNT" -eq 1 ] \
&& pass "SIG$SIG_NAME: der Anzeigedienst wird trotz Abbruch genau einmal wieder gestartet" \
|| fail "SIG$SIG_NAME: $START_COUNT Startversuche (1 erwartet) - Anzeigedienst bleibt aus oder der Handler beendet nicht selbst: $(cat "$SBX/cmd.log")"
[ "$RC" -eq "$EXPECT_RC" ] \
&& pass "SIG$SIG_NAME: Exit-Code $EXPECT_RC (128 + Signalnummer)" \
|| fail "SIG$SIG_NAME: Exit-Code $RC statt $EXPECT_RC: $OUT"
done
echo
echo "=== Fall D2d: war der Anzeigedienst aus, startet ihn auch ein Strg-C nicht ==="
# Spiegelbild zu D2a: der Signalhandler darf den Zustand nicht anders behandeln als der
# normale Ausgang - sonst schaltete ein abgebrochener Handgriff die Anzeige ein, die
# 'off' zuvor bewusst ausgeschaltet hat.
SBX="$TMPROOT/tD2d"
setup_sandbox "$SBX"
: >"$SBX/cmd.log"
set +e
OUT=$(FAKE_DISPLAY_SERVICE_ACTIVE_EXIT=3 FAKE_DISPLAY_SIGNAL=INT run_cli "$SBX" display test 2>&1)
RC=$?
set -e
[ "$RC" -eq 130 ] && pass "Exit-Code 130 auch ohne laufenden Anzeigedienst" || fail "Exit-Code $RC statt 130: $OUT"
grep -Fq "STUB systemctl start ninlight-display" "$SBX/cmd.log" \
&& fail "der Signalhandler hat den Anzeigedienst eingeschaltet, obwohl er aus war: $(cat "$SBX/cmd.log")" \
|| pass "kein 'start' fuer einen Dienst, der gar nicht lief"
echo
echo "=== Fall D3 (Task 6b, Punkt 4): 'display off' stoppt den Dienst VOR dem Leeren des Panels ==="
# Umgekehrte Reihenfolge waere wirkungslos: der Daemon fragt alle 5 s nach und
# schriebe sein altes Bild binnen dieser Zeit wieder aufs Panel.
SBX="$TMPROOT/tD3"
setup_sandbox "$SBX"
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" display off 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true)
CLEAR_LINE=$(grep -n "STUB display.py --clear" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true)
if [ -n "$STOP_LINE" ] && [ -n "$CLEAR_LINE" ] && [ "$STOP_LINE" -lt "$CLEAR_LINE" ]; then
pass "Anzeigedienst wird gestoppt, BEVOR das Panel geleert wird (stop=$STOP_LINE, clear=$CLEAR_LINE)"
else
fail "Reihenfolge falsch oder Aufruf fehlt (stop=$STOP_LINE, clear=$CLEAR_LINE): $(cat "$SBX/cmd.log")"
fi
echo
echo "=== Fall D4 (Befund Task 7f): 'off' fasst den Anzeigedienst nicht mehr an ==="
# Frueher stoppte 'off' den Anzeigedienst und leerte das Panel - mit der Begruendung,
# ein stehengebliebenes "MIGRATION"-Bild wuerde einen Betriebszustand behaupten, den es
# nicht mehr gibt. Die Begruendung trug nicht: der Anzeigedienst erkennt den
# ausgeschalteten Zustand selbst (systemctl is-active ninlight-station) und zeichnet dann
# "Station aus". Das eigentliche Ergebnis des Stoppens war ein anderes: kein "on"-Pfad hat
# den Dienst je wieder gestartet, und das Panel blieb ab dem zweiten Durchgang bis zum
# naechsten Pi-Neustart dunkel. Jetzt laesst 'off' den Dienst laufen.
SBX="$TMPROOT/tD4"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
run_cli "$SBX" migrate on >/dev/null 2>&1
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" off 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'off' stoppt den Anzeigedienst weiterhin: $(cat "$SBX/cmd.log")" || pass "'off' stoppt den Anzeigedienst nicht mehr"
grep -Fq "STUB display.py --clear" "$SBX/cmd.log" && fail "'off' leert weiterhin das Panel: $(cat "$SBX/cmd.log")" || pass "'off' leert das Panel nicht mehr (der Dienst zeichnet 'Station aus' selbst)"
echo
echo "=== Fall D4b (die Zusage aus Task 7f): 'off' und ein erneutes 'on' lassen den Anzeigedienst durchgehend an ==="
# Genau das ist die Zusage: nach 'off' und einem erneuten 'on' ist die Anzeige wieder in
# Betrieb - und zwar OHNE dass irgendwer den Dienst zwischendurch neu gestartet haben
# muesste, weil er nie gestoppt wurde. Ein/Aus/Ein durchspielen und ueber die gesamte
# Sequenz pruefen, dass 'ninlight-display' kein einziges Mal gestoppt wird.
SBX="$TMPROOT/tD4b"
setup_sandbox "$SBX"
: >"$SBX/cmd.log"
set +e
run_cli "$SBX" on >/dev/null 2>&1
RC1=$?
run_cli "$SBX" off >/dev/null 2>&1
RC2=$?
run_cli "$SBX" on >/dev/null 2>&1
RC3=$?
set -e
[ "$RC1" -eq 0 ] && [ "$RC2" -eq 0 ] && [ "$RC3" -eq 0 ] \
&& pass "on/off/on liefern alle Exit-Code 0" \
|| fail "on/off/on: Exit-Codes $RC1/$RC2/$RC3, Log: $(cat "$SBX/cmd.log")"
grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" \
&& fail "der Anzeigedienst wurde waehrend on/off/on gestoppt - nach 'off' bliebe das Panel dunkel: $(cat "$SBX/cmd.log")" \
|| pass "der Anzeigedienst blieb ueber on/off/on hinweg durchgehend an"
echo
echo "=== Fall D9: ein fehlender/scheiternder Anzeigedienst haelt weder 'on' noch 'off' auf ==="
# FAKE_DISPLAY_SERVICE_MISSING bildet nach, dass 'ninlight-display' gar nicht installiert
# oder absichtlich deaktiviert ist (systemctl meldet dann einen Fehler statt still
# mitzuspielen). Das darf 'on' und 'off' nicht scheitern lassen - eine tragende Zusage
# dieses Projekts.
SBX="$TMPROOT/tD9"
setup_sandbox "$SBX"
: >"$SBX/cmd.log"
set +e
OUT_ON=$(FAKE_DISPLAY_SERVICE_MISSING=1 FAKE_DISPLAY_SERVICE_ACTIVE_EXIT=4 run_cli "$SBX" on 2>&1)
RC_ON=$?
OUT_OFF=$(FAKE_DISPLAY_SERVICE_MISSING=1 FAKE_DISPLAY_SERVICE_ACTIVE_EXIT=4 run_cli "$SBX" off 2>&1)
RC_OFF=$?
set -e
[ "$RC_ON" -eq 0 ] && pass "'on' gelingt trotz fehlendem Anzeigedienst" || fail "'on' scheitert am fehlenden Anzeigedienst, Exit $RC_ON: $OUT_ON"
[ "$RC_OFF" -eq 0 ] && pass "'off' gelingt trotz fehlendem Anzeigedienst" || fail "'off' scheitert am fehlenden Anzeigedienst, Exit $RC_OFF: $OUT_OFF"
echo
echo "=== Fall D7: 'display' ohne/mit unbekanntem Unterbefehl -> Hilfetext, Exit 2 ==="
SBX="$TMPROOT/tD7"
setup_sandbox "$SBX"
# Anlegen, bevor geprueft wird: eine fehlende Logdatei wuerde die "kein Aufruf"-Zusage
# unten sonst durch ein grep-Versagen erfuellen statt durch das Verhalten des Skripts.
: >"$SBX/cmd.log"
for ARG in "" "quatsch"; do
set +e
# shellcheck disable=SC2086
OUT=$(run_cli "$SBX" display $ARG 2>&1)
RC=$?
set -e
[ "$RC" -eq 2 ] && pass "Exit-Code 2 fuer 'display ${ARG:-<leer>}'" || fail "Exit-Code $RC fuer 'display ${ARG:-<leer>}': $OUT"
echo "$OUT" | grep -q "display {test|clear|off}" && pass "Hilfetext nennt die Unterbefehle ('display ${ARG:-<leer>}')" || fail "Hilfetext fehlt: $OUT"
done
grep -Fq "STUB display.py" "$SBX/cmd.log" && fail "ein unbekannter Unterbefehl hat display.py trotzdem aufgerufen" || pass "kein display.py-Aufruf bei unbekanntem Unterbefehl"
echo
echo "=== Fall D8 (Task 6b, Punkt 3): der Hilfetext erklaert die Revisionsbestimmung ==="
# Die Panel-Revision steht am Geraet nirgends; 'display test' ist die einzige
# Moeglichkeit, sie zu bestimmen, und das muss dort stehen, wo man beim ersten
# Handgriff der Hardware-Abnahme hinsieht.
SBX="$TMPROOT/tD8"
setup_sandbox "$SBX"
set +e
HELP=$(run_cli "$SBX" quatsch 2>&1)
set -e
echo "$HELP" | grep -q "EPD_REVISION" && pass "Hilfetext nennt EPD_REVISION" || fail "Hilfetext nennt EPD_REVISION nicht: $HELP"
echo "$HELP" | grep -q "ninlight-station.conf" && pass "Hilfetext nennt die Datei, in der die Revision steht" || fail "Hilfetext nennt /etc/ninlight-station.conf nicht: $HELP"
echo "$HELP" | grep -qE "V2|V3" && pass "Hilfetext nennt die beiden moeglichen Revisionen" || fail "Hilfetext nennt V2/V3 nicht: $HELP"
echo
echo "########## Zeitschalter (Auto-Aus) ##########"
# Geprueft wird durchgehend das ERGEBNIS: die Zustandsdatei $RUN_DIR/autooff.json (sie
# ist im Skript die einzige Quelle dafuer, ob ein Zeitschalter laeuft), der
# Rueckgabewert, die Ausgabe und der nmcli-/systemd-Zustand - nicht die Interna der
# Stubs.
autooff_field() {
# $1 = Pfad zu autooff.json, $2 = Feldname
python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2]))" "$1" "$2" 2>/dev/null \
|| echo "FEHLT"
}
write_autooff() {
# $1 = sandbox, $2 = minutes, $3 = deadline-Offset in Sekunden ab jetzt, $4 = deferrals
python3 - "$1/run/autooff.json" "$2" "$3" "$4" <<'PY'
import json, sys, time
path, minutes, offset, deferrals = sys.argv[1:5]
with open(path, "w") as fh:
json.dump({"minutes": int(minutes),
"deadline": int(time.time()) + int(offset),
"deferrals": int(deferrals)}, fh)
PY
}
echo "=== Fall Z1 (die wichtigste Zusage): 'on' OHNE --for legt keinen Zeitschalter an ==="
# Der Regelfall ist der Pi am Kabel - dem darf niemand den Zugangspunkt unter der
# laufenden Arbeit wegschalten, nur weil jemand vorsichtig sein wollte.
SBX="$TMPROOT/tZ1"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" on 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zeitschalter-Zustandsdatei angelegt" || fail "ohne --for entstand trotzdem ein Zeitschalter: $(cat "$SBX/run/autooff.json")"
grep -q "systemd-run" "$SBX/cmd.log" && fail "ohne --for wurde trotzdem ein Timer angelegt: $(grep systemd-run "$SBX/cmd.log")" || pass "kein systemd-run-Aufruf"
echo "$OUT" | grep -qi "Auto-off" && fail "Ausgabe verspricht einen Zeitschalter, den es nicht gibt: $OUT" || pass "Ausgabe erwaehnt keinen Zeitschalter"
OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -qi "Auto-off" && fail "'status' zeigt eine Zeitschalter-Zeile, obwohl keiner laeuft: $OUT" || pass "'status' schweigt ueber den Zeitschalter"
echo
echo "=== Fall Z2: 'on --for 30' legt einen Zeitschalter an ==="
SBX="$TMPROOT/tZ2"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" on --for 30 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ -e "$SBX/run/autooff.json" ] && pass "Zustandsdatei angelegt" || fail "keine autooff.json angelegt: $(cat "$SBX/cmd.log")"
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "angeforderte Dauer 30 min gemerkt" || fail "minutes falsch: $(cat "$SBX/run/autooff.json")"
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "0" ] && pass "noch keine Verschiebung" || fail "deferrals falsch: $(cat "$SBX/run/autooff.json")"
[ "$(file_mode "$SBX/run/autooff.json")" = "644" ] && pass "autooff.json ist 0644 (display.py muss sie lesen)" || fail "falsche Rechte: $(file_mode "$SBX/run/autooff.json")"
grep -q "systemd-run .*--unit=ninlight-station-autooff" "$SBX/cmd.log" && pass "Timer unter festem Unit-Namen angelegt" || fail "kein systemd-run mit festem Unit-Namen: $(cat "$SBX/cmd.log")"
grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "Timer feuert nach 30 min" || fail "falsche Laufzeit: $(grep systemd-run "$SBX/cmd.log")"
grep -q "__autooff" "$SBX/cmd.log" && pass "Timer ruft das CLI mit seiner Abschaltaktion" || fail "Timer ruft nichts Abschaltendes: $(grep systemd-run "$SBX/cmd.log")"
[ "$(cat "$SBX/run/mode")" = "recovery" ] && pass "die Station ist trotzdem ganz normal an" || fail "Modusdatei falsch"
OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -qE "Auto-off: in (29|30) min" && pass "'status' zeigt die Restzeit" || fail "'status' zeigt keine brauchbare Restzeit: $OUT"
echo
echo "=== Fall Z3: ungueltige Minutenangaben schalten GAR NICHTS ein ==="
# Geprueft wird nicht nur der Rueckgabewert: nach einer abgelehnten Angabe darf weder ein
# Zugangspunkt hochgefahren noch der Dienst neu gestartet noch eine Modusdatei
# geschrieben worden sein.
for BAD in "0" "241" "abc" "-5" "1.5" ""; do
SBX="$TMPROOT/tZ3-${BAD:-leer}"
setup_sandbox "$SBX"
# Logdatei anlegen, BEVOR geprueft wird (wie in Fall D7): eine fehlende Datei wuerde
# die "hat nichts eingeschaltet"-Zusagen unten sonst durch ein grep-Versagen erfuellen
# statt durch das Verhalten des Skripts.
: >"$SBX/cmd.log"
set +e
if [ -z "$BAD" ]; then
OUT=$(run_cli "$SBX" on --for 2>"$SBX/stderr.log") # --for ohne Wert
else
OUT=$(run_cli "$SBX" on --for "$BAD" 2>"$SBX/stderr.log")
fi
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "'--for ${BAD:-<leer>}' wird abgelehnt (Exit $RC)" || fail "'--for ${BAD:-<leer>}' wurde angenommen: $OUT"
echo "$ERR" | grep -qi "error" && pass "'--for ${BAD:-<leer>}': Fehlermeldung auf stderr" || fail "'--for ${BAD:-<leer>}': keine Fehlermeldung: $ERR"
grep -q "nmcli con up" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat trotzdem einen Zugangspunkt hochgefahren: $(cat "$SBX/cmd.log")" || pass "'--for ${BAD:-<leer>}': kein Zugangspunkt hochgefahren"
grep -q "systemctl restart" "$SBX/cmd.log" && fail "'--for ${BAD:-<leer>}' hat den Dienst gestartet" || pass "'--for ${BAD:-<leer>}': Dienst nicht gestartet"
[ ! -e "$SBX/run/mode" ] && pass "'--for ${BAD:-<leer>}': keine Modusdatei geschrieben" || fail "'--for ${BAD:-<leer>}' hat eine Modusdatei geschrieben: $(cat "$SBX/run/mode")"
[ ! -e "$SBX/run/autooff.json" ] && pass "'--for ${BAD:-<leer>}': kein Zeitschalter angelegt" || fail "'--for ${BAD:-<leer>}': Zeitschalter trotz ungueltiger Angabe"
done
echo
echo "=== Fall Z4: 'migrate on --listen --for 5' - beides zusammen ==="
SBX="$TMPROOT/tZ4"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
set +e
OUT=$(run_cli "$SBX" migrate on --listen --for 5 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
[ "$(control_phase "$SBX/run/control.json")" = "listen" ] && pass "phase=listen bleibt erhalten" || fail "phase falsch: $(cat "$SBX/run/control.json" 2>&1)"
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "5" ] && pass "Zeitschalter ueber 5 min angelegt" || fail "Zeitschalter falsch/fehlt: $(cat "$SBX/run/autooff.json" 2>&1)"
# Umgekehrte Reihenfolge der Schalter muss genauso gehen.
SBX="$TMPROOT/tZ4b"
setup_sandbox "$SBX"
echo "dummy" >"$SBX/images/migrate-nocreds/gu10_rgb_prod.img"
set +e
OUT=$(run_cli "$SBX" migrate on --for 5 --listen 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && [ "$(control_phase "$SBX/run/control.json")" = "listen" ] \
&& pass "Schalterreihenfolge ist egal" || fail "'--for 5 --listen' scheiterte (Exit $RC): $OUT"
echo
echo "=== Fall Z5 (nicht verhandelbar): der Zeitschalter schaltet NICHT in eine laufende Auslieferung ==="
SBX="$TMPROOT/tZ5"
setup_sandbox "$SBX"
run_cli "$SBX" on --for 30 >/dev/null 2>&1
: >"$SBX/cmd.log"
set +e
OUT=$(FAKE_SS_BUSY=1 run_cli "$SBX" __autooff 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT"
grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "die Station wurde mitten in der Auslieferung abgeschaltet: $(cat "$SBX/cmd.log")" || pass "die Station bleibt an"
grep -q "nmcli con down" "$SBX/cmd.log" && fail "der Zugangspunkt wurde mitten in der Auslieferung heruntergefahren" || pass "der Zugangspunkt bleibt oben"
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "1" ] && pass "Verschiebung gezaehlt (1)" || fail "deferrals nicht hochgezaehlt: $(cat "$SBX/run/autooff.json" 2>&1)"
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "die angeforderte Dauer bleibt fuer 'renew' erhalten" || fail "minutes wurde ueberschrieben: $(cat "$SBX/run/autooff.json")"
echo "$OUT" | grep -qi "deferred" && pass "die Verschiebung wird gemeldet" || fail "keine Meldung ueber die Verschiebung: $OUT"
# Gegenprobe: dieselbe Lage ohne laufende Verbindung schaltet sehr wohl ab.
SBX="$TMPROOT/tZ5b"
setup_sandbox "$SBX"
run_cli "$SBX" on --for 30 >/dev/null 2>&1
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" __autooff 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Gegenprobe: Exit-Code 0" || fail "Gegenprobe: Exit-Code $RC: $OUT"
grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && pass "ohne laufende Auslieferung schaltet der Zeitschalter ab" || fail "der Zeitschalter schaltet gar nicht ab: $(cat "$SBX/cmd.log")"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
echo "$ACTIVE_AFTER" | grep -qx "home-wifi" && pass "wlan0 ist zurueck im Heim-WLAN" || fail "nicht zurueck im Heim-WLAN: $ACTIVE_AFTER"
[ ! -e "$SBX/run/autooff.json" ] && pass "der abgelaufene Zeitschalter ist abgeraeumt" || fail "Zustandsdatei blieb liegen"
# Die Pruefung muss die richtige Frage stellen: offene Verbindungen auf Port 80 aus dem
# Stationsnetz.
grep -q "STUB ss .*:80" "$SBX/cmd.log" && pass "geprueft wird Port 80" || fail "die Verbindungspruefung fragt nicht nach Port 80: $(grep 'STUB ss' "$SBX/cmd.log")"
grep -q "STUB ss .*192.168.8.0/24" "$SBX/cmd.log" && pass "geprueft wird das Stationsnetz 192.168.8.0/24" || fail "die Verbindungspruefung fragt nicht nach dem Stationsnetz: $(grep 'STUB ss' "$SBX/cmd.log")"
echo
echo "=== Fall Z6: die Obergrenze fuers Verschieben greift - eine haengende Verbindung haelt den Zeitschalter nicht ewig auf ==="
SBX="$TMPROOT/tZ6"
setup_sandbox "$SBX"
run_cli "$SBX" on --for 30 >/dev/null 2>&1
write_autooff "$SBX" 30 60 10 # 10 Verschiebungen sind die Obergrenze
: >"$SBX/cmd.log"
set +e
OUT=$(FAKE_SS_BUSY=1 run_cli "$SBX" __autooff 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT $ERR"
grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && pass "an der Obergrenze wird trotz offener Verbindung abgeschaltet" || fail "die haengende Verbindung hebelt den Zeitschalter dauerhaft aus: $(cat "$SBX/cmd.log")"
echo "$ERR" | grep -qi "warning" && pass "das Abschalten trotz offener Verbindung wird gemeldet" || fail "keine Warnung: $ERR"
[ ! -e "$SBX/run/autooff.json" ] && pass "Zustandsdatei abgeraeumt" || fail "Zustandsdatei blieb liegen"
# Eine Verschiebung UNTER der Grenze verschiebt dagegen weiter (sonst koennte die
# Grenzpruefung auch einfach immer abschalten).
SBX="$TMPROOT/tZ6b"
setup_sandbox "$SBX"
run_cli "$SBX" on --for 30 >/dev/null 2>&1
write_autooff "$SBX" 30 60 9
: >"$SBX/cmd.log"
set +e
FAKE_SS_BUSY=1 run_cli "$SBX" __autooff >/dev/null 2>&1
set -e
grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "eine Verschiebung unter der Grenze hat schon abgeschaltet" || pass "unter der Grenze wird weiter verschoben"
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "10" ] && pass "Zaehler steht jetzt auf der Grenze (10)" || fail "deferrals falsch: $(cat "$SBX/run/autooff.json" 2>&1)"
echo
echo "=== Fall Z7: 'off' raeumt einen laufenden Zeitschalter mit ab ==="
# Sonst feuert er spaeter in eine Station hinein, die laengst aus ist - oder inzwischen
# wieder an.
SBX="$TMPROOT/tZ7"
setup_sandbox "$SBX"
run_cli "$SBX" on --for 30 >/dev/null 2>&1
[ -e "$SBX/run/autooff.json" ] || fail "Vorbedingung: kein Zeitschalter angelegt"
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" off 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
[ ! -e "$SBX/run/autooff.json" ] && pass "Zeitschalter-Zustandsdatei ist weg" || fail "'off' liess den Zeitschalter stehen: $(cat "$SBX/run/autooff.json")"
grep -q "systemctl stop ninlight-station-autooff.timer" "$SBX/cmd.log" && pass "der Timer selbst wurde angehalten" || fail "der systemd-Timer wurde nicht angehalten: $(cat "$SBX/cmd.log")"
grep -q "systemctl stop ninlight-station-autooff$" "$SBX/cmd.log" && fail "es wurde die .service statt der .timer gestoppt - das brächte den Zeitschalter-Lauf um sich selbst" || pass "die gleichnamige .service wird nicht gestoppt"
echo
echo "=== Fall Z8: 'renew'/'hold' ohne laufenden Zeitschalter sind klare Fehler, kein stillschweigendes Neuanlegen ==="
for CMD in renew hold; do
SBX="$TMPROOT/tZ8-$CMD"
setup_sandbox "$SBX"
run_cli "$SBX" on >/dev/null 2>&1 # Station an, aber OHNE --for
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" "$CMD" 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "'$CMD' ohne Zeitschalter: Exit-Code $RC" || fail "'$CMD' meldete Erfolg: $OUT"
echo "$ERR" | grep -qi "no timer active" && pass "'$CMD': klare Meldung auf stderr" || fail "'$CMD': unklare Meldung: $ERR"
[ ! -e "$SBX/run/autooff.json" ] && pass "'$CMD' hat keinen Zeitschalter angelegt" || fail "'$CMD' hat stillschweigend einen angelegt: $(cat "$SBX/run/autooff.json")"
grep -q "systemd-run" "$SBX/cmd.log" && fail "'$CMD' hat einen Timer angelegt" || pass "'$CMD': kein systemd-run"
done
echo
echo "=== Fall Z9: 'renew' stellt die Uhr zurueck - ohne Argument auf die zuletzt angeforderte Dauer ==="
SBX="$TMPROOT/tZ9"
setup_sandbox "$SBX"
run_cli "$SBX" on --for 30 >/dev/null 2>&1
write_autooff "$SBX" 30 120 4 # Restzeit fast abgelaufen, schon viermal verschoben
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" renew 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
[ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "30" ] && pass "dieselbe Dauer wie zuletzt angefordert (30)" || fail "minutes falsch: $(cat "$SBX/run/autooff.json")"
[ "$(autooff_field "$SBX/run/autooff.json" deferrals)" = "0" ] && pass "Verschiebungszaehler zurueckgesetzt" || fail "deferrals nicht zurueckgesetzt: $(cat "$SBX/run/autooff.json")"
STATUS=$(run_cli "$SBX" status 2>&1)
echo "$STATUS" | grep -qE "Auto-off: in (29|30) min" && pass "die Restzeit steht wieder bei 30 min" || fail "Restzeit nicht zurueckgestellt: $STATUS"
grep -q -- "--on-active=1800s" "$SBX/cmd.log" && pass "der Timer wurde neu gestellt" || fail "kein neuer Timer: $(cat "$SBX/cmd.log")"
# Mit Argument gilt das Argument - und ungueltige Angaben werden auch hier abgelehnt.
set +e
OUT=$(run_cli "$SBX" renew 90 2>&1); RC=$?
set -e
[ "$RC" -eq 0 ] && [ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "90" ] && pass "'renew 90' stellt auf 90 min" || fail "'renew 90' scheiterte (Exit $RC): $OUT"
set +e
OUT=$(run_cli "$SBX" renew 999 2>"$SBX/stderr.log"); RC=$?
set -e
[ "$RC" -ne 0 ] && [ "$(autooff_field "$SBX/run/autooff.json" minutes)" = "90" ] && pass "'renew 999' wird abgelehnt, der laufende Zeitschalter bleibt unveraendert" || fail "'renew 999' wurde angenommen (Exit $RC): $(cat "$SBX/run/autooff.json")"
echo
echo "=== Fall Z10: 'hold' bestellt ab - die Station bleibt an ==="
SBX="$TMPROOT/tZ10"
setup_sandbox "$SBX"
run_cli "$SBX" on --for 30 >/dev/null 2>&1
: >"$SBX/cmd.log"
set +e
OUT=$(run_cli "$SBX" hold 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
[ ! -e "$SBX/run/autooff.json" ] && pass "Zeitschalter abbestellt" || fail "Zeitschalter laeuft weiter: $(cat "$SBX/run/autooff.json")"
grep -q "systemctl stop ninlight-station-autooff.timer" "$SBX/cmd.log" && pass "der Timer wurde angehalten" || fail "Timer nicht angehalten: $(cat "$SBX/cmd.log")"
grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "'hold' hat die Station abgeschaltet" || pass "die Station bleibt an"
ACTIVE_AFTER=$(cat "$SBX/nmstate/active")
[ "$ACTIVE_AFTER" = "ninlight-recovery" ] && pass "der Zugangspunkt laeuft unveraendert weiter" || fail "Zugangspunkt-Zustand veraendert: $ACTIVE_AFTER"
# Und ein danach doch noch feuernder Timer-Lauf schaltet nichts ab.
: >"$SBX/cmd.log"
set +e
run_cli "$SBX" __autooff >/dev/null 2>&1
set -e
grep -q "systemctl stop ninlight-station$" "$SBX/cmd.log" && fail "ein abbestellter Zeitschalter hat trotzdem abgeschaltet: $(cat "$SBX/cmd.log")" || pass "ein abbestellter Zeitschalter schaltet nichts mehr ab"
echo
echo "=== Fall Z11: scheitert systemd-run, bleibt das nicht unbemerkt ==="
# Sonst glaubte der Nutzer an einen Zeitschalter, den es nie gab - und genau darauf
# hat er sich verlassen, als er die SSH-Sitzung ueber WLAN aufgab.
SBX="$TMPROOT/tZ11"
setup_sandbox "$SBX"
set +e
OUT=$(FAKE_SYSTEMD_RUN_EXIT=1 run_cli "$SBX" on --for 30 2>"$SBX/stderr.log")
RC=$?
set -e
ERR=$(cat "$SBX/stderr.log")
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0, obwohl der Timer nicht angelegt werden konnte: $OUT"
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR"
[ ! -e "$SBX/run/autooff.json" ] && pass "keine Zustandsdatei, die einen Zeitschalter vortaeuscht" || fail "autooff.json trotz gescheitertem Timer: $(cat "$SBX/run/autooff.json")"
echo "$ERR" | grep -q "off" && pass "die Meldung sagt, dass die Station AN bleibt" || fail "die Meldung verschweigt den Zustand der Station: $ERR"
echo
echo "=== Bildsatz A: die Vorgabe ohne Umschalterdatei ist der Satz OHNE WLAN-Zugangsdaten ==="
# Die sichere Richtung: ein Fehlgriff kostet einen Onboarding-Durchgang, kein
# WLAN-Passwort in einer fremden Lampe.
SBX="$TMPROOT/bs1"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" bildsatz 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
echo "$OUT" | grep -q "ohne-wlan" && pass "ohne Datei gilt 'ohne-wlan'" || fail "Vorgabe ist nicht 'ohne-wlan': $OUT"
echo "$OUT" | grep -q "migrate-nocreds" && pass "und das zugehoerige Verzeichnis wird genannt" || fail "Verzeichnis fehlt in der Ausgabe: $OUT"
[ ! -e "$SBX/var/imageset" ] && pass "das blosse Berichten legt keine Datei an" || fail "'bildsatz' ohne Argument hat geschrieben"
echo
echo "=== Bildsatz B: umschalten schreibt die Datei atomar mit 0644 und bleibt stehen ==="
SBX="$TMPROOT/bs2"
setup_sandbox "$SBX"
set +e
OUT=$(run_cli "$SBX" bildsatz mit-wlan 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
[ "$(cat "$SBX/var/imageset" 2>/dev/null | tr -d '\n')" = "mit-wlan" ] && pass "die Datei enthaelt 'mit-wlan'" || fail "Dateiinhalt: $(cat "$SBX/var/imageset" 2>&1)"
MODE=$(file_mode "$SBX/var/imageset")
[ "$MODE" = "644" ] && pass "Rechte 0644 (der Anzeigedienst muss lesen koennen)" || fail "Rechte $MODE statt 644"
[ -z "$(find "$SBX/var" -name '.imageset-*' 2>/dev/null)" ] && pass "keine Temp-Datei liegengeblieben" || fail "Temp-Datei liegt noch da"
echo "$OUT" | grep -qi "caution" && pass "der Wechsel zu 'mit-wlan' warnt" || fail "keine Warnung beim Wechsel zu mit-wlan: $OUT"
# ... und er BLEIBT stehen: ein zweiter, unabhaengiger Aufruf sieht ihn.
OUT2=$(run_cli "$SBX" bildsatz 2>&1)
echo "$OUT2" | grep -q "mit-wlan" && pass "der Zustand bleibt ueber Aufrufe hinweg" || fail "Zustand nicht gespeichert: $OUT2"
# ... und zurueck.
run_cli "$SBX" bildsatz ohne-wlan >/dev/null 2>&1
[ "$(cat "$SBX/var/imageset" | tr -d '\n')" = "ohne-wlan" ] && pass "zurueckschalten geht auch" || fail "zurueckschalten hat nicht gewirkt"
echo
echo "=== Bildsatz C: ein unbekannter Name schaltet nichts um ==="
SBX="$TMPROOT/bs3"
setup_sandbox "$SBX"
printf 'mit-wlan\n' >"$SBX/var/imageset"
set +e
ERR=$(run_cli "$SBX" bildsatz irgendwas 2>&1 >/dev/null)
RC=$?
set -e
[ "$RC" -ne 0 ] && pass "Exit-Code != 0" || fail "Exit-Code 0 fuer einen unbekannten Bildsatz"
[ "$(cat "$SBX/var/imageset" | tr -d '\n')" = "mit-wlan" ] && pass "der bestehende Zustand blieb unveraendert" || fail "der Zustand wurde veraendert: $(cat "$SBX/var/imageset")"
echo "$ERR" | grep -qi "error" && pass "Fehlermeldung auf stderr" || fail "keine Fehlermeldung: $ERR"
echo
echo "=== Bildsatz D: ein kaputter Dateiinhalt faellt auf die SICHERE Vorgabe zurueck ==="
# Nie auf den teureren Satz - das ist die eine Richtung, die kein Zweifelsfall nehmen darf.
SBX="$TMPROOT/bs4"
setup_sandbox "$SBX"
printf 'voellig-anderes\n' >"$SBX/var/imageset"
OUT=$(run_cli "$SBX" bildsatz 2>&1)
echo "$OUT" | grep -q "ohne-wlan" && pass "kaputter Inhalt -> 'ohne-wlan'" || fail "kaputter Inhalt fuehrte nicht auf die Vorgabe: $OUT"
echo "$OUT" | grep -q "migrate-nocreds" && pass "und das Verzeichnis des sicheren Satzes" || fail "falsches Verzeichnis: $OUT"
# Der Bediener muss erfahren, dass sein Eintrag NICHT gewirkt hat - sonst haelt er die
# Station fuer umgeschaltet.
echo "$OUT" | grep -q "unknown setting 'voellig-anderes'" \
&& pass "die unbekannte Einstellung wird beim Namen genannt" \
|| fail "die unbekannte Einstellung wird verschwiegen: $OUT"
# Gegenprobe: bei einem GUELTIGEN Eintrag darf diese Zeile nicht erscheinen.
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
OUT=$(run_cli "$SBX" bildsatz 2>&1)
echo "$OUT" | grep -q "unknown setting" \
&& fail "meldet 'unbekannte Einstellung' fuer einen gueltigen Eintrag: $OUT" \
|| pass "kein Fehlalarm bei gueltigem Eintrag"
echo
echo "=== Bildsatz E: ein gewaehlter, aber fehlender Satz wird beim Umschalten UND im Status gemeldet ==="
SBX="$TMPROOT/bs5"
setup_sandbox "$SBX"
rm -rf "$SBX/images/migrate-wlan"
set +e
ERR=$(run_cli "$SBX" bildsatz mit-wlan 2>&1 >/dev/null)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Umschalten gelingt trotzdem (der Zustand ist gueltig, nur die Bestueckung fehlt)" || fail "Exit-Code $RC"
echo "$ERR" | grep -qi "warning" && pass "Warnung beim Umschalten" || fail "keine Warnung: $ERR"
OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -q "MISSING" && pass "'status' sagt, dass nichts ausgeliefert wird" || fail "'status' verschweigt den fehlenden Satz: $OUT"
echo
echo "=== Bildsatz F: ein LEERES Verzeichnis zaehlt genauso als fehlend ==="
SBX="$TMPROOT/bs6"
setup_sandbox "$SBX"
rm -f "$SBX/images/migrate-nocreds"/*.img
OUT=$(run_cli "$SBX" status 2>&1)
echo "$OUT" | grep -q "MISSING" && pass "ein leeres Bildsatzverzeichnis wird als fehlend gemeldet" || fail "ein leerer Satz gilt als vorhanden: $OUT"
echo
echo "=== Bildsatz G: 'migrate on' nennt den geltenden Bildsatz in der Einschaltmeldung ==="
# Er kann von vorletzter Woche stammen - wer einschaltet, muss ihn sehen.
SBX="$TMPROOT/bs7"
setup_sandbox "$SBX"
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
set +e
OUT=$(run_cli "$SBX" migrate on --listen 2>&1)
RC=$?
set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC: $OUT"
echo "$OUT" | grep -q "Image set: mit-wlan" && pass "die Einschaltmeldung nennt den Bildsatz" || fail "Bildsatz fehlt in der Einschaltmeldung: $OUT"
echo
echo "=== Bildsatz H: die Warnung 'keine Migrationsimages' meint den GEWAEHLTEN Satz ==="
SBX="$TMPROOT/bs8"
setup_sandbox "$SBX"
rm -f "$SBX/images/migrate-wlan"/*.img # nur der andere Satz ist leer
set +e
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
set -e
echo "$ERR" | grep -q "no migration images" && fail "warnt, obwohl der GEWAEHLTE Satz ('ohne-wlan') bestueckt ist: $ERR" || pass "kein Fehlalarm - gezaehlt wird im gewaehlten Satz"
run_cli "$SBX" bildsatz mit-wlan >/dev/null 2>&1
set +e
ERR=$(run_cli "$SBX" migrate on --listen 2>&1 >/dev/null)
set -e
echo "$ERR" | grep -q "no migration images" && 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 "warning" && 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 "warning" && pass "Warnung auf stderr" || fail "keine Warnung: $ERR"
echo
if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden."
else
echo "Mindestens ein Test ist fehlgeschlagen." >&2
fi
exit "$FAILED"