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:
+104
-19
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ---------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user