fix(display): Freigabe-Ziel lesbar, Tests schriftunabhaengig

Sechs Review-Befunde an e2d836a/2aab1b0.

C1: die Pixelschwellen in test_render.py waren gegen Pillow 12/Arial
kalibriert und auf dem Pi wie auf Debian 13 (DejaVu) rot - "> 150
schwarze Pixel in Zeile 11" sind dort nur 143. Die Zusagen ueber den
Kopfbalken sind jetzt Anteile und Symmetrien statt Pixelzahlen
(mindestens 55 % des Balkens schwarz, weisse Schrift links UND rechts
der Mitte, Balken ueber die volle Breite). Zwei weitere schriftabhaengige
Schwellen ebenso: die grosse Zeile hebt sich ueber das VERHAELTNIS der
Tintenhoehen vom Beiwerk ab, und die Randgrenze zaehlt Zeilenbaender
statt Tinten-Cluster.

I2: im Zustand "armed" stand "FREIGEGEBEN: <ziel>" in der Kopfzeile,
wo 112 px Platz sind und der Text 182 px misst - gezeichnet wurde
"FREIGEGEBE…", bei jedem Ziel gleich. Im Kopf steht jetzt nur noch das
Label, der Zielname als eigene Beiwerkzeile ueber die volle Breite; die
SSID-Zeile gibt dafuer in "armed" ihren Platz ab. Geprueft wird das
gezeichnete Bild (Pixelvergleich gegen eine Referenzzeile), nicht
lines().

I3: der Kommentar an display_handover() begruendete die
Rueckgabewert-Durchreichung damit, dass der Handgriff bei stummem Panel
rot werde. Das stimmt nicht - display.py beendet sich bei fehlendem
Panel/SPI/Pillow mit 0. Der Kommentar sagt jetzt, dass man die Revision
am Panel beurteilt und nicht am Exit-Code.

I4: zwischen "systemctl stop" und "systemctl start" gab es keinen
Signalhandler - ein Strg-C waehrend "display test" (der dokumentierte
Weg, die Revision zu bestimmen, bei falscher Revision haengt das Panel)
liess den Anzeigedienst gestoppt zurueck. trap auf INT/TERM/HUP, das
Wiederanfahren in display_service_resume(), Handler beenden selbst mit
128 + Signalnummer.

M2: HEADER_PHASE_MIN_WIDTH_PX bleibt als Sicherheitsnetz, ist jetzt aber
getestet statt toter Code.

M4: compose() baute je Aufruf fuenf Schriften neu. Prozess-Cache (mit
ImageFont-Modul und FONT_SEARCH_PATHS im Schluessel, damit die Tests
nicht verfaelscht werden) und faules Laden je Rolle: 25,0 ms -> 7,0 ms.

Die Pixeltests laufen zusaetzlich unter Pillow 11.1 mit echtem DejaVu
und unter fuenf weiteren Schriften. Sieben Mutationsproben angewandt,
angesehen und zurueckgenommen. tools/test_host.sh gruen.
This commit is contained in:
Michael Sauer
2026-09-14 15:12:45 +02:00
parent e77d3da292
commit f105a0eb69
4 changed files with 516 additions and 49 deletions
+104 -19
View File
@@ -50,12 +50,14 @@ TEXT_MARGIN_PX = 2
# Panelbreite. Invertiert kostet auf e-Paper nichts (bistabil, kein
# Stromverbrauch pro Pixel) und trennt die Kopfzeile auf einen Blick
# vom Rest.
# header_phase - die Phase (AUTO/HORCHT/FREIGEGEBEN: ...), NUR im Migrationsmodus und
# header_phase - die Phase (AUTO/HORCHT/FREIGEGEBEN), NUR im Migrationsmodus und
# nur, wenn eine Phase bekannt ist. Steht in derselben Kopfzeile wie
# header, rechtsbuendig, in kleinerer Schrift (zweite Hardware-Abnahme:
# die Phase ging als eigene kleine Zeile unter dem Lampennamen unter).
# Rettung/aus kennen keine Phase - dort bleibt die Kopfzeile
# unveraendert (nur header, mittig, wie zuvor).
# unveraendert (nur header, mittig, wie zuvor). NUR das kurze Label -
# der Zielname der Freigabe steht als eigene Beiwerkzeile, siehe
# ARMED_DETAIL_PREFIX.
# hero - die eine wichtigste Information des Zustands (Migration: der
# erkannte Produktname; Rettung: die SSID), fett.
# detail - Beiwerk (Ziel/Variante, Lampenversion, Zaehler, letzte Auslieferung).
@@ -100,8 +102,19 @@ HEADER_PHASE_GAP_PX = 6
# Bleibt nach Modus-Text und Puffer weniger als das fuer die Phase uebrig, wird sie ganz
# weggelassen statt ein einzelnes "…" hinzuzeichnen - das waere ohnehin keine Auskunft
# mehr wert (dritte Option aus der Abnahme: kuerzen, verkleinern oder weglassen; die
# ersten beiden greifen fuer die im Betrieb vorkommenden Werte immer, das hier ist nur
# das Sicherheitsnetz fuer einen theoretisch sehr langen Zielnamen).
# ersten beiden greifen fuer die im Betrieb vorkommenden Werte immer).
#
# Dritte Hardware-Abnahme, Befund M2: der Reviewer hat zu Recht angemerkt, dass dieser
# Zweig mit den heutigen Werten nicht feuern KANN - der Modus-Text im Migrationsmodus ist
# die Konstante "MIGRATION" (MODE_LABELS), available_px ist damit fest (112 px DejaVu,
# 127 px Arial) und nie < 24. Der Zweig bleibt trotzdem, aber er ist jetzt getestet
# (test_header_phase_is_dropped_when_too_little_room_is_left): was ihn aufweckt, ist
# nicht ein langer Zielname (den gibt es seit Befund I2 im Kopf gar nicht mehr), sondern
# ein schmalerer Rest neben dem Modus - eine groessere HEADER_FONT_PX, eine deutlich
# breitere Schrift, ein kuenftiger dritter Modus mit laengerem Namen oder ein groesserer
# HEADER_PHASE_GAP_PX. Ohne den Zweig waere die Folge kein fehlendes Wort, sondern ein
# "…", das sich mit dem Modus ueberlagert; das ist der Grund, warum hier ein
# totgeglaubtes Sicherheitsnetz billiger ist als sein Fehlen.
HEADER_PHASE_MIN_WIDTH_PX = 24
# Suchkette fuer eine echte, skalierbare Schrift, in dieser Reihenfolge probiert
@@ -138,10 +151,22 @@ FONT_SEARCH_PATHS = (
)
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
# gelistet.
# "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem
# Zielnamen nach sich), deshalb hier nicht gelistet.
PHASE_LABELS = {"auto": "AUTO", "listen": "HORCHT"}
# Dritte Hardware-Abnahme, Befund I2: in der Kopfzeile stand frueher
# "FREIGEGEBEN: <zielname>" - der Platz neben dem Modus reicht dafuer nicht (mit DejaVu
# auf dem Pi bleiben 112 px, der Text misst 182 px), gezeichnet wurde also "FREIGEGEBE…":
# ein Wortfragment, das bei JEDEM Ziel gleich aussieht. Ausgerechnet in "armed" muss der
# Bediener aber sehen, WELCHES Ziel freigegeben ist, sonst gibt er im Zweifel die falsche
# Lampe frei. Deshalb im Kopf nur noch das kurze Label (das auch bei einer deutlich
# breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft
# verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
ARMED_LABEL = "FREIGEGEBEN"
ARMED_DETAIL_PREFIX = "Freigabe: "
def _hhmm(ts: object) -> str:
""""2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer
@@ -205,9 +230,13 @@ def blocks(state: dict) -> list[tuple[str, str]]:
# wird sie hier direkt nach dem ROLE_HEADER-Block angehaengt, nicht erst nach
# der grossen Zeile.
phase = state.get("phase")
armed_target = None
if phase == "armed":
target = state.get("armed_target") or "?"
out.append((f"FREIGEGEBEN: {target}", ROLE_HEADER_PHASE))
# Nur das Label in den Kopf, der Zielname kommt weiter unten als eigene
# Beiwerkzeile (siehe ARMED_DETAIL_PREFIX) - in den Kopf passt er nicht,
# und dort war er bis zur dritten Abnahme faktisch unsichtbar.
out.append((ARMED_LABEL, ROLE_HEADER_PHASE))
armed_target = str(state.get("armed_target") or "?")
elif phase in PHASE_LABELS:
out.append((PHASE_LABELS[phase], ROLE_HEADER_PHASE))
@@ -225,6 +254,13 @@ def blocks(state: dict) -> list[tuple[str, str]]:
elif ssid:
out.append((str(ssid), ROLE_HERO))
if armed_target is not None:
# ERSTE Beiwerkzeile, also die, die als letzte der unteren Randgrenze zum
# Opfer fiele (compose() bricht von unten ab, blocks() ist nach Wichtigkeit
# sortiert): in "armed" ist das freigegebene Ziel die Auskunft, wegen der
# der Bediener ueberhaupt aufs Panel sieht.
out.append((f"{ARMED_DETAIL_PREFIX}{armed_target}", ROLE_DETAIL))
if product_name:
target = last_seen.get("target")
variant = last_seen.get("variant")
@@ -233,9 +269,16 @@ def blocks(state: dict) -> list[tuple[str, str]]:
lamp_version = last_seen.get("lamp_version")
if lamp_version:
out.append((f"FW: {lamp_version}", ROLE_DETAIL))
if ssid:
if ssid and armed_target is None:
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
# SSID zweimal auf dem Panel.
#
# Und nur ohne Freigabezeile: aufs Panel passen neben der grossen Zeile
# fuenf Beiwerkzeilen (siehe BOTTOM_MARGIN_PX), die Freigabezeile ist die
# sechste. Von den beiden gibt die SSID nach - sie ist die Auskunft, mit
# der man das Netz FINDET, und in "armed" hat die Lampe es laengst
# gefunden (sie wurde ja erkannt, sonst gaebe es kein product_name);
# ohne erkannte Lampe steht die SSID ohnehin als grosse Zeile da.
out.append((f"SSID: {ssid}", ROLE_DETAIL))
served = state.get("served")
@@ -278,9 +321,25 @@ def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px:
return (trimmed + ellipsis) if trimmed else ellipsis
# Prozess-Cache fuer _load_font(). Dritte Hardware-Abnahme, Befund M4: compose() baute je
# Aufruf FUENF Schriften neu (auf der Entwicklungsmaschine gemessen: 25,0 ms je compose(),
# davon 21,2 ms Schriftaufrufe - auf einem ARMv6-Kern ohne NEON grob 0,5-1 s, und das auf
# demselben Kern, auf dem server.py die Lampen bedient, bei JEDER ausgelieferten Lampe,
# weil sich der Zaehler aendert).
#
# Der Schluessel enthaelt das hereingereichte ImageFont-MODUL und FONT_SEARCH_PATHS, nicht
# nur Groesse/Fettung: beide werden in den Tests ersetzt (nachgebautes Modul,
# monkeypatch auf eine leere oder andere Suchkette), und ein Cache, der das ignoriert,
# wuerde einem Test die Schrift eines anderen unterschieben - genau der Grund, aus dem
# Task 7b noch bewusst auf einen Cache verzichtet hatte.
_FONT_CACHE: dict = {}
def _load_font(image_font, size_px: int, bold: bool = False):
"""Laedt eine skalierbare Schrift der Groesse `size_px` aus FONT_SEARCH_PATHS - der
erste Pfad, der sich oeffnen laesst, gewinnt.
erste Pfad, der sich oeffnen laesst, gewinnt. Das Ergebnis wird prozessweit
zwischengespeichert (siehe _FONT_CACHE): dieselbe Schrift in derselben Groesse wird
je Prozess genau einmal von der Platte gelesen und geparst.
`image_font` ist das PIL.ImageFont-MODUL, hereingereicht statt hier importiert: so
bleibt render.py (ausserhalb von compose()) ohne Pillow importierbar - dasselbe
@@ -297,13 +356,26 @@ def _load_font(image_font, size_px: int, bold: bool = False):
Pillow 10.1, Pi 2 hat 9.4.0 (siehe FONT_SEARCH_PATHS). Ein `size=`-Aufruf waere dort
ein TypeError mitten im Zeichnen - also genau in dem Pfad, der eigentlich der letzte
Rettungsanker ist."""
# Das Modul selbst (nicht seine id()) im Schluessel: Module und die nachgebauten
# Testmodule sind ueber ihre Identitaet hashbar, und solange der Schluessel das
# Objekt festhaelt, kann keine recycelte id() zwei verschiedene Module
# zusammenwerfen.
key = (image_font, size_px, bold, FONT_SEARCH_PATHS)
if key in _FONT_CACHE:
return _FONT_CACHE[key]
for regular_path, bold_path in FONT_SEARCH_PATHS:
path = bold_path if bold else regular_path
try:
return image_font.truetype(path, size_px)
font = image_font.truetype(path, size_px)
break
except (OSError, ImportError):
continue
return image_font.load_default()
else:
font = image_font.load_default()
_FONT_CACHE[key] = font
return font
def _draw_text(draw, text: str, font, top: int, height: int, fill: int,
@@ -341,13 +413,26 @@ def compose(state: dict):
image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255)
draw = ImageDraw.Draw(image)
fonts = {
ROLE_HEADER: _load_font(ImageFont, HEADER_FONT_PX, bold=True),
ROLE_HEADER_PHASE: _load_font(ImageFont, HEADER_PHASE_FONT_PX, bold=True),
ROLE_HERO: _load_font(ImageFont, HERO_FONT_PX, bold=True),
ROLE_DETAIL: _load_font(ImageFont, DETAIL_FONT_PX),
ROLE_BIG: _load_font(ImageFont, BIG_FONT_PX, bold=True),
# Je Rolle FAUL geladen, nicht alle fuenf im Voraus (Befund M4): "Station aus" und
# "Status unbekannt" zeichnen genau eine Rolle (ROLE_BIG) - vier der fuenf Schriften
# waeren dort reine Ladezeit. Zusammen mit _FONT_CACHE kostet jede Rolle im Betrieb
# hoechstens einmal je Prozess etwas, danach nur noch einen Woerterbuchzugriff.
role_fonts = {
ROLE_HEADER: (HEADER_FONT_PX, True),
ROLE_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True),
ROLE_HERO: (HERO_FONT_PX, True),
ROLE_DETAIL: (DETAIL_FONT_PX, False),
ROLE_BIG: (BIG_FONT_PX, True),
}
loaded: dict = {}
def font_for(role: str):
if role not in loaded:
size_px, bold = role_fonts[role]
loaded[role] = _load_font(ImageFont, size_px, bold=bold)
return loaded[role]
max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
def fit(raw_text: str, font, width_px: float = max_width_px) -> str:
@@ -372,7 +457,7 @@ def compose(state: dict):
y = TEXT_MARGIN_PX
for raw_text, role in block_list:
font = fonts[role]
font = font_for(role)
if role == ROLE_BIG:
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze
+35 -7
View File
@@ -282,9 +282,24 @@ display_run() {
# veraendert, was 'off' zuvor bewusst hergestellt hatte. Deshalb "is-active" vorher
# fragen statt blind "stop" und "start".
#
# Rueckgabewert ist der von display.py - der Handgriff muss weiterhin rot werden, wenn
# das Panel nicht antwortet; das Wiederanfahren des Dienstes darf ihn weder verdecken
# noch (bei eigenem Fehlschlag) einen Panel-Erfolg in einen Fehler verwandeln.
# Der Rueckgabewert von display.py wird unveraendert durchgereicht, und das
# Wiederanfahren des Dienstes darf ihn weder verdecken noch (bei eigenem Fehlschlag) in
# einen Fehler verwandeln.
#
# Was dieser Rueckgabewert NICHT ist: eine Aussage ueber das Panel. display.py's main()
# faengt DisplayUnavailable/ImportError ab, protokolliert eine Zeile und beendet sich mit
# sys.exit(0) - die ueber mehrere Fixrunden erkaempfte Zusage "die Anzeige ist nirgends
# Voraussetzung". Fehlendes Panel, fehlendes SPI, fehlendes Pillow, lgpio 'GPIO busy':
# alles rc=0. Rot wird der Handgriff nur bei einem echten Absturz (ein Traceback kommt
# unveraendert durch). Ob die Revision stimmt, beurteilst du deshalb AM PANEL - zeigt es
# ein sauberes, seitenrichtiges Testbild? - und nie am Exit-Code; "Exit 0" heisst hier
# ausdruecklich nicht "Panel in Ordnung".
display_service_resume() {
[ "${_was_active:-0}" -eq 1 ] || return 0
sudo systemctl start ninlight-display 2>/dev/null \
|| echo "Warnung: Anzeigedienst konnte nicht wieder gestartet werden - 'systemctl status ninlight-display' pruefen" >&2
}
display_handover() {
_was_active=0
if systemctl is-active --quiet ninlight-display 2>/dev/null; then
@@ -292,13 +307,26 @@ display_handover() {
sudo systemctl stop ninlight-display 2>/dev/null || true
fi
# Zwischen "stop" und "start" darf uns kein Signal aus der Hand nehmen, den Dienst
# wieder anzufahren. Der Fall ist nicht theoretisch: die Abnahmeprozedur in
# display.py's Modul-Docstring sagt ausdruecklich, dass bei falscher Revision
# ReadBusy() NIE zurueckkehrt - der Abnehmer probiert EPD_REVISION=V3, das Panel
# haengt, er drueckt Strg-C. Ohne Handler stirbt display_handover mit, der
# Anzeigedienst bleibt aus, das Panel bleibt schwarz, und mitten in der Lampenabnahme
# sieht das aus wie "Panel kaputt".
#
# Die Handler beenden SELBST (130/143/129 = 128 + Signalnummer): ein Handler ohne
# "exit" laesst die Shell hinter dem unterbrochenen Kommando weiterlaufen - in diesem
# Projekt schon einmal ein Befund (setup_pi.sh, Profilschreiben).
trap 'display_service_resume; exit 130' INT
trap 'display_service_resume; exit 143' TERM
trap 'display_service_resume; exit 129' HUP
_rc=0
display_run "$1" || _rc=$?
if [ "$_was_active" -eq 1 ]; then
sudo systemctl start ninlight-display 2>/dev/null \
|| echo "Warnung: Anzeigedienst konnte nicht wieder gestartet werden - 'systemctl status ninlight-display' pruefen" >&2
fi
trap - INT TERM HUP
display_service_resume
return "$_rc"
}
+80
View File
@@ -198,11 +198,35 @@ setup_sandbox() {
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
@@ -672,6 +696,62 @@ 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
+297 -23
View File
@@ -148,25 +148,94 @@ def test_off_screen_is_drawn_big_and_centered():
assert abs(top - bottom) <= 4, f"nicht senkrecht mittig (oben {top}, unten {bottom})"
def test_header_is_a_black_bar_with_white_text():
"""Die Kopfzeile ist invertiert: schwarzer Balken ueber die volle Breite, Modus in
Weiss darin. Geprueft an einer Zeile mitten im Balken - die muss ueberwiegend schwarz
sein UND weisse Pixel enthalten (sonst stuende dort kein Text, sondern nur ein
Balken)."""
image = render.compose(_migration_state())
pixels = image.load()
row = render.HEADER_BAR_HEIGHT_PX // 2
HEADER_MIN_BLACK_SHARE = 0.55
black = sum(1 for x in range(render.PANEL_WIDTH_PX) if pixels[x, row] == 0)
white = sum(1 for x in range(render.PANEL_WIDTH_PX) if pixels[x, row] != 0)
assert black > 150, f"Kopfzeile ist kein schwarzer Balken (nur {black} schwarze Pixel)"
assert white >= 5, "keine weisse Schrift im Balken - der Balken uebermalt den Modus"
def _assert_header_bar_with_text_on_both_sides(image, hint=""):
"""Die Zusage ueber den Kopfbalken, SCHRIFTUNABHAENGIG formuliert (dritte
Hardware-Abnahme, Befund C1).
Vorher stand hier eine absolute Pixelzahl ("> 150 schwarze Pixel in Zeile 11"), gegen
Pillow 12 mit ARIAL auf der Entwicklungsmaschine kalibriert. Auf dem Pi und auf der
Debian-13-Maschine zeichnet DejaVu, das merklich breiter laeuft - dort blieben 149
schwarze Pixel uebrig und die Suite war rot, ohne dass sich am Code etwas geaendert
hatte. Die Schwelle einfach auf 145 nachzuziehen haette nur gegen die naechste
Schrift kalibriert.
Stattdessen drei Zusagen, die keine Schrift kennen:
* ANTEIL statt Anzahl, und ueber den GANZEN Balken statt ueber eine einzelne Zeile:
mindestens 55 % des Balkens sind schwarz. Ueber sechs sehr verschiedene Schriften
gemessen (Arial, Arial Black, Verdana, Courier New, Impact, Georgia) liegt der
Anteil zwischen 69 % und 93 %, mit load_default() bei 93 % - die Schwelle trennt
also "Balken mit Schrift darin" von "kein Balken" (ohne Rechteck: rund 5 %), ohne
an einer Schriftbreite zu haengen. Eine einzelne Zeile allein taugt dafuer nicht:
dieselben sechs Schriften streuen in Zeile 11 zwischen 43 % und 69 %.
* Weisse Schrift LINKS UND RECHTS der Panelmitte: der Modus steht links, die Phase
rechts. Faellt eine von beiden aus (oder uebermalt der Balken den Text), ist die
betreffende Haelfte leer.
* Der Balken reicht ueber die volle Breite: die aeussersten Spalten sind
durchgehend schwarz - sie liegen vor TEXT_MARGIN_PX, dort kann keine Schrift
stehen."""
pixels = image.load()
height = render.HEADER_BAR_HEIGHT_PX
width = render.PANEL_WIDTH_PX
middle = width // 2
black = sum(1 for y in range(height) for x in range(width) if pixels[x, y] == 0)
share = black / float(height * width)
assert share >= HEADER_MIN_BLACK_SHARE, (
f"Kopfzeile ist kein schwarzer Balken (nur {share:.0%} schwarz){hint}")
white_left = sum(1 for y in range(height) for x in range(middle) if pixels[x, y] != 0)
white_right = sum(1 for y in range(height) for x in range(middle, width)
if pixels[x, y] != 0)
assert white_left >= 5, f"kein Modus-Text links im Balken{hint}"
assert white_right >= 5, f"keine Phase rechts im Balken{hint}"
for x in (0, width - 1):
assert all(pixels[x, y] == 0 for y in range(height)), (
f"Spalte {x} des Kopfbalkens ist nicht durchgehend schwarz{hint}")
def test_header_is_a_black_bar_with_white_text():
"""Die Kopfzeile ist invertiert: schwarzer Balken ueber die volle Breite, Modus links
und Phase rechts in Weiss darin."""
_assert_header_bar_with_text_on_both_sides(render.compose(_migration_state()))
def test_header_promise_survives_a_noticeably_wider_font(monkeypatch):
"""Gegenprobe zu C1: dieselbe Zusage muss auch dann halten, wenn die Schrift deutlich
mehr Platz braucht als die hier gefundene.
Statt eine zweite Schriftdatei vorauszusetzen (DejaVu gibt es auf der macOS-
Entwicklungsmaschine nicht, Arial nicht auf dem Pi - ein Test, der die eine oder die
andere braucht, waere auf je einer der beiden Maschinen nur ein Skip) wird dieselbe
Schrift 25 % groesser gezogen: das ist genau der Effekt, den eine breitere Schrift auf
diese Zusage hat, und es laeuft auf jeder Maschine gleich. Der Abstand DejaVu-zu-Arial
liegt bei rund 13 % Breite ("MIGRATION": 128 px gegen 113 px), hier also mit Puffer
ueberschritten. Dass die Phase dabei gekuerzt wird, ist erlaubt - sie darf nur nicht
verschwinden oder den Modus ueberlaufen."""
monkeypatch.setattr(render, "HEADER_FONT_PX", int(render.HEADER_FONT_PX * 1.25))
monkeypatch.setattr(render, "HEADER_PHASE_FONT_PX",
int(render.HEADER_PHASE_FONT_PX * 1.25))
_assert_header_bar_with_text_on_both_sides(
render.compose(_migration_state()), hint=" (mit 25 % breiterer Schrift)")
def test_hero_line_is_bigger_than_the_details():
"""Die wichtigste Zeile (hier: der erkannte Produktname) muss sich vom Beiwerk
abheben - sonst ist die Rollenvergabe in blocks() folgenlos. Gemessen: 15 px Tinte
fuer die grosse Zeile gegen 10 px fuer die erste Beiwerkzeile."""
abheben - sonst ist die Rollenvergabe in blocks() folgenlos.
Als VERHAELTNIS der Tintenhoehen, nicht als absoluter Pixelabstand (dritte
Hardware-Abnahme, C1: auch die uebrigen Schwellen dieser Datei schriftunabhaengig
machen). Die Zusage kommt aus dem Verhaeltnis der Schriftgroessen -
HERO_FONT_PX/DETAIL_FONT_PX = 16/11 = 1,45 - und das ueberlebt einen
Schriftwechsel, ein "mindestens 3 px mehr" nicht: ueber sieben Schriften gemessen
liegt das Verhaeltnis zwischen 1,30 (Courier New) und 1,60 (Verdana), der absolute
Abstand dagegen zwischen 3 px und 6 px - mit 3 px waere Courier New genau auf der
Kante. Mit load_default() (beide Rollen rund 8 px) liegt das Verhaeltnis bei 1,0,
der Test also weiterhin rot, wenn die Schriftsuche ausfaellt."""
image = render.compose(_migration_state())
hero_top = render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX
hero_bottom = hero_top + render.HERO_LINE_HEIGHT_PX
@@ -177,9 +246,10 @@ def test_hero_line_is_bigger_than_the_details():
hero_height = hero[3] - hero[1] + 1
detail_height = detail[3] - detail[1] + 1
assert hero_height >= detail_height + 3, (
assert hero_height >= detail_height * 1.2, (
f"grosse Zeile ({hero_height} px) hebt sich nicht vom Beiwerk "
f"({detail_height} px) ab")
f"({detail_height} px) ab - Verhaeltnis "
f"{hero_height / float(detail_height):.2f}, erwartet mindestens 1,20")
def test_the_last_delivery_line_still_fits_on_the_panel():
@@ -239,12 +309,22 @@ def test_compose_stops_drawing_details_before_the_bottom_margin(monkeypatch):
image = render.compose({"mode": "migration"})
pixels = image.load()
rows_with_ink = [
any(pixels[x, y] == 0 for x in range(render.PANEL_WIDTH_PX))
for y in range(render.HEADER_BAR_HEIGHT_PX, render.PANEL_HEIGHT_PX)]
# Je Zeile IHR eigenes Band gezaehlt, nicht zusammenhaengende Tinten-Cluster
# (dritte Hardware-Abnahme, C1: "auch anderswo schriftabhaengige Schwellen suchen"):
# die Clusterzaehlung setzte stillschweigend voraus, dass zwischen zwei Zeilen eine
# komplett tintenfreie Pixelzeile bleibt. Das ist eine Aussage ueber die
# Schrifthoehe bei DETAIL_LINE_HEIGHT_PX Zeilenabstand - mit einer hoeher bauenden
# Schrift waeren zwei Zeilen zu einem Cluster verschmolzen und der Test rot, ohne
# dass am Code etwas falsch waere. Bandweise gezaehlt ist es egal, ob sich die
# Zeilen beruehren.
first_top = render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX
bands = range((render.PANEL_HEIGHT_PX - first_top) // render.DETAIL_LINE_HEIGHT_PX)
drawn_lines = sum(
1 for i, has_ink in enumerate(rows_with_ink)
if has_ink and not (i > 0 and rows_with_ink[i - 1]))
1 for i in bands
if any(pixels[x, y] == 0
for y in range(first_top + i * render.DETAIL_LINE_HEIGHT_PX,
first_top + (i + 1) * render.DETAIL_LINE_HEIGHT_PX)
for x in range(render.PANEL_WIDTH_PX)))
assert drawn_lines == 6, (
f"{drawn_lines} Beiwerkzeilen gezeichnet, sechs erwartet - "
@@ -350,6 +430,115 @@ def test_long_armed_target_does_not_overlap_the_mode_in_the_header():
)
def test_header_phase_is_dropped_when_too_little_room_is_left(monkeypatch):
"""Deckt den "Weglassen"-Zweig ab (dritte Hardware-Abnahme, Befund M2): bleibt neben
dem Modus weniger als HEADER_PHASE_MIN_WIDTH_PX uebrig, wird die Phase ganz
weggelassen statt ein einsames, rechtsbuendiges "…" zu zeichnen, das sich mit dem
Modus ueberlagert.
Mit den heutigen Werten kann der Zweig nicht von selbst feuern - der Modus-Text im
Migrationsmodus ist die Konstante "MIGRATION", available_px also fest (112 px DejaVu,
127 px Arial). Hier wird deshalb der Mindestabstand hochgedreht: das ist der
direkteste, von jeder Schrift unabhaengige Weg, available_px unter die Schwelle zu
druecken, und genau das ist die Bedingung, um die es geht."""
monkeypatch.setattr(render, "HEADER_PHASE_GAP_PX", 200)
image = render.compose(_migration_state())
pixels = image.load()
header_rows = range(render.HEADER_BAR_HEIGHT_PX)
white_cols = [x for x in range(render.PANEL_WIDTH_PX)
if any(pixels[x, y] == 255 for y in header_rows)]
assert white_cols, "der Modus fehlt jetzt auch"
# Vorbedingung: der Modus selbst reicht nicht in den geprueften rechten Streifen -
# sonst wuerde der Test etwas anderes messen als "die Phase fehlt".
reserved_from = render.PANEL_WIDTH_PX - render.HEADER_PHASE_MIN_WIDTH_PX
assert max(white_cols) < reserved_from - 4, (
f"der Modus-Text reicht bis Spalte {max(white_cols)} - der Test kann so nicht "
"zwischen Modus und Phase unterscheiden")
assert not [x for x in white_cols if x >= reserved_from], (
"die Phase wurde trotz fehlenden Platzes gezeichnet - rechtsbuendig, also "
"quer ueber den Modus")
# --- Schriftladen: Prozess-Cache und faules Laden (Befund M4) ------------------------
#
# compose() baute je Aufruf fuenf Schriften neu: auf der Entwicklungsmaschine 21,2 ms von
# 25,0 ms, auf einem ARMv6-Kern grob 0,5-1 s - bei JEDER ausgelieferten Lampe, auf
# demselben Kern wie server.py.
def test_font_cache_reuses_the_same_font_object():
"""Zweimal dieselbe Rolle -> die Schriftdatei wird nur einmal gelesen."""
image_font = _FakeImageFontModule(available=(DEJAVU_REGULAR, DEJAVU_BOLD))
first = render._load_font(image_font, 11)
second = render._load_font(image_font, 11)
assert first is second
assert len(image_font.truetype_calls) == 1, (
f"Schrift mehrfach geladen: {image_font.truetype_calls}")
def test_font_cache_keeps_different_modules_and_search_paths_apart(monkeypatch):
"""Der Cache-Schluessel muss das hereingereichte ImageFont-Modul UND
FONT_SEARCH_PATHS enthalten - beides wird in den Tests ersetzt. Ein Cache, der nur
Groesse/Fettung kennt, wuerde z.B. test_compose_still_works_without_any_font_file
(leere Suchkette) eine vorher geladene echte Schrift unterschieben und die Zusage
"eine fehlende Schrift haelt nichts auf" stillschweigend entwerten."""
first_module = _FakeImageFontModule(available=(DEJAVU_REGULAR,))
second_module = _FakeImageFontModule(available=(DEJAVU_REGULAR,))
render._load_font(first_module, 11)
render._load_font(second_module, 11)
assert len(second_module.truetype_calls) == 1, "Cache reicht Modulgrenzen hinueber"
monkeypatch.setattr(render, "FONT_SEARCH_PATHS", ())
assert render._load_font(first_module, 11) == ("default",), (
"leere Suchkette liefert eine zwischengespeicherte Schrift von vorher")
def test_compose_loads_only_the_roles_it_actually_draws(monkeypatch):
""""Station aus" zeichnet genau eine Rolle - die anderen vier Schriften duerfen gar
nicht erst gebaut werden (faules Laden, Befund M4)."""
real_load_font = render._load_font
requested = []
def spy(image_font, size_px, bold=False):
requested.append(size_px)
return real_load_font(image_font, size_px, bold=bold)
monkeypatch.setattr(render, "_load_font", spy)
render.compose({"mode": "off"})
assert requested == [render.BIG_FONT_PX], (
f"compose() hat {len(requested)} Schriften angefordert, gezeichnet wird eine: "
f"{requested}")
def test_compose_reads_no_font_file_a_second_time(monkeypatch):
"""Der Prozess-Cache am echten Pillow: der zweite compose()-Aufruf desselben
Prozesses fasst keine Schriftdatei mehr an. Das ist die Zusage, an der auf dem Pi die
Zeichenzeit haengt - der Daemon zeichnet bei jeder ausgelieferten Lampe neu."""
from PIL import ImageFont
state = _migration_state()
render.compose(state) # Cache fuellen
truetype_calls = []
real_truetype = ImageFont.truetype
def counting_truetype(*args, **kwargs):
truetype_calls.append(args)
return real_truetype(*args, **kwargs)
monkeypatch.setattr(ImageFont, "truetype", counting_truetype)
render.compose(state)
assert truetype_calls == [], (
f"Schriften werden bei jedem compose() neu von der Platte gelesen: "
f"{truetype_calls}")
def test_compose_still_works_without_any_font_file(monkeypatch):
"""Die Zusage "eine fehlende Schrift haelt die Anzeige nicht auf" gilt unveraendert:
ohne einen einzigen Treffer in der Suchkette faellt compose() auf load_default()
@@ -451,10 +640,95 @@ def test_missing_fields_do_not_raise():
assert render.lines(state)
def test_armed_phase_is_visible():
# --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) -----
#
# Frueher stand "FREIGEGEBEN: <ziel>" als Phase im Kopfbalken. Neben dem Modus bleiben
# dort 112 px (DejaVu) - der Text misst 182 px, gezeichnet wurde "FREIGEGEBE…", also ein
# Wortfragment, das bei jedem Ziel gleich aussieht. Der alte Test dazu
# (test_armed_phase_is_visible) prueft lines(), also den UNGEKUERZTEN Text, und war
# deshalb gruen, waehrend auf dem Panel nichts davon zu lesen war. Die Tests hier pruefen
# das GEZEICHNETE BILD.
def _armed_state():
"""Wie _migration_state(), aber mit einem Produktnamen OHNE Unterlaengen: die
Bildvergleiche unten schneiden die erste Beiwerkzeile zeilengenau aus, und ein 'y'
oder 'q' der grossen Zeile darueber ragte in diesen Ausschnitt hinein."""
state = _migration_state()
state["last_seen"] = dict(state["last_seen"], product_name="KLYQA GU10 RGB")
return state
def _detail_band(index):
"""(oben, unten) der `index`-ten Beiwerkzeile unter der grossen Zeile."""
top = (render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX
+ render.HERO_LINE_HEIGHT_PX + index * render.DETAIL_LINE_HEIGHT_PX)
return top, top + render.DETAIL_LINE_HEIGHT_PX
def _band_pixels(image, top, bottom):
pixels = image.load()
return [tuple(pixels[x, y] for x in range(render.PANEL_WIDTH_PX))
for y in range(top, bottom)]
def _reference_detail_line(text, top):
"""Zeichnet `text` allein, mit derselben Schrift und an derselben Stelle, die
compose() fuer eine Beiwerkzeile benutzt. Der Vergleich mit dem echten Bild ist damit
schriftunabhaengig: er sagt "genau dieser Text, ungekuerzt", ohne eine Pixelbreite zu
behaupten."""
from PIL import Image, ImageDraw, ImageFont
image = Image.new("1", (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX), 255)
draw = ImageDraw.Draw(image)
draw.text((render.TEXT_MARGIN_PX, top), text,
font=render._load_font(ImageFont, render.DETAIL_FONT_PX), fill=0)
return image
def test_armed_target_is_drawn_in_full_as_its_own_line():
"""Der Zielname muss vollstaendig und lesbar auf dem Panel stehen - als eigene
Beiwerkzeile ueber die volle Breite, nicht als Rest eines im Kopf abgeschnittenen
Texts. Geprueft durch Pixelvergleich mit einer unabhaengig gezeichneten Referenzzeile:
kuerzt compose() den Namen (oder laesst die Zeile ganz weg), stimmt der Ausschnitt
nicht mehr ueberein."""
state = _armed_state()
expected = render.ARMED_DETAIL_PREFIX + state["armed_target"]
assert expected in render.lines(state), render.lines(state)
top, bottom = _detail_band(0)
drawn = _band_pixels(render.compose(state), top, bottom)
reference = _band_pixels(_reference_detail_line(expected, top), top, bottom)
assert drawn == reference, (
f"die erste Beiwerkzeile zeigt nicht ungekuerzt {expected!r} - der Bediener "
"kann nicht sehen, welche Lampe freigegeben ist")
def test_header_does_not_depend_on_the_armed_target_name():
"""Die Gegenrichtung, und die eigentliche Mutationswache fuer I2: der Kopfbalken darf
den Zielnamen gar nicht mehr enthalten. Das laesst sich ohne jede Schriftannahme
pruefen - zwei sehr verschiedene Zielnamen muessen denselben Kopfbalken ergeben.
Zeichnet ihn jemand wieder in den Kopf, unterscheiden sich die beiden Balken (oder,
bei fruehem Abschneiden, spaetestens beim langen Namen)."""
short = _armed_state()
long_name = _armed_state()
long_name["armed_target"] = "sehr_langer_zielname_" * 4
band = (0, render.HEADER_BAR_HEIGHT_PX)
assert (_band_pixels(render.compose(short), *band)
== _band_pixels(render.compose(long_name), *band)), (
"der Kopfbalken haengt am Zielnamen - dort ist kein Platz dafuer, er wird "
"abgeschnitten und ist bei jedem Ziel gleich unlesbar")
def test_armed_phase_label_is_visible():
"""Das kurze Label bleibt (es sagt, DASS etwas freigegeben ist) - nur der Zielname
steht nicht mehr daneben."""
lines = render.lines({"mode": "migration", "phase": "armed", "armed_target": "g95_cw",
"served": 0, "recent": [], "ssid": "qconnex-firmware"})
assert "FREIGEGEBEN" in "\n".join(lines)
text = "\n".join(lines)
assert render.ARMED_LABEL in text
assert "g95_cw" in text
# --- display.build_state() (Fixrunde 1, I3) ---------------------------------------