feat(display): grosse Schrift, invertierter Kopf, GPIO-Uebergabe
Zwei Befunde der Hardware-Abnahme. 1) Die Schrift war zu klein: compose() zeichnete alles in ImageFont.load_default() (rund 10 px) - auf 250x122 aus zwei Metern nicht lesbar. blocks() vergibt jetzt Rollen (header/hero/detail/big), compose() setzt jede Rolle eigen: Modus fett und weiss auf schwarzem Balken, die wichtigste Zeile gross (Migration: erkannte Lampe, Rettung: SSID), Beiwerk klein, "Station aus" gross und mittig. Die Schrift kommt per ImageFont.truetype() aus FONT_SEARCH_PATHS mit DejaVu (/usr/share/fonts/truetype/dejavu/) als Hauptweg - auf beiden Pis vorhanden. load_default(size=...) scheidet aus: das gibt es erst ab Pillow 10.1, Pi 2 hat 9.4.0. Letzter Rueckfall bleibt load_default() ohne Argument, damit eine fehlende Schrift die Anzeige (und damit nie eine Migration) aufhaelt. lines() bleibt ohne Pillow importierbar, die Breitenkuerzung misst weiter in Pixeln. 2) "display test" scheiterte bei laufendem Anzeigedienst mit einem rohen "lgpio.error: 'GPIO busy'": lgpio.error ist keine gpiozero.GPIOZeroError und fiel durch die Ausnahmeliste in epdconfig.module_init(). Sie ist jetzt aufgefuehrt - weiterhin gezielt, TypeError/AttributeError/NameError bleiben Programmierfehler - und die Meldung nennt bei belegten Leitungen den Anzeigedienst. "display test|clear" stoppt den Dienst fuer die Dauer des Handgriffs und startet ihn danach wieder, aber nur, wenn er vorher lief.
This commit is contained in:
@@ -123,12 +123,21 @@ RUN_DIR="$rundir"
|
||||
UNIT_FILE="$unitfile"
|
||||
case "\$1" in
|
||||
is-active)
|
||||
if [ "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-0}" -eq 0 ]; then
|
||||
# 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}"
|
||||
else
|
||||
code="\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-0}"
|
||||
fi
|
||||
if [ "\$code" -eq 0 ]; then
|
||||
echo active
|
||||
exit 0
|
||||
else
|
||||
echo inactive
|
||||
exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT}"
|
||||
exit "\$code"
|
||||
fi
|
||||
;;
|
||||
restart|stop)
|
||||
@@ -137,15 +146,14 @@ case "\$1" in
|
||||
# RuntimeDirectory bei JEDEM Stop/Restart, nicht nur beim allerersten Start,
|
||||
# und legt es leer wieder an.
|
||||
#
|
||||
# ACHTUNG, Stub-Erfindung (Task 6b F1): die Regel wird hier auf JEDE
|
||||
# Unit angewandt, also auch auf ninlight-display - die traegt aber gar kein
|
||||
# RuntimeDirectory=, ein "stop ninlight-display" raeumt in Wirklichkeit nichts
|
||||
# weg. \$UNIT_FILE ist immer ninlight-station.service, unabhaengig davon, welche
|
||||
# Unit im Aufruf steht. Heute folgenlos (kein Testfall stoppt die Anzeige und
|
||||
# erwartet danach noch Dateien in RUN_DIR), aber niemand darf sich darauf
|
||||
# verlassen: wer kuenftig prueft, was ein "display off" mit RUN_DIR macht, muss
|
||||
# den Stub vorher nach Unit unterscheiden lassen.
|
||||
if ! grep -q '^RuntimeDirectoryPreserve=restart' "\$UNIT_FILE" 2>/dev/null; then
|
||||
# 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
|
||||
@@ -274,8 +282,8 @@ 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)
|
||||
UP_LINE=$(grep -n "nmcli con up qconnex-firmware" "$SBX/cmd.log" | head -n1 | cut -d: -f1)
|
||||
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
|
||||
@@ -595,9 +603,25 @@ grep -Fq "STUB display.py --test" "$SBX/cmd.log" && pass "display.py wurde mit -
|
||||
# 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")"
|
||||
# Nicht gleichzeitig den Anzeigedienst umbringen: 'display test' ist ein Handgriff bei
|
||||
# der Abnahme, kein Ausschalten.
|
||||
grep -Fq "systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'display test' stoppt den Anzeigedienst - das soll nur 'display off'/'off'" || pass "'display test' laesst den Anzeigedienst in Ruhe"
|
||||
# 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 ==="
|
||||
@@ -611,6 +635,42 @@ 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 D3 (Task 6b, Punkt 4): 'display off' stoppt den Dienst VOR dem Leeren des Panels ==="
|
||||
@@ -624,8 +684,8 @@ 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)
|
||||
CLEAR_LINE=$(grep -n "STUB display.py --clear" "$SBX/cmd.log" | head -n1 | cut -d: -f1)
|
||||
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
|
||||
@@ -649,8 +709,8 @@ 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" && pass "'off' stoppt den Anzeigedienst" || fail "'off' stoppt den Anzeigedienst nicht: $(cat "$SBX/cmd.log")"
|
||||
grep -Fq "STUB display.py --clear" "$SBX/cmd.log" && pass "'off' leert das Panel" || fail "'off' leert das Panel nicht: $(cat "$SBX/cmd.log")"
|
||||
STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1)
|
||||
CLEAR_LINE=$(grep -n "STUB display.py --clear" "$SBX/cmd.log" | head -n1 | cut -d: -f1)
|
||||
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 "auch bei 'off': erst stoppen, dann leeren"
|
||||
else
|
||||
@@ -658,7 +718,7 @@ else
|
||||
fi
|
||||
# Das Panel muss geleert sein, BEVOR ap_switch das Ergebnis von "off" verkuenden kann -
|
||||
# und vor allem, bevor ap_switch bei haengendem NetworkManager abbricht (Fall D6).
|
||||
UP_LINE=$(grep -n "nmcli con up home-wifi" "$SBX/cmd.log" | head -n1 | cut -d: -f1)
|
||||
UP_LINE=$(grep -n "nmcli con up home-wifi" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true)
|
||||
if [ -n "$UP_LINE" ] && [ "$CLEAR_LINE" -lt "$UP_LINE" ]; then
|
||||
pass "Panel ist geleert, bevor wlan0 ins Heim-WLAN zurueckgeschaltet wird"
|
||||
else
|
||||
|
||||
@@ -2,6 +2,8 @@ import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display"))
|
||||
|
||||
import display # noqa: E402
|
||||
@@ -75,7 +77,12 @@ def test_compose_does_not_draw_into_the_last_column_for_long_lines():
|
||||
(empirisch mit echtem Pillow bestaetigt: die rechte Kante liegt bei ca. Spalte 237,
|
||||
deutlich vor Spalte 249). Mit deaktivierter Kuerzung fuellt der zeichenweise laengere
|
||||
Text die Zeile bis zum Rand - ebenfalls empirisch bestaetigt (die letzte Spalte
|
||||
enthaelt dann einen schwarzen Pixel)."""
|
||||
enthaelt dann einen schwarzen Pixel).
|
||||
|
||||
Geprueft wird erst UNTERHALB des Kopfbalkens: der ist seit der Schriftumstellung
|
||||
absichtlich vollflaechig schwarz (weisse Schrift auf schwarzem Grund) und faerbt die
|
||||
letzte Spalte in seinen eigenen Zeilen mit - das ist Layout, keine ueberlaufende
|
||||
Zeile. Die absichtlich zu lange Zeile (der Produktname) steht ohnehin darunter."""
|
||||
state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [],
|
||||
"ssid": "qconnex-firmware",
|
||||
"last_seen": {"product_name": "W" * 200, "target": "e27_rgb",
|
||||
@@ -85,10 +92,207 @@ def test_compose_does_not_draw_into_the_last_column_for_long_lines():
|
||||
pixels = image.load()
|
||||
last_col = render.PANEL_WIDTH_PX - 1
|
||||
|
||||
black_in_last_column = any(pixels[last_col, y] == 0 for y in range(render.PANEL_HEIGHT_PX))
|
||||
black_in_last_column = any(
|
||||
pixels[last_col, y] == 0
|
||||
for y in range(render.HEADER_BAR_HEIGHT_PX, render.PANEL_HEIGHT_PX))
|
||||
assert not black_in_last_column, "Text erreicht die letzte Spalte - Kuerzung wirkt nicht"
|
||||
|
||||
|
||||
# --- Schriftgroesse/Layout (Hardware-Abnahme: "die Schrift ist zu klein") -----------
|
||||
#
|
||||
# Alle Tests hier messen die TINTE im tatsaechlich gezeichneten Bild, nicht die
|
||||
# Konstanten oben: eine Zusage ueber die Schriftgroesse, die nur die Konstante liest,
|
||||
# haelt niemanden auf, der die Schriftsuche entfernt und wieder auf
|
||||
# ImageFont.load_default() (rund 10 px) zurueckfaellt - genau die Mutation, gegen die
|
||||
# diese Tests wachen sollen.
|
||||
|
||||
def _ink_box(image, y0=0, y1=None):
|
||||
"""(links, oben, rechts, unten) der schwarzen Pixel im Band [y0, y1), oder None."""
|
||||
y1 = image.height if y1 is None else y1
|
||||
pixels = image.load()
|
||||
xs, ys = [], []
|
||||
for y in range(y0, y1):
|
||||
for x in range(image.width):
|
||||
if pixels[x, y] == 0:
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
if not xs:
|
||||
return None
|
||||
return (min(xs), min(ys), max(xs), max(ys))
|
||||
|
||||
|
||||
def _migration_state():
|
||||
return {"mode": "migration", "phase": "armed", "armed_target": "gu10_rgb",
|
||||
"served": 3, "ssid": "qconnex-firmware",
|
||||
"last_seen": {"product_name": "Klyqa GU10 RGB", "target": "gu10_rgb",
|
||||
"variant": "prod", "lamp_version": "V1.1.0.1138"},
|
||||
"recent": [{"target": "gu10_rgb", "status": "ok", "ts": "2026-09-13T13:04:02Z"}]}
|
||||
|
||||
|
||||
def test_off_screen_is_drawn_big_and_centered():
|
||||
""""Station aus" hat fast keinen Inhalt und muss aus zwei Metern lesbar sein.
|
||||
|
||||
Gemessen mit echtem Pillow: mit der Schriftsuche ist die Tinte 19 px hoch und links
|
||||
wie rechts 56 px vom Rand entfernt; faellt compose() auf load_default() zurueck, sind
|
||||
es 8 px - die Schwelle 14 trennt beide Faelle eindeutig."""
|
||||
image = render.compose({"mode": "off"})
|
||||
box = _ink_box(image)
|
||||
assert box is not None, "gar nichts gezeichnet"
|
||||
|
||||
height = box[3] - box[1] + 1
|
||||
assert height >= 14, f"Schrift nur {height} px hoch - Schriftsuche wirkt nicht"
|
||||
|
||||
left, right = box[0], render.PANEL_WIDTH_PX - 1 - box[2]
|
||||
assert abs(left - right) <= 3, f"nicht waagerecht mittig (links {left}, rechts {right})"
|
||||
top, bottom = box[1], render.PANEL_HEIGHT_PX - 1 - box[3]
|
||||
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
|
||||
|
||||
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 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."""
|
||||
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
|
||||
|
||||
hero = _ink_box(image, hero_top, hero_bottom)
|
||||
detail = _ink_box(image, hero_bottom, hero_bottom + render.DETAIL_LINE_HEIGHT_PX)
|
||||
assert hero is not None and detail is not None
|
||||
|
||||
hero_height = hero[3] - hero[1] + 1
|
||||
detail_height = detail[3] - detail[1] + 1
|
||||
assert hero_height >= detail_height + 3, (
|
||||
f"grosse Zeile ({hero_height} px) hebt sich nicht vom Beiwerk "
|
||||
f"({detail_height} px) ab")
|
||||
|
||||
|
||||
def test_the_last_delivery_line_still_fits_on_the_panel():
|
||||
"""Der vollste Zustand (Kopfbalken, grosse Zeile, sechs Beiwerkzeilen) muss
|
||||
vollstaendig aufs Panel passen - mit groesserer Schrift ist das keine
|
||||
Selbstverstaendlichkeit mehr, und ausgerechnet die letzte Zeile ("zuletzt
|
||||
ausgeliefert") faellt sonst stumm hinten herunter."""
|
||||
state = _migration_state()
|
||||
expected = render.lines(state)
|
||||
assert len(expected) == 8, expected # sonst misst dieser Test etwas anderes
|
||||
|
||||
image = render.compose(state)
|
||||
last_line_top = (render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX
|
||||
+ render.HERO_LINE_HEIGHT_PX + 5 * render.DETAIL_LINE_HEIGHT_PX)
|
||||
assert _ink_box(image, last_line_top, render.PANEL_HEIGHT_PX) is not None, (
|
||||
"letzte Zeile fehlt - sie passt nicht mehr aufs Panel")
|
||||
|
||||
|
||||
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()
|
||||
zurueck und zeichnet - klein, aber vorhanden."""
|
||||
monkeypatch.setattr(render, "FONT_SEARCH_PATHS", ())
|
||||
image = render.compose({"mode": "off"})
|
||||
assert _ink_box(image) is not None
|
||||
|
||||
|
||||
# --- Die Schriftsuche selbst, ohne echte Schriftdateien und ohne altes Pillow -------
|
||||
|
||||
class _FakeImageFontModule:
|
||||
"""Nachbau der zwei ImageFont-Funktionen, die _load_font() benutzt.
|
||||
|
||||
`available` sind die Pfade, die es "gibt"; jeder andere truetype()-Aufruf scheitert
|
||||
mit OSError, genau wie Pillow es bei einer fehlenden Datei tut.
|
||||
|
||||
load_default() bildet Pillow 9.4.0 auf Pi 2 nach: KEIN size-Argument (das gibt es
|
||||
erst ab 10.1). Ein `size=`-Aufruf schlaegt hier deshalb mit demselben TypeError fehl
|
||||
wie am Geraet - so laesst sich die 9.4-Lage pruefen, ohne Pillow herunterzustufen."""
|
||||
|
||||
def __init__(self, available=()):
|
||||
self.available = set(available)
|
||||
self.truetype_calls = []
|
||||
self.load_default_calls = 0
|
||||
|
||||
def truetype(self, path, size):
|
||||
self.truetype_calls.append((path, size))
|
||||
if path not in self.available:
|
||||
raise OSError(f"cannot open resource: {path}")
|
||||
return ("truetype", path, size)
|
||||
|
||||
def load_default(self, *args, **kwargs):
|
||||
self.load_default_calls += 1
|
||||
if args or kwargs:
|
||||
raise TypeError(
|
||||
"load_default() got an unexpected keyword argument 'size'")
|
||||
return ("default",)
|
||||
|
||||
|
||||
DEJAVU_REGULAR = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
DEJAVU_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
||||
|
||||
|
||||
def test_font_search_takes_dejavu_first_on_the_pi():
|
||||
"""Auf beiden Pis liegt DejaVu unter /usr/share/fonts/truetype/dejavu/ - das ist der
|
||||
Hauptweg, und er muss VOR jedem Rueckfall kommen. Rot, sobald jemand die Suche
|
||||
entfernt und wieder direkt auf load_default() geht."""
|
||||
image_font = _FakeImageFontModule(available=(DEJAVU_REGULAR, DEJAVU_BOLD))
|
||||
|
||||
regular = render._load_font(image_font, 11)
|
||||
bold = render._load_font(image_font, 20, bold=True)
|
||||
|
||||
assert regular == ("truetype", DEJAVU_REGULAR, 11)
|
||||
assert bold == ("truetype", DEJAVU_BOLD, 20)
|
||||
assert image_font.truetype_calls[0] == (DEJAVU_REGULAR, 11), \
|
||||
"DejaVu wurde nicht zuerst probiert"
|
||||
assert image_font.load_default_calls == 0, \
|
||||
"load_default() benutzt, obwohl DejaVu da ist - die Schrift waere wieder winzig"
|
||||
|
||||
|
||||
def test_font_search_falls_through_to_a_later_candidate():
|
||||
"""Auf der Entwicklungsmaschine (macOS) gibt es den Debian-Pfad nicht - dann muss die
|
||||
Kette weiterlaufen statt sofort auf load_default() zu fallen, sonst laeuft der Test
|
||||
oben dort gegen eine 10-px-Schrift."""
|
||||
arial = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||
image_font = _FakeImageFontModule(available=(arial,))
|
||||
|
||||
assert render._load_font(image_font, 16) == ("truetype", arial, 16)
|
||||
assert image_font.load_default_calls == 0
|
||||
|
||||
|
||||
def test_font_search_ends_at_load_default_without_a_size_argument():
|
||||
"""Der letzte Rueckfall auf einem Rechner ohne jede Schriftdatei. Entscheidend ist,
|
||||
dass load_default() OHNE size gerufen wird: auf Pi 2 (Pillow 9.4.0) waere
|
||||
load_default(size=...) ein TypeError - der Fake wirft ihn hier genauso."""
|
||||
image_font = _FakeImageFontModule()
|
||||
|
||||
assert render._load_font(image_font, 26, bold=True) == ("default",)
|
||||
assert image_font.load_default_calls == 1
|
||||
assert len(image_font.truetype_calls) == len(render.FONT_SEARCH_PATHS)
|
||||
|
||||
|
||||
def test_font_search_survives_a_pillow_without_freetype():
|
||||
"""Ein ohne FreeType gebautes Pillow laesst truetype() mit ImportError scheitern -
|
||||
auch das darf die Anzeige nicht umbringen."""
|
||||
|
||||
class _NoFreetype(_FakeImageFontModule):
|
||||
def truetype(self, path, size):
|
||||
raise ImportError("The _imagingft C module is not installed")
|
||||
|
||||
image_font = _NoFreetype()
|
||||
assert render._load_font(image_font, 20) == ("default",)
|
||||
|
||||
|
||||
def test_missing_fields_do_not_raise():
|
||||
"""status.json kann jederzeit halb geschrieben oder aus einer aelteren Version sein."""
|
||||
for state in ({}, {"mode": "migration"}, {"mode": "migration", "last_seen": {}}):
|
||||
@@ -243,6 +447,80 @@ def _stub_hardware(monkeypatch):
|
||||
monkeypatch.setattr(epdconfig, "_busy_pin", None)
|
||||
|
||||
|
||||
# --- "GPIO busy" (Hardware-Abnahme, zweiter Befund) ---------------------------------
|
||||
|
||||
class _FakeLgpioModule:
|
||||
"""Nur das, was epdconfig von lgpio kennt: die Ausnahmeklasse. Wie im echten Modul
|
||||
erbt sie direkt von Exception - deshalb faengt eine Liste, die sie enthaelt, keinen
|
||||
TypeError/AttributeError/NameError mit ein."""
|
||||
|
||||
class error(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _gpiozero_raising(exception):
|
||||
"""Ein gpiozero-Ersatz, dessen erster Pin-Aufbau `exception` wirft."""
|
||||
|
||||
class _Raising:
|
||||
GPIOZeroError = _FakeGpiozeroModule.GPIOZeroError
|
||||
DigitalInputDevice = _FakeDigitalInputDevice
|
||||
|
||||
@staticmethod
|
||||
def DigitalOutputDevice(pin):
|
||||
raise exception
|
||||
|
||||
return _Raising
|
||||
|
||||
|
||||
def test_lgpio_gpio_busy_becomes_display_unavailable(monkeypatch):
|
||||
"""Der Befund vom Geraet: laeuft der Anzeigedienst, haelt er die GPIO-Leitungen, und
|
||||
"ninlight-station display test" bekam ein rohes `lgpio.error: 'GPIO busy'` samt
|
||||
Stapelabzug und rc=1 - lgpio.error ist KEINE gpiozero.GPIOZeroError und fiel durch
|
||||
die Ausnahmeliste in module_init(). Erwartet ist stattdessen DisplayUnavailable,
|
||||
das display.main() zu genau einer Protokollzeile und sys.exit(0) macht."""
|
||||
_stub_hardware(monkeypatch)
|
||||
monkeypatch.setattr(epdconfig, "lgpio", _FakeLgpioModule)
|
||||
monkeypatch.setattr(epdconfig, "gpiozero",
|
||||
_gpiozero_raising(_FakeLgpioModule.error("'GPIO busy'")))
|
||||
|
||||
with pytest.raises(epdconfig.DisplayUnavailable) as excinfo:
|
||||
epdconfig.module_init()
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "GPIO busy" in message
|
||||
# Und die Meldung muss dem Bediener sagen, was hier praktisch immer los ist, statt
|
||||
# ihn den lgpio-Fehler googeln zu lassen.
|
||||
assert "ninlight-display" in message, f"kein Hinweis auf den Anzeigedienst: {message}"
|
||||
|
||||
|
||||
def test_gpio_busy_hint_stays_away_from_unrelated_errors(monkeypatch):
|
||||
"""Der Hinweis auf den Anzeigedienst gilt nur fuer belegte Leitungen - bei einem
|
||||
anderen Fehler waere er eine Falschaussage, die den Bediener in die falsche Richtung
|
||||
schickt."""
|
||||
_stub_hardware(monkeypatch)
|
||||
monkeypatch.setattr(epdconfig, "lgpio", _FakeLgpioModule)
|
||||
monkeypatch.setattr(epdconfig, "gpiozero",
|
||||
_gpiozero_raising(OSError("kein solches Geraet")))
|
||||
|
||||
with pytest.raises(epdconfig.DisplayUnavailable) as excinfo:
|
||||
epdconfig.module_init()
|
||||
|
||||
assert "ninlight-display" not in str(excinfo.value)
|
||||
|
||||
|
||||
def test_programming_errors_are_still_not_disguised_as_missing_hardware(monkeypatch):
|
||||
"""Die Ausnahmeliste bleibt gezielt (Fixrunde 1, C2): ein TypeError ist ein
|
||||
Programmierfehler und darf nicht als "Panel fehlt" durchgehen - sonst verschwindet er
|
||||
hinter einer INFO-Zeile und sys.exit(0)."""
|
||||
_stub_hardware(monkeypatch)
|
||||
monkeypatch.setattr(epdconfig, "lgpio", _FakeLgpioModule)
|
||||
monkeypatch.setattr(epdconfig, "gpiozero",
|
||||
_gpiozero_raising(TypeError("falsches Argument")))
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
epdconfig.module_init()
|
||||
|
||||
|
||||
def test_v3_partial_draw_after_sleep_does_not_crash(monkeypatch):
|
||||
"""Regressionstest zu Fixrunde 2, N1 - genau der vom Reviewer nachgestellte Ablauf:
|
||||
Panel("V3") -> clear -> draw_full -> sleep -> draw_partial. Vorher stuerzte das mit
|
||||
|
||||
Reference in New Issue
Block a user