Files
ninlight-w600/tools/station/tests/test_render.py
Michael Sauer 71a579c0f9 fix(display): Abschlussbild sagt 'Strom trennen erlaubt' - der alte Satz war abgeschnitten
Zweiter Fund derselben Art wie die Bildsatz-Fusszeile: 'Strom kann getrennt werden'
misst in der Hero-Schrift 262,1 px bei 246 px Platz und kam am Geraet als
'Strom kann getrennt wer...' an.

Der Test deckt jetzt ALLE festen Beschriftungen ab (Abschlussbilder, beide Bildsaetze,
beide Fehlt-Warnungen, Freigabe), nicht nur die zuerst entdeckte Stelle - sonst waere
genau dieser zweite Fall wieder durchgerutscht. Der Wortlauttest prueft die Zusage
('trennen erlaubt' gegen 'NICHT trennen') statt des genauen Satzes.
2026-09-15 10:41:42 +02:00

2145 lines
98 KiB
Python

import json
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display"))
import display # noqa: E402
import epdconfig # noqa: E402
import render # noqa: E402
def test_off_mode_says_so_and_shows_nothing_else():
lines = render.lines({"mode": "off"})
assert any("Station aus" in l for l in lines)
assert not any("SSID" in l for l in lines)
def test_migration_shows_detected_lamp():
lines = render.lines({
"mode": "migration", "phase": "auto", "served": 3,
"ssid": "qconnex-firmware",
"last_seen": {"product_name": "Klyqa GU10 RGB CW/WW", "target": "gu10_rgb",
"variant": "prod", "lamp_version": "V1.1.0.1138"},
"recent": [{"target": "gu10_rgb", "status": "ok", "ts": "2026-09-13T13:04:02Z"}]})
text = "\n".join(lines)
assert "MIGRATION" in text
assert "Klyqa GU10 RGB CW/WW" in text
assert "V1.1.0.1138" in text
assert "prod" in text
assert "13:04" in text
def test_migration_shows_where_the_dev_classification_came_from():
"""Der Hardwarebefund vom 2026-09-15: die Station lieferte ein falsch signiertes Image
aus, ohne dass am Panel etwas darauf hindeutete. Steht die Quelle der Einstufung
daneben, sieht der Bediener VOR der Freigabe, worauf sie beruht."""
def variant_line(last_seen):
state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [],
"ssid": "qconnex-firmware", "last_seen": last_seen}
return [l for l in render.lines(state) if l.startswith("g95_cw (")][0]
base = {"product_name": "Klyqa G95 Vintage CW/WW", "target": "g95_cw",
"lamp_version": "V1.1.1.2357-dev"}
assert variant_line(dict(base, variant="dev", variant_source="version")) == "g95_cw (dev/Ver)"
assert variant_line(dict(base, variant="dev", variant_source="product_id")) == "g95_cw (dev/ID)"
assert variant_line(dict(base, variant="dev", variant_source="both")) == "g95_cw (dev/ID+Ver)"
# prod hat keine Quelle - da gibt es nirgends ein "-dev", das man anzeigen koennte.
assert variant_line(dict(base, variant="prod", variant_source="none")) == "g95_cw (prod)"
# Eine aeltere Serverfassung ohne das Feld bleibt darstellbar.
assert variant_line(dict(base, variant="dev")) == "g95_cw (dev)"
def test_recovery_mode_without_traffic():
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery",
"served": 0, "recent": []})
text = "\n".join(lines)
assert "RETTUNG" in text
assert "ninlight-recovery" in text
def test_long_product_name_is_not_truncated_by_lines():
"""lines() kuerzt bewusst NICHT (mehr) - Breitenkuerzung braucht eine echte
Textmessung (schriftabhaengig, siehe render.py-Modul-Docstring/Fixrunde 2) und
passiert deshalb erst in compose(). lines() bleibt so ohne Pillow testbar und liefert
hier den vollen, ungekuerzten Produktnamen zurueck."""
lines = render.lines({"mode": "migration", "phase": "listen", "served": 0, "recent": [],
"ssid": "qconnex-firmware",
"last_seen": {"product_name": "X" * 90, "target": "e27_rgb",
"variant": "dev", "lamp_version": "V1"}})
assert "X" * 90 in lines
def test_truncate_to_width_shortens_and_appends_ellipsis():
"""_truncate_to_width() selbst importiert kein Pillow - eine einfache Zeichenzahl-
Messfunktion reicht, um die Kuerzungslogik ohne Hardware/Pillow zu pruefen."""
result = render._truncate_to_width("X" * 50, measure=len, max_width_px=10)
assert len(result) <= 10
assert result.endswith("…")
def test_truncate_to_width_leaves_short_text_untouched():
assert render._truncate_to_width("kurz", measure=len, max_width_px=10) == "kurz"
def test_compose_does_not_draw_into_the_last_column_for_long_lines():
"""Regressionstest zu Fixrunde 3: der Reviewer hat per Mutation belegt, dass eine
deaktivierte Kuerzung in compose() (fitted = line) die vorherige Testsuite NICHT
aufgehalten haette, weil der alte Test die Kuerzung selbst nachbaute (mit derselben
_truncate_to_width()-Funktion, die auch geprueft werden sollte) statt das von
compose() tatsaechlich GEZEICHNETE BILD zu inspizieren.
Dieser Test prueft stattdessen die Pixel direkt: bei korrekt wirkender Kuerzung
bleibt die letzte Panel-Spalte (x = PANEL_WIDTH_PX - 1) fuer eine absichtlich sehr
lange Zeile weiss, weil die Kuerzung auf PANEL_WIDTH_PX - 2*TEXT_MARGIN_PX zielt
(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).
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",
"variant": "dev", "lamp_version": "V1"}}
image = render.compose(state)
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.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})"
HEADER_MIN_BLACK_SHARE = 0.55
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.
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
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 * 1.2, (
f"grosse Zeile ({hero_height} px) hebt sich nicht vom Beiwerk "
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():
"""Der vollste Zustand (Kopfbalken mit Modus+Phase, grosse Zeile, fuenf
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. Die Phase steckt seit der
zweiten Hardware-Abnahme im Kopfbalken (ROLE_HEADER_PHASE) statt als eigene
Beiwerkzeile - deshalb acht Bloecke insgesamt, aber nur noch fuenf Beiwerkzeilen
vor der letzten."""
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 + 4 * 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_bottom_margin_stays_free_of_ink_even_in_the_fullest_state():
"""Zweite Hardware-Abnahme, Befund 2: der Rahmen des echten Panels frisst den
unteren Rand, der volleste Zustand wirkte dort gedraengt. Geprueft an echten
gezeichneten Pixeln (nicht an nachgerechneten Koordinaten): die letzten
BOTTOM_MARGIN_PX Zeilen duerfen im volleren Zustand keine einzige schwarze Tinte
enthalten, sonst waere der Abstand nur auf dem Papier vorhanden."""
image = render.compose(_migration_state())
pixels = image.load()
margin_top = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
ink_in_margin = any(
pixels[x, y] == 0
for y in range(margin_top, render.PANEL_HEIGHT_PX)
for x in range(render.PANEL_WIDTH_PX))
assert not ink_in_margin, (
f"Text reicht bis in die letzten {render.BOTTOM_MARGIN_PX}px - kein sichtbarer "
"Rand mehr")
def test_compose_stops_drawing_details_before_the_bottom_margin(monkeypatch):
"""Direkter Test der Abbruchbedingung in compose(): der heutige volleste Zustand
(fuenf Beiwerkzeilen) erreicht die 122 px ohnehin nicht mehr (s.o.) - eine einzelne
Zeile weniger oder mehr an der Faltkante der Abbruchbedingung ist dort nur 1-2px
Tinte, kein verlässlicher Ausschlag. Dieser Test ersetzt blocks() deshalb durch eine
synthetische Liste mit zehn Beiwerkzeilen - deutlich mehr, als aufs Panel passen -
und ZAEHLT die tatsaechlich gezeichneten Zeilen (als getrennte Tinten-Cluster
unterhalb des Kopfbalkens), statt nur zu prüfen, ob irgendein Pixel in den letzten
Zeilen liegt: mit der Abbruchbedingung "> PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX" werden
hier sechs Zeilen gezeichnet, ohne den reservierten Rand ("> PANEL_HEIGHT_PX" allein,
die Mutation) waeren es sieben - am Geraet nachgemessen (siehe Mutationsprobe im
Abnahmebericht)."""
synthetic = [("MIGRATION", render.ROLE_HEADER)] + [
(f"Zeile {i}", render.ROLE_DETAIL) for i in range(10)]
monkeypatch.setattr(render, "blocks", lambda state: synthetic)
image = render.compose({"mode": "migration"})
pixels = image.load()
# 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 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 - "
"BOTTOM_MARGIN_PX wird in der Abbruchbedingung nicht mehr beruecksichtigt")
margin_top = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
ink_in_margin = any(
pixels[x, y] == 0
for y in range(margin_top, render.PANEL_HEIGHT_PX)
for x in range(render.PANEL_WIDTH_PX))
assert not ink_in_margin, (
"eine ueberlaufende Beiwerkzeile wurde trotzdem in den reservierten unteren "
"Rand gezeichnet")
# --- Kopfzeile: Phase neben dem Modus (zweite Hardware-Abnahme, Befund 1) -----------
#
# Frueher stand die Phase als eigene kleine Beiwerkzeile unter dem Lampennamen und ging
# dort unter. Jetzt steht sie rechtsbuendig im selben schwarzen Balken wie der Modus,
# der dafuer nach links ruecken darf. Rettung/aus kennen keine Phase und behalten die
# alte, zentrierte Kopfzeile.
def _header_white_columns(image):
"""Spalten (x) im Kopfbalken, die irgendwo eine weisse (Text-)Zelle enthalten."""
pixels = image.load()
return [x for x in range(render.PANEL_WIDTH_PX)
if any(pixels[x, y] == 255 for y in range(render.HEADER_BAR_HEIGHT_PX))]
def test_phase_appears_right_aligned_next_to_the_mode_in_the_header():
"""Grundfall: Modus links, Phase rechts, beide im selben Balken sichtbar - nicht mehr
als eigene Zeile darunter."""
state = _migration_state()
state["phase"] = "auto"
del state["armed_target"]
lines_out = render.lines(state)
assert lines_out[0] == "MIGRATION"
assert lines_out[1] == "AUTO"
image = render.compose(state)
white_cols = _header_white_columns(image)
assert white_cols, "Kopfzeile zeigt keinen Text"
# Modus (links) und Phase (rechts): die Schrift muss deutlich in der linken UND in
# der rechten Panelhaelfte stehen - stuende alles zentriert (die alte Darstellung),
# waere die rechte Haelfte praktisch leer.
left_half = [x for x in white_cols if x < render.PANEL_WIDTH_PX // 2]
right_half = [x for x in white_cols if x >= render.PANEL_WIDTH_PX // 2]
assert left_half, "kein Modus-Text in der linken Kopfzeilenhaelfte"
assert right_half, "keine Phase in der rechten Kopfzeilenhaelfte"
# Und die Phase muss tatsaechlich an den rechten Rand reichen (rechtsbuendig).
assert max(white_cols) >= render.PANEL_WIDTH_PX - render.TEXT_MARGIN_PX - 10
def test_recovery_header_stays_centered_without_a_phase():
"""Rettung kennt keine Phase - die Kopfzeile bleibt wie vor der zweiten
Hardware-Abnahme: der Modus steht waagerecht mittig, nicht links."""
image = render.compose({"mode": "recovery", "ssid": "ninlight-recovery",
"served": 0, "recent": []})
white_cols = _header_white_columns(image)
assert white_cols
left_gap = white_cols[0]
right_gap = render.PANEL_WIDTH_PX - 1 - white_cols[-1]
assert abs(left_gap - right_gap) <= 3, (
f"Modus nicht mehr mittig (links {left_gap}, rechts {right_gap}) - "
"Rettung darf die Kopfzeile nicht umbauen")
def test_long_armed_target_does_not_overlap_the_mode_in_the_header():
"""Mutationswache fuer Befund 1: eine sehr lange Phasenbezeichnung ("FREIGEGEBEN:
<langer Zielname>") darf den Modus links nicht ueberdecken. Geprueft an echten
Pixeln, nicht an Koordinaten: zwischen dem Ende des Modus-Texts und dem Anfang der
Phase muss eine durchgehend schwarze Luecke bleiben, die deutlich breiter ist als
eine normale Buchstabenluecke innerhalb eines Worts (die bei einer echten
Ueberlappung/einem fehlenden Kuerzungs-Clamp verschwinden wuerde, weil die Phase
dann weit nach links unter/ueber den Modus gezeichnet wird)."""
state = _migration_state()
state["phase"] = "armed"
state["armed_target"] = "x" * 80
image = render.compose(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
def is_black_column(x):
return all(pixels[x, y] != 255 for y in header_rows)
first, last = white_cols[0], white_cols[-1]
best_gap = 0
run = 0
for x in range(first, last + 1):
if is_black_column(x):
run += 1
best_gap = max(best_gap, run)
else:
run = 0
assert best_gap >= render.HEADER_PHASE_GAP_PX - 1, (
f"kein durchgehender Zwischenraum von mindestens "
f"{render.HEADER_PHASE_GAP_PX - 1}px zwischen Modus und Phase gefunden "
f"(groesste Luecke: {best_gap}px) - Modus und Phase ueberlappen sich"
)
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()
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": {}}):
assert render.lines(state)
# --- "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"})
text = "\n".join(lines)
assert render.ARMED_LABEL in text
assert "g95_cw" in text
# --- Zeitschalter am Panel (Task 7e) ------------------------------------------------
#
# Ein Zeitschalter, den man am Panel nicht sieht, ist schlimmer als keiner: man glaubt,
# die Station laeuft, und sie ist laengst aus. Umgekehrt darf ohne Zeitschalter (der
# Regelfall - er entsteht nur mit "--for") keine Zeile dafuer erscheinen.
def test_autooff_line_appears_only_with_a_running_timer():
without = render.lines(_migration_state())
assert not any("Auto-Aus" in line for line in without), without
state = dict(_migration_state(), autooff_minutes=7)
with_timer = render.lines(state)
assert "Auto-Aus in 7 min" in with_timer, with_timer
def test_autooff_line_also_appears_in_recovery_mode():
"""Der Zeitschalter haengt nicht am Migrationsmodus - im Rettungsmodus reisst "on"
die SSH-Sitzung genauso ab."""
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0,
"recent": [], "autooff_minutes": 42})
assert "Auto-Aus in 42 min" in lines, lines
def test_autooff_line_is_actually_drawn_on_the_panel():
"""Nicht nur in lines(): die Zeile muss im vollsten Zustand auch tatsaechlich aufs
Panel kommen und nicht unten herausfallen (compose() bricht Beiwerkzeilen vor dem
unteren Rand ab). Geprueft per Pixelvergleich gegen eine unabhaengig gezeichnete
Referenzzeile - schriftunabhaengig und ohne behauptete Pixelbreiten."""
state = dict(_armed_state(), autooff_minutes=7)
expected = "Auto-Aus in 7 min"
# Die wievielte BEIWERKZEILE ist es? (Kopfbalken, Phase im Kopf und grosse Zeile
# sind eigene Rollen und belegen keine Beiwerkzeile.)
details = [text for text, role in render.blocks(state) if role == render.ROLE_DETAIL]
assert expected in details, details
index = details.index(expected)
top, bottom = _detail_band(index)
drawn = _band_pixels(render.compose(state), top, bottom)
reference = _band_pixels(_reference_detail_line(expected, top), top, bottom)
assert drawn == reference, (
"die Zeitschalter-Zeile steht nicht (ungekuerzt) auf dem Panel - der Bediener "
"sieht nicht, dass sich die Station gleich abschaltet")
def test_autooff_line_says_so_when_the_deadline_has_arrived():
lines = render.lines({"mode": "recovery", "ssid": "x", "served": 0, "recent": [],
"autooff_minutes": 0})
assert "Auto-Aus steht an" in lines, lines
def test_build_state_reads_the_autooff_file(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery")
conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n")
# Noch 6 min 1 s - aufgerundet 7, wie es "ninlight-station status" auch rechnet.
(tmp_path / "autooff.json").write_text(json.dumps(
{"minutes": 30, "deadline": time.time() + 361, "deferrals": 0}))
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
assert state["autooff_minutes"] == 7
assert "Auto-Aus in 7 min" in render.lines(state)
def test_build_state_without_autooff_file_has_no_timer_field(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery")
conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n")
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
assert "autooff_minutes" not in state
assert not any("Auto-Aus" in line for line in render.lines(state))
def test_broken_autooff_file_is_treated_as_no_timer(tmp_path):
"""Dieselbe Zusage wie fuer status.json/control.json: halb geschrieben, kaputt oder
aus einer aelteren Fassung darf keine Ausnahme werfen - und vor allem keine
erfundene Restzeit zeigen."""
(tmp_path / "autooff.json").write_text("{kein json")
assert display._read_autooff_minutes(tmp_path) is None
(tmp_path / "autooff.json").write_text(json.dumps({"minutes": 30}))
assert display._read_autooff_minutes(tmp_path) is None
(tmp_path / "autooff.json").write_text(json.dumps({"deadline": "gleich"}))
assert display._read_autooff_minutes(tmp_path) is None
def test_autooff_remaining_time_rounds_up(tmp_path):
(tmp_path / "autooff.json").write_text(json.dumps({"deadline": 1000.0}))
assert display._read_autooff_minutes(tmp_path, now=1000.0 - 600) == 10
assert display._read_autooff_minutes(tmp_path, now=1000.0 - 601) == 11
assert display._read_autooff_minutes(tmp_path, now=1000.0 - 1) == 1
assert display._read_autooff_minutes(tmp_path, now=1000.0) == 0
assert display._read_autooff_minutes(tmp_path, now=1000.0 + 90) == 0
def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
""""off" raeumt die Zustandsdatei ab - aber selbst wenn sie durch einen Absturz
liegen bliebe, darf neben "Station aus" keine Restzeit stehen."""
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
(tmp_path / "autooff.json").write_text(json.dumps(
{"minutes": 30, "deadline": time.time() + 600, "deferrals": 0}))
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x",
imageset_path=tmp_path / "imageset")
assert state["mode"] == "off"
assert "autooff_minutes" not in state
assert not any("Auto-Aus" in line for line in render.lines(state))
# --- display.build_state() (Fixrunde 1, I3) ---------------------------------------
#
# Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal
# nachgebessert werden musste: Phase/armed_target kommen aus control.json und sind nur im
# Migrationsmodus gueltig; ein control.json-Rest aus einer frueheren Migration darf im
# Rettungsmodus nicht als "FREIGEGEBEN" durchschlagen.
def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery")
(tmp_path / "control.json").write_text(json.dumps({"phase": "armed", "armed_target": "g95_cw"}))
conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n")
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
assert state["mode"] == "recovery"
assert "phase" not in state
assert "armed_target" not in state
assert "FREIGEGEBEN" not in "\n".join(render.lines(state))
def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
# Selbst ein noch so frisches/gueltiges status.json darf "off" nicht verhindern -
# "ninlight-station off" laesst status.json bewusst unangetastet liegen.
(tmp_path / "mode").write_text("migration")
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5}))
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x",
imageset_path=tmp_path / "imageset")
# "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im
# ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll).
assert state["mode"] == "off"
assert set(state) == {"mode", "imageset", "imageset_ok"}
def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch):
"""Der Modus-Vorrang liegt bei RUN_DIR/mode, nicht bei status.json's "mode"-Feld -
status.json kann veraltet sein (Fixrunde 1, Kleinigkeit: der Vorrang war zuerst
umgekehrt)."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery")
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 1}))
conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n")
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
assert state["mode"] == "recovery"
assert state["served"] == 1
def test_build_state_falls_back_to_recovery_without_mode_file(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x")
assert state["mode"] == "recovery"
def test_read_conf_accepts_export_prefix(tmp_path):
"""setup_pi.sh prueft beim Nachtragen fehlender Schluessel ausdruecklich auf ein
optionales "export "-Praefix (^(export[[:space:]]+)?KEY=) - eine bereits vorhandene
Konfigurationsdatei mit "export SSID=..." muss also genauso gelesen werden koennen."""
conf_path = tmp_path / "station.conf"
conf_path.write_text("export SSID='ninlight-recovery'\nexport MIGRATE_SSID='qconnex-firmware'\n")
values = display._read_conf(conf_path)
assert values["SSID"] == "ninlight-recovery"
assert values["MIGRATE_SSID"] == "qconnex-firmware"
# --- Panel-Stubs fuer die V2/V3-Aufwach-/Schlafsequenz (Fixrunde 2, N1) --------------
#
# gpiozero/spidev sind auf der Entwicklungsmaschine nicht installiert (und selbst wenn
# sie es waeren, haengt hier kein echtes Panel) - diese Fakes implementieren nur genau
# die Teilmenge der Schnittstelle, die epdconfig.py und die vendored Panel-Treiber
# tatsaechlich aufrufen, damit Panel() end-to-end (ohne echte Hardware) durchlaufen kann.
class _FakeDigitalOutputDevice:
def __init__(self, pin):
self.pin = pin
self.value = False
def close(self):
pass
class _FakeDigitalInputDevice:
def __init__(self, pin, pull_up=False):
self.pin = pin
self.value = 0 # "idle", nie "busy" - sonst haengt ReadBusy() in einer Schleife
def close(self):
pass
class _FakeGpiozeroModule:
DigitalOutputDevice = _FakeDigitalOutputDevice
DigitalInputDevice = _FakeDigitalInputDevice
class GPIOZeroError(Exception):
pass
class _FakeSpiDev:
def __init__(self):
self.max_speed_hz = None
self.mode = None
def open(self, bus, device):
pass
def writebytes(self, data):
pass
def writebytes2(self, data):
pass
def close(self):
pass
class _FakeSpidevModule:
SpiDev = _FakeSpiDev
def _stub_hardware(monkeypatch):
"""Ersetzt epdconfig's Sicht auf gpiozero/spidev durch die Fakes oben, taeuscht ein
vorhandenes /dev/spidev0.0 vor, macht delay_ms() ein No-op (epd.sleep() wartet sonst
echte 2 Sekunden) und stellt sicher, dass epdconfig mit "nichts offen" in den Test
geht - epdconfig haelt seinen Zustand in Modulglobalen, die sonst zwischen Tests
durchsickern koennten."""
monkeypatch.setattr(epdconfig, "gpiozero", _FakeGpiozeroModule)
monkeypatch.setattr(epdconfig, "spidev", _FakeSpidevModule)
monkeypatch.setattr(epdconfig.os.path, "exists", lambda path: True)
monkeypatch.setattr(epdconfig, "delay_ms", lambda ms: None)
monkeypatch.setattr(epdconfig, "_spi", None)
monkeypatch.setattr(epdconfig, "_rst_pin", None)
monkeypatch.setattr(epdconfig, "_dc_pin", None)
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
AttributeError ab: epd.sleep() schliesst ueber epdconfig.module_exit() die
GPIO-Pins, und epd2in13_V3.displayPartial() fasst als Erstes die Reset-Leitung an -
von main()s except (DisplayUnavailable, ImportError) NICHT gefangen, also ein
echter Absturz (Exit 1, Neustartschleife). V3 ist die Vorgaberevision, und der
Vollaufbau-nach-jedem-Bild-Ansatz aus Fixrunde 1 erreichte diesen Pfad bei der
ersten Inhaltsaenderung ohne Moduswechsel - binnen Sekunden nach dem Start.
Mit dem Panel._awake-Tracking wertet draw_partial() sich nach sleep() selbst zu
einem Vollaufbau auf, statt die geschlossenen Pins anzufassen - dieser Test waere
mit der Fixrunde-1-Fassung von Panel fehlgeschlagen und besteht jetzt."""
_stub_hardware(monkeypatch)
panel = display.Panel("V3")
image = render.compose({"mode": "off"})
panel.clear()
panel.draw_full(image)
panel.sleep()
panel.draw_partial(image) # das ist der Schritt, der vorher abgestuerzt waere
def test_v2_partial_draw_after_sleep_does_not_crash(monkeypatch):
"""Dieselbe Rauchprobe fuer V2 wie oben fuer V3 (Fixrunde 2, N3) - dass hier NICHTS
crasht, ist aber nur die halbe Absicherung: ob dabei auch die RICHTIGEN Register
gesetzt werden, prueft erst der staerkere Test unten
(test_v2_wake_after_sleep_sets_full_update_registers) mit einem Kommando-Rekorder.
Der Reviewer hat zu Recht angemerkt, dass eine Fassung dieses Tests ohne jede
Zusicherung nichts ueber die Registerlage aussagt - deshalb hier wenigstens die
beobachtbaren Panel-Zustandsuebergaenge pruefen."""
_stub_hardware(monkeypatch)
panel = display.Panel("V2")
image = render.compose({"mode": "off"})
panel.clear()
panel.draw_full(image)
panel.sleep()
assert panel._awake is False # Vorbedingung fuer den eigentlich interessanten Schritt
panel.draw_partial(image) # das ist der Schritt, der bei falscher Sequenz froeren wuerde
assert panel._awake is True
assert epdconfig._spi is not None # Panel hat sich nach dem Schlafen neu initialisiert
def test_panel_sleep_is_idempotent(monkeypatch):
"""Ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft, darf nicht
versuchen, ueber die von module_exit() bereits geschlossenen Pins SPI-Befehle zu
schicken (derselbe AttributeError-Mechanismus wie N1, nur selbst herbeigefuehrt).
Die staerkere Fassung unten (test_panel_sleep_sends_no_commands_when_already_asleep)
prueft das zusaetzlich direkt an der gesendeten Kommandofolge."""
_stub_hardware(monkeypatch)
panel = display.Panel("V3")
panel.clear()
panel.sleep()
assert panel._awake is False
panel.sleep() # darf nicht erneut versuchen, epd.sleep() aufzurufen
assert panel._awake is False
# --- Kommando-Rekorder: Registerzustand nach echten Bytes pruefen, nicht nur "kein Absturz"
#
# Der Reviewer hat per Mutation gezeigt, dass die Stubs oben (_stub_hardware) N2/N3 nicht
# wirklich absichern: displayPartBaseImage(buf) durch display(buf) ersetzen, oder die
# V2-Registerfolge nach sleep() kaputt machen, liess alle bisherigen Tests gruen. Ein
# stiller Registerzustandsfehler wirft keine Ausnahme - die einzige Art, ihn in Software
# statt erst am Panel selbst zu fassen, ist die tatsaechlich gesendete Byte-Folge zu
# protokollieren. Ersetzt dafuer epdconfig's Funktionen direkt (nicht gpiozero/spidev
# darunter) und wertet aus, welche Bytes waehrend DC=0 (Kommando) vs. DC=1 (Daten)
# gesendet wurden - siehe send_command()/send_data() in den vendored Treibern.
class _RecordingHardware:
def __init__(self):
self.commands: list[int] = []
self._dc = 0
self._busy = 0
def digital_write(self, pin, value):
if pin == epdconfig.DC_PIN:
self._dc = value
# RST_PIN/CS_PIN sind fuer die Kommandofolge nicht relevant.
def digital_read(self, pin):
return self._busy
def delay_ms(self, ms):
pass
def spi_writebyte(self, data):
if self._dc == 0:
self.commands.extend(data)
def spi_writebyte2(self, data):
pass # Nutzdaten (Bildpuffer/LUT) - fuer die Kommandofolge nicht relevant.
def module_init(self):
return 0
def module_exit(self):
pass
def _install_recorder(monkeypatch) -> _RecordingHardware:
recorder = _RecordingHardware()
monkeypatch.setattr(epdconfig, "digital_write", recorder.digital_write)
monkeypatch.setattr(epdconfig, "digital_read", recorder.digital_read)
monkeypatch.setattr(epdconfig, "delay_ms", recorder.delay_ms)
monkeypatch.setattr(epdconfig, "spi_writebyte", recorder.spi_writebyte)
monkeypatch.setattr(epdconfig, "spi_writebyte2", recorder.spi_writebyte2)
monkeypatch.setattr(epdconfig, "module_init", recorder.module_init)
monkeypatch.setattr(epdconfig, "module_exit", recorder.module_exit)
return recorder
def test_v3_full_refresh_writes_reference_buffer(monkeypatch):
"""Regressionstest zu Fixrunde 2, N2 - mit einer echten Zusicherung (Fixrunde 3): ein
Vollaufbau muss bei V3 sowohl RAM 0x24 als auch 0x26 beschreiben
(displayPartBaseImage() statt display()), sonst arbeitet der naechste Teilaufbau
gegen einen undefinierten Referenzpuffer. Der Reviewer hat per Mutation
(displayPartBaseImage(buf) -> display(buf)) gezeigt, dass die vorherige Testsuite das
nicht bemerkt haette - dieser Test waere mit dieser Mutation rot."""
recorder = _install_recorder(monkeypatch)
panel = display.Panel("V3")
image = render.compose({"mode": "off"})
panel.clear()
recorder.commands.clear() # nur der eigentliche Vollaufbau interessiert hier
panel.draw_full(image)
assert 0x24 in recorder.commands, f"0x24 (aktuelles RAM) fehlt: {recorder.commands}"
assert 0x26 in recorder.commands, f"0x26 (Referenzpuffer) fehlt: {recorder.commands}"
assert recorder.commands.index(0x26) > recorder.commands.index(0x24)
def test_v2_wake_after_sleep_sets_full_update_registers(monkeypatch):
"""Regressionstest zu Fixrunde 2, N3 - mit einer echten Zusicherung (Fixrunde 3,
statt der vorherigen Fassung ganz ohne): nach sleep() muss der naechste Aufbau
(draw_partial(), das sich wegen Panel._awake selbst zu einem Vollaufbau aufwertet)
die Register senden, die NUR der FULL_UPDATE-Zweig von epd2in13_V2.init() setzt
(0x01 Driver output control, 0x11 Data entry mode, 0x44/0x45 RAM-Fenster, 0x4E/0x4F
RAM-Zaehler), und danach sowohl 0x24 als auch 0x26 beschreiben. Dieser Fehler wirft
keine Ausnahme - er ist nur an der gesendeten Byte-Folge erkennbar, sonst erst am
Panel selbst, wo er von einem Verdrahtungsfehler nicht zu unterscheiden waere."""
recorder = _install_recorder(monkeypatch)
panel = display.Panel("V2")
image = render.compose({"mode": "off"})
panel.clear()
panel.draw_full(image)
panel.sleep()
recorder.commands.clear() # nur die Kommandofolge NACH dem Schlafen interessiert hier
panel.draw_partial(image)
required = [0x01, 0x11, 0x44, 0x45, 0x4E, 0x4F]
positions = [recorder.commands.index(cmd) for cmd in required
if cmd in recorder.commands]
assert len(positions) == len(required), (
f"FULL_UPDATE-Register fehlen nach sleep(): {recorder.commands}")
assert positions == sorted(positions), (
f"FULL_UPDATE-Register nicht in der erwarteten Reihenfolge: {recorder.commands}")
assert 0x24 in recorder.commands and 0x26 in recorder.commands, (
f"0x24/0x26 (Referenzpuffer) fehlen: {recorder.commands}")
idx_24 = recorder.commands.index(0x24)
idx_26 = recorder.commands.index(0x26)
assert idx_24 > positions[-1], "0x24 vor den FULL_UPDATE-Registern geschrieben"
assert idx_26 > idx_24, "0x26 (Referenzpuffer) fehlt oder kommt vor 0x24"
def test_panel_clear_establishes_reference_buffer(monkeypatch):
"""Regressionstest zu Fixrunde 3, F2: clear() setzte vorher self._awake=True, ohne
einen 0x26-Referenzpuffer zu schreiben (epd.Clear() schreibt nur 0x24, bei V2 ist das
0x26-Schreiben in Clear() sogar auskommentiert) - ein draw_partial() direkt nach
clear() liefe gegen denselben undefinierten Puffer, den N2 eigentlich beheben
sollte. clear() verwendet jetzt wie draw_full() displayPartBaseImage()."""
recorder = _install_recorder(monkeypatch)
panel = display.Panel("V3")
panel.clear()
assert 0x24 in recorder.commands
assert 0x26 in recorder.commands
assert panel._awake is True
def test_panel_sleep_sends_no_commands_when_already_asleep(monkeypatch):
"""Staerkere Fassung von test_panel_sleep_is_idempotent (Fixrunde 3): ein zweiter
sleep()-Aufruf darf NICHT nochmal versuchen, ueber die schon geschlossenen Pins
SPI-Befehle zu schicken - hier direkt an der aufgezeichneten Kommandofolge geprueft
statt nur daran, dass keine Ausnahme fliegt."""
recorder = _install_recorder(monkeypatch)
panel = display.Panel("V3")
panel.clear()
panel.sleep()
recorder.commands.clear()
panel.sleep() # bereits asleep - darf keine weiteren Kommandos senden
assert recorder.commands == []
def test_draw_partial_reports_whether_it_upgraded_itself(monkeypatch):
"""Fixrunde 3, F4 - vom Reviewer per Mutation als ungetestet belegt: gibt
draw_partial() nach dem Aufwerten `False` statt `True` zurueck, blieben alle 21 Tests
gruen. Der Rueckgabewert ist aber die einzige Information, an der RefreshPlanner
erkennt, dass die 30-Minuten-Uhr neu beginnt - ohne ihn kaeme der naechste
Ghosting-Ausgleich zu frueh und alterte das Panel ohne Not."""
_install_recorder(monkeypatch)
panel = display.Panel("V3")
image = render.compose({"mode": "off"})
panel.clear()
assert panel.draw_partial(image) is False, "waches Panel: kein Vollaufbau noetig"
panel.sleep()
assert panel.draw_partial(image) is True, (
"nach sleep() wertet draw_partial() intern zu einem Vollaufbau auf - das muss es "
"auch zurueckmelden")
assert panel.draw_partial(image) is False, "danach ist das Panel wieder wach"
def test_compose_survives_a_newline_inside_a_value():
"""Fixrunde 3, F5 - ebenfalls per Mutation als ungetestet belegt (`line = raw_line`
liess alle Tests gruen): draw.textlength() wirft ValueError("can't measure length of
multiline text"), sobald eine Zeile ein "\\n" enthaelt. product_name kommt aus der
Lampenerkennung, ist also letztlich Netzwerkdatum - und ein ValueError hier faengt
main()s `except (DisplayUnavailable, ImportError)` NICHT ab, das waere ein echter
Absturz des Dienstes."""
state = {"mode": "migration", "phase": "auto", "served": 0, "recent": [],
"last_seen": {"product_name": "Klyqa\nGU10", "target": "gu10_rgb",
"variant": "prod", "lamp_version": "V1"}}
image = render.compose(state) # vor F5: ValueError
assert image.size == (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX)
# --- run_daemon() gegen eine Fake-Uhr durchspielen (Fixrunde 4, verschaerft in 5) -----
#
# run_daemon() hatte null Testabdeckung - die gesamte Zeitsteuerung steckte in einer
# Endlosschleife. Sie liegt jetzt in display.RefreshPlanner, aber die ENTSCHEIDUNG allein
# zu pruefen reicht nicht: der Reviewer hat in Fixrunde 5 gezeigt, dass alle drei
# Mutationen an der VERDRAHTUNG zwischen Planer und Panel gruen blieben, solange der Test
# eine nachgebaute Schleife statt run_daemon() selbst trieb (was_full=upgraded verworfen,
# record_sleep() gestrichen, "full" auf draw_partial() umgebogen - letzteres haette
# bedeutet: nie wieder ein echter Vollaufbau auf wachem Panel).
#
# Deshalb wird hier das ECHTE run_daemon() getrieben - mit eingeschleuster Fake-Uhr und
# einem protokollierenden Panel-Doppel. Die frueher hier stehende Nachbildung
# (_run_schedule) ist ersatzlos entfallen, damit es nur eine Fassung der Schleife gibt.
SIX_HOURS_S = 6 * 60 * 60
class _StopDaemon(Exception):
"""Bricht run_daemon()s Endlosschleife ab, sobald die simulierte Zeit abgelaufen ist."""
class _FakeClock:
"""Ersetzt das time-Modul in run_daemon(): monotonic() liefert die simulierte Zeit,
sleep() laesst sie springen, statt tatsaechlich zu warten."""
def __init__(self, until_s: float) -> None:
self.t = 0.0
self.until_s = until_s
def monotonic(self) -> float:
return self.t
def sleep(self, seconds: float) -> None:
self.t += seconds
if self.t > self.until_s:
raise _StopDaemon
class _RecordingPanel:
"""Panel-Doppel: protokolliert jeden Aufruf mit Zeitstempel und bildet genau das
Verhalten des echten Panels nach, das die Zeitsteuerung beeinflusst - die
Selbst-Aufwertung von draw_partial() auf einem schlafenden Panel (dort als "wake-full"
protokolliert, weil ein solcher Aufbau eben NICHT nur teilweise auffrischt, siehe
test_draw_partial_reports_whether_it_upgraded_itself fuer den Nachweis am echten
Panel) und die Idempotenz von sleep() (ein Aufruf auf einem schon schlafenden Panel
wird als "sleep-noop" protokolliert: unschaedlich, aber ein Zeichen, dass run_daemon()
das Einschlafen nicht zurueckgemeldet hat)."""
def __init__(self, clock: _FakeClock) -> None:
self.clock = clock
self.calls: list[tuple[float, str]] = []
self.awake_s = 0.0
self._awake = False
self._awake_since = 0.0
def _record(self, name: str) -> None:
self.calls.append((self.clock.monotonic(), name))
def _wake(self) -> None:
if not self._awake:
self._awake = True
self._awake_since = self.clock.monotonic()
def clear(self) -> None:
self._record("clear")
self._wake()
def draw_full(self, image) -> None:
assert image is not None
self._record("full")
self._wake()
def draw_partial(self, image) -> bool:
assert image is not None
if not self._awake:
self._record("wake-full")
self._wake()
return True
self._record("partial")
return False
def sleep(self) -> None:
if not self._awake:
self._record("sleep-noop")
return
self._record("sleep")
self.awake_s += self.clock.monotonic() - self._awake_since
self._awake = False
def _finish(self, end_t: float) -> None:
if self._awake:
self.awake_s += end_t - self._awake_since
def _drive_daemon(state_at, duration_s: float, ui_at=None) -> _RecordingPanel:
"""Laesst das echte display.run_daemon() `duration_s` simulierte Sekunden laufen.
`state_at(t)` liefert den Zustand, den build_state() zum Zeitpunkt t liefern wuerde,
`ui_at(t)` den Bedienzustand, den read_ui() dort liefern wuerde (Vorgabe: keiner -
dann verhaelt sich die Schleife wie ohne Taster)."""
clock = _FakeClock(duration_s)
panel = _RecordingPanel(clock)
if ui_at is None:
def ui_at(t):
return {}
try:
display.run_daemon("V3", clock=clock, panel=panel,
read_state=lambda: state_at(clock.monotonic()),
read_ui=lambda: ui_at(clock.monotonic()))
except _StopDaemon:
pass
panel._finish(clock.monotonic())
return panel
def _times(panel: _RecordingPanel, call: str) -> list[float]:
return [t for t, name in panel.calls if name == call]
def _changing_every(period_s, mode="recovery"):
"""Zustand, der sich alle `period_s` Sekunden aendert (und sonst gleich bleibt)."""
return lambda t: {"mode": mode, "served": int(t // period_s), "recent": []}
def test_static_content_never_triggers_another_full_refresh():
"""Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die
Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle Tests gruen.
Genau diese Bedingung verhindert aber den 33-%-Bias-Zyklus: bei UNVERAENDERTEM Inhalt
gibt es keine Geisterbilder aus Teilaufbauten auszugleichen, ein Vollaufbau alle 30
Minuten waere reine Alterung - und weckt das Panel jedesmal fuer weitere 10 Minuten
auf."""
panel = _drive_daemon(lambda t: {"mode": "recovery", "served": 0, "recent": []},
SIX_HOURS_S)
assert _times(panel, "full") == [0.0], (
f"nur der Erstaufbau darf voll sein, tatsaechlich: {_times(panel, 'full')}")
assert _times(panel, "partial") == []
assert _times(panel, "sleep") == [600.0], "genau einmal einschlafen, dann Ruhe"
assert _times(panel, "sleep-noop") == [], (
"run_daemon() hat das Einschlafen nicht zurueckgemeldet - panel.sleep() wird "
"seitdem in jeder Runde erneut gerufen")
assert panel.awake_s <= 700, (
f"Panel war {panel.awake_s} s wach - erwartet nur die ersten 10 min")
def test_change_every_3_minutes_stays_awake_and_compensates_every_30_minutes():
panel = _drive_daemon(_changing_every(3 * 60), SIX_HOURS_S)
fulls = _times(panel, "full")
assert _times(panel, "sleep") == [], "alle 3 min Verkehr - das Panel darf nicht schlafen"
assert len(_times(panel, "partial")) > 100, "die Aenderungen muessen Teilaufbauten sein"
assert len(fulls) >= 10, f"Ghosting-Ausgleich alle 30 min fehlt: {fulls}"
gaps = [b - a for a, b in zip(fulls, fulls[1:])]
assert all(gap >= display.FULL_REFRESH_INTERVAL_S for gap in gaps), (
f"Vollaufbauten zu dicht: {fulls}")
def test_change_every_9_minutes_still_never_sleeps():
"""9 Minuten liegen knapp unter SLEEP_AFTER_IDLE_S (10 min) - das Panel bleibt wach,
die Teilaufbauten sammeln sich, und die 30-Minuten-Uhr gleicht sie aus."""
panel = _drive_daemon(_changing_every(9 * 60), SIX_HOURS_S)
fulls = _times(panel, "full")
assert _times(panel, "sleep") == []
assert _times(panel, "partial"), "Aenderungen bei wachem Panel sind Teilaufbauten"
assert len(fulls) >= 5, f"Ghosting-Ausgleich fehlt: {fulls}"
gaps = [b - a for a, b in zip(fulls, fulls[1:])]
assert all(gap >= display.FULL_REFRESH_INTERVAL_S for gap in gaps)
def test_change_every_11_minutes_sleeps_between_and_wakes_with_a_full_refresh():
"""11 Minuten liegen knapp ueber SLEEP_AFTER_IDLE_S: das Panel schlaeft zwischen zwei
Aenderungen ein, und der naechste Aufbau wird von Panel.draw_partial() selbst zu einem
Vollaufbau aufgewertet - es darf also gar kein Teilaufbau mehr entstehen, und die
30-Minuten-Uhr darf nie zusaetzlich feuern (jeder Aufbau IST schon ein Vollaufbau).
Genau hier faellt auch auf, wenn run_daemon() die Rueckmeldung der Aufwertung
(was_full=upgraded) verwirft: dann zaehlen die Aufwertungen als Teilaufbauten und die
30-Minuten-Uhr schiebt zusaetzliche Vollaufbauten ein (Fixrunde 5, N2)."""
panel = _drive_daemon(_changing_every(11 * 60), SIX_HOURS_S)
assert _times(panel, "partial") == [], (
"nach dem Schlafen wertet das Panel jeden Aufbau selbst zu einem Vollaufbau auf")
assert len(_times(panel, "wake-full")) == SIX_HOURS_S // (11 * 60), (
f"pro Aenderung ein Aufbau erwartet: {_times(panel, 'wake-full')}")
assert len(_times(panel, "sleep")) >= 15, "zwischen den Aenderungen muss geschlafen werden"
assert _times(panel, "sleep-noop") == []
# Nur der Erstaufbau ist ein regulaerer Vollaufbau: jeder weitere Aufbau IST wegen der
# Aufwertung schon vollstaendig, ein zusaetzlicher "stale"-Vollaufbau waere ein Zeichen,
# dass die Aufwertung nicht als Vollaufbau verbucht wurde (F4).
assert _times(panel, "full") == [0.0], (
f"unerwartete Vollaufbauten: {_times(panel, 'full')}")
def test_burst_then_quiet_still_gets_its_ghosting_compensation():
"""Fixrunde 4, N1 - der Dauerzustand, den jemand am laengsten ansieht: ein Buendel von
30 Teilaufbauten in 2,5 Minuten (Migration mehrerer Lampen), danach stundenlang Ruhe.
Die Fassung aus Fixrunde 3 setzte partials_since_full beim Einschlafen auf 0; das
Panel schlief 10 Minuten nach dem Buendel mit dem entstandenen Geisterbild ein, stale
konnte danach nie mehr feuern, und genau dieses Bild stand die restlichen rund 5 h 50
min unveraendert da. Der faellige Ausgleich muss stattdessen nachgeholt werden - und
das Panel danach wieder zur Ruhe kommen."""
def state_at(t):
# 30 Aenderungen im 5-Sekunden-Takt (t = 5 .. 150), danach unveraendert.
step = min(int(t // display.POLL_INTERVAL_S), 30)
return {"mode": "migration", "served": step, "recent": []}
panel = _drive_daemon(state_at, SIX_HOURS_S)
assert len(_times(panel, "partial")) == 30, (
f"das Buendel muss aus Teilaufbauten bestehen: {_times(panel, 'partial')}")
fulls = _times(panel, "full")
assert len(fulls) == 2, f"Erstaufbau + genau ein Ausgleich erwartet, war: {fulls}"
ausgleich = fulls[1]
assert display.FULL_REFRESH_INTERVAL_S <= ausgleich <= 35 * 60, (
f"Ghosting-Ausgleich faellt aus oder kommt zu spaet (t={ausgleich})")
# ... und danach wirklich Ruhe: einmal nach dem Buendel, einmal nach dem Ausgleich.
assert len(_times(panel, "sleep")) == 2, f"unruhiger Schlaf: {_times(panel, 'sleep')}"
assert panel.awake_s <= 30 * 60, (
f"Panel war {panel.awake_s} s von {SIX_HOURS_S} s wach - der Ausgleich darf nicht "
"in einen Wachzyklus ausarten")
def test_daemon_draws_fully_on_mode_change_and_partially_on_content_change():
"""Die Zuordnung Aktion -> Panel-Methode direkt an run_daemon() (Fixrunde 5, N2):
ruft der "full"-Zweig panel.draw_partial() statt panel.draw_full(), gaebe es auf einem
wachen Panel NIE wieder einen echten Vollaufbau - Moduswechsel wuerden nur teilweise
gezeichnet und der Ghosting-Ausgleich waere wirkungslos. Kurzer Verlauf, damit die
Zuordnung Zeitpunkt fuer Zeitpunkt nachvollziehbar ist."""
def state_at(t):
if t < 60:
return {"mode": "recovery", "served": 0, "recent": []}
if t < 120:
return {"mode": "recovery", "served": 1, "recent": []} # nur Inhalt
return {"mode": "migration", "served": 1, "recent": []} # Moduswechsel
panel = _drive_daemon(state_at, 180)
assert panel.calls == [
(0.0, "clear"),
(0.0, "full"), # Erstaufbau (Modus zum ersten Mal bekannt)
(60.0, "partial"), # nur der Inhalt hat sich geaendert
(120.0, "full"), # Moduswechsel - muss ein Vollaufbau sein
], panel.calls
def test_planner_counts_an_upgraded_partial_as_a_full_refresh():
"""Direkt an der Zustandsmaschine (F4): meldet der Aufrufer eine von
Panel.draw_partial() selbst aufgewertete Teilaktualisierung als Vollaufbau, beginnt die
30-Minuten-Uhr neu und es gibt nichts mehr auszugleichen."""
planner = display.RefreshPlanner(0.0)
planner.record_draw(0.0, ["a"], "recovery", was_full=True)
planner.record_draw(10.0, ["b"], "recovery", was_full=False)
assert planner.partials_since_full == 1
planner.record_draw(20.0, ["c"], "recovery", was_full=True) # aufgewerteter Teilaufbau
assert planner.partials_since_full == 0
assert planner.decide(20.0 + display.FULL_REFRESH_INTERVAL_S, ["c"], "recovery") != "full"
def test_planner_draws_fully_on_mode_change():
planner = display.RefreshPlanner(0.0)
planner.record_draw(0.0, ["RETTUNG"], "recovery", was_full=True)
assert planner.decide(5.0, ["MIGRATION"], "migration") == "full"
# --- Kopplung an den Tasterdienst ----------------------------------------------------
#
# Der Tasterdienst (tools/station/button/button.py) fasst den SPI-Bus NICHT an - zwei
# Prozesse auf denselben GPIO-/SPI-Leitungen ergeben "lgpio.error: 'GPIO busy'". Er
# schreibt stattdessen seinen Bedienzustand in eine Datei, die hier gelesen und gezeichnet
# wird. Geprueft wird deshalb beides: dass ein Bedienzustand das Statusbild ersetzt, und
# dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein.
_MENU_UI = {"view": "menu", "title": "MENUE", "hero": "> Station aus",
"details": ["Anzeige auffrischen", "Herunterfahren"], "active": True}
def test_the_ui_file_lives_outside_the_stations_runtime_directory():
"""Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim
regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station aus", in dem
das Menue "Rettung starten"/"Migration starten" anbietet."""
assert str(display.UI_FILE) == "/run/ninlight-button/ui.json"
assert not str(display.UI_FILE).startswith("/run/ninlight-station/")
def test_a_running_operation_replaces_the_status_screen():
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7,
"recent": [], "ui": _MENU_UI})
assert lines == ["MENUE", "> Station aus", "Anzeige auffrischen", "Herunterfahren"]
assert not any("ninlight-recovery" in l for l in lines)
def test_the_menu_uses_the_same_roles_as_the_status_screen():
"""Kopfbalken, grosse Zeile, Beiwerk - damit Schriftgroessen, Breitenkuerzung und
unterer Rand dieselben bleiben wie im Statusbild."""
roles = [role for _text, role in render.blocks({"mode": "recovery", "ui": _MENU_UI})]
assert roles[:2] == [render.ROLE_HEADER, render.ROLE_HERO]
assert set(roles[2:]) <= {render.ROLE_DETAIL}
def test_the_menu_is_actually_drawn_on_the_panel():
image = render.compose({"mode": "recovery", "ssid": "ninlight-recovery",
"ui": _MENU_UI})
reference = render.compose({"mode": "recovery", "ssid": "ninlight-recovery"})
assert list(image.getdata()) != list(reference.getdata())
assert _ink_box(image) is not None
def test_a_broken_ui_file_does_not_break_the_display():
"""Die Datei kommt aus einem fremden Prozess, der abgestuerzt, aelter oder neuer sein
kann - genau wie status.json darf sie die Anzeige nie zum Absturz bringen."""
lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17,
"hero": None, "details": "nope"}})
assert lines == ["Bedienung"]
render.compose({"mode": "recovery", "ui": {"view": "menu"}})
def test_read_ui_ignores_a_missing_broken_or_idle_file(tmp_path):
path = tmp_path / "ui.json"
assert display.read_ui(path) == {}
path.write_text("{kein json")
assert display.read_ui(path) == {}
path.write_text(json.dumps({"view": "idle", "active": False}))
assert display.read_ui(path) == {}
# Auch ein Menue, das sich selbst nicht mehr als laufend meldet, zaehlt nicht - so
# kann ein stehengebliebener Bedienzustand das Statusbild nicht dauerhaft verdecken.
path.write_text(json.dumps({"view": "menu", "active": False}))
assert display.read_ui(path) == {}
path.write_text(json.dumps(_MENU_UI))
assert display.read_ui(path)["hero"] == "> Station aus"
def test_the_daemon_shows_an_operation_within_half_a_second():
"""Solange eine Bedienung laeuft, muss die Anzeige schneller nachsehen als in ihrem
ueblichen 5-Sekunden-Takt - sonst ist das Menue unbenutzbar."""
panel = _drive_daemon(lambda t: {"mode": "recovery", "served": 0, "recent": []},
duration_s=20.0,
ui_at=lambda t: _MENU_UI if t >= 3.0 else {})
drawn = [t for t, name in panel.calls if name in ("partial", "full", "wake-full")
and t >= 3.0]
assert drawn, "das Menue wurde ueberhaupt nicht gezeichnet"
assert drawn[0] <= 3.5
def test_a_keypress_costs_the_panel_no_full_refresh():
"""Alterungsschutz: Vollaufbauten sind teuer, und das Panel soll nicht bei jedem
Tastendruck einen bekommen. Der Modus bleibt ueber eine Bedienung hinweg derselbe,
also kommt jeder Bedienschritt als Teilaufbau."""
views = [dict(_MENU_UI, hero="> Eintrag {}".format(i)) for i in range(4)]
def ui_at(t):
if t < 3.0:
return {}
return views[min(int((t - 3.0) // 2), len(views) - 1)]
panel = _drive_daemon(lambda t: {"mode": "recovery", "served": 0, "recent": []},
duration_s=12.0, ui_at=ui_at)
assert len(_times(panel, "partial")) >= 4
# Genau einer: der allererste Bildaufbau nach dem Start.
assert _times(panel, "full") == [0.0]
def test_the_expensive_station_state_is_still_only_read_every_five_seconds():
"""Der schnelle Takt gilt NUR fuer die kleine Bedienzustandsdatei. build_state()
forkt fuer "systemctl is-active" - das zehnmal so oft zu tun waere auf einem
ARMv6-Kern spuerbar und voellig unnoetig."""
calls = []
def state_at(t):
calls.append(t)
return {"mode": "recovery", "served": 0, "recent": []}
_drive_daemon(state_at, duration_s=10.0)
assert calls == [0.0, 5.0, 10.0]
# --- Bildsatz-Umschalter in der Fusszeile -------------------------------------------
#
# Zusage: welcher Migrations-Bildsatz gilt, steht DAUERHAFT auf dem Panel - nicht nur im
# Menue. Der Zustand ueberlebt Neustarts und kann von vorletzter Woche stammen; wer ihn
# erst beim Blaettern im Menue saehe, hat womoeglich schon zwanzig Lampen mit den eigenen
# WLAN-Zugangsdaten geflasht.
def _flat(rows):
"""_band_pixels() liefert Zeilen-Tupel - hier interessieren die einzelnen Pixel."""
return [px for row in rows for px in row]
def _imageset_line(state):
texts = [text for text, role in render.blocks(state) if role == render.ROLE_FOOTER]
assert len(texts) <= 1, texts
return texts[0] if texts else None
def test_the_image_set_is_shown_in_every_status_screen():
for state in ({"mode": "off"},
{"mode": "recovery", "ssid": "ninlight-recovery"},
dict(_migration_state()),
{"mode": None}):
for name in ("ohne-wlan", "mit-wlan"):
s = dict(state, imageset=name, imageset_ok=True)
line = _imageset_line(s)
assert line == render.IMAGESET_LABELS[name], (state.get("mode"), name)
def test_the_two_image_sets_read_differently():
"""Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine
Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos."""
assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"]
assert "WLAN" in render.IMAGESET_LABELS["mit-wlan"]
def test_a_missing_image_set_says_so_on_the_panel():
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
line = _imageset_line(state)
assert line is not None and "FEHLT" in line
def test_without_an_image_set_field_nothing_is_claimed():
"""Ein Zustand aus einer aelteren display.py-Fassung bekommt keine erfundene
Fusszeile."""
assert _imageset_line({"mode": "off"}) is None
def test_the_image_set_line_is_actually_drawn_at_the_bottom():
"""Nicht nur in blocks(): auf dem Panel, unterhalb aller Beiwerkzeilen und ueber dem
unteren Rand."""
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=True)
footer_top = (render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
- render.FOOTER_BAND_HEIGHT_PX)
with_footer = render.compose(state)
without = render.compose({k: v for k, v in state.items()
if k not in ("imageset", "imageset_ok")})
bottom = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
band = _band_pixels(with_footer, footer_top, bottom)
assert 0 in _flat(band), "im Fussband ist gar keine Tinte"
assert band != _band_pixels(without, footer_top, bottom), \
"Fussband sieht mit und ohne Bildsatz gleich aus"
def test_the_image_set_line_keeps_the_bottom_margin_free():
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
image = render.compose(state)
margin = _flat(_band_pixels(image, render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX,
render.PANEL_HEIGHT_PX))
assert set(margin) <= {255}, "der untere Rand ist nicht mehr frei"
def test_the_image_set_line_survives_the_fullest_state():
"""Im vollsten Zustand faellt eine Beiwerkzeile heraus - aber nie die Fusszeile:
sie steht an festem Platz und nicht im Zeilenfluss."""
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=True,
autooff_minutes=17)
assert _imageset_line(state) is not None
# ... und der Zeilenfluss endet wirklich vor dem Fussband.
image = render.compose(state)
footer_top = (render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
- render.FOOTER_BAND_HEIGHT_PX)
# Die Zeile direkt ueber dem Trennstrich darf ihn nicht beruehren.
assert set(_flat(_band_pixels(image, footer_top - 1, footer_top))) <= {255}
def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
"""Dieselbe Datei, die das CLI schreibt und server.py liest - kein zweiter Weg."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("migration")
images = tmp_path / "images"
(images / "migrate-nocreds").mkdir(parents=True)
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
(images / "migrate-wlan").mkdir()
(images / "migrate-wlan" / "g95_cw_dev.img").write_bytes(b"IMG")
conf = tmp_path / "station.conf"
conf.write_text(f"MIGRATE_SSID='qconnex-firmware'\nIMAGE_DIR='{images}'\n")
switch = tmp_path / "imageset"
def state():
return display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)
# Ohne Datei: die sichere Vorgabe.
assert state()["imageset"] == "ohne-wlan"
switch.write_text("mit-wlan\n")
assert state()["imageset"] == "mit-wlan"
# Unbekannter Inhalt darf NIE auf den teureren Satz zeigen.
switch.write_text("irgendwas")
assert state()["imageset"] == "ohne-wlan"
def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
""""Vorhanden" heisst: Verzeichnis da UND mindestens ein Image darin - dieselbe Regel
wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
images = tmp_path / "images"
(images / "migrate-nocreds").mkdir(parents=True)
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
conf = tmp_path / "station.conf"
conf.write_text(f"IMAGE_DIR='{images}'\n")
switch = tmp_path / "imageset"
switch.write_text("ohne-wlan")
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)["imageset_ok"] is True
switch.write_text("mit-wlan") # migrate-wlan/ gibt es nicht
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)
assert state["imageset_ok"] is False
assert "FEHLT" in _imageset_line(state)
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
(images / "migrate-wlan").mkdir()
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)["imageset_ok"] is False
def test_the_display_and_the_server_agree_on_the_set_names():
"""Zwei Prozesse, eine Wahrheit: waeren die Namen oder die Vorgabe verschieden,
behauptete das Panel etwas anderes, als der Server tut."""
import importlib.util
spec = importlib.util.spec_from_file_location(
"server_for_names", Path(__file__).resolve().parents[1] / "server.py")
server = importlib.util.module_from_spec(spec)
spec.loader.exec_module(server)
assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS
assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart -----------------------
#
# E-Paper ist bistabil: das zuletzt gezeichnete Bild bleibt fuer immer stehen, auch
# stromlos. Ohne dieses Bild behauptet das Panel nach dem Ausschalten weiter den Zustand
# von vorher - bei der Abnahme stand dort die Rueckfrage des Tastermenues, und der Pi sah
# aus, als warte er noch auf eine Eingabe.
def _final_text(kind):
return " ".join(render.lines({"mode": "final", "final": kind}))
def test_the_shutdown_and_the_reboot_screen_differ():
"""Der wichtigste Unterschied im ganzen Bild: beim Ausschalten DARF man den Stecker
ziehen, beim Neustart NICHT (an diesem Pi hat ein Stromzug schon eine SD-Karte
gekostet)."""
off, reboot = _final_text("poweroff"), _final_text("reboot")
assert off != reboot
assert "AUSGESCHALTET" in off and "NEUSTART" in reboot
# Ausschalten: erlaubt das Trennen ausdruecklich. Geprueft wird die ZUSAGE, nicht
# der genaue Satz - der Wortlaut musste am 2026-09-15 kuerzer werden, weil die alte
# Fassung ("Strom kann getrennt werden", 262,1 px) auf dem Panel abgeschnitten war.
assert "trennen erlaubt" in off
# Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil.
assert "NICHT trennen" in reboot
assert "trennen erlaubt" not in reboot
def test_the_reboot_screen_says_how_long_it_stays():
"""Ein Zero W braucht gut anderthalb Minuten bis zum Anzeigedienst - ohne diesen Satz
sieht das stehende Bild nach "haengt" aus, und genau dann greift jemand zum Stecker."""
assert "min" in _final_text("reboot")
def test_the_final_screen_carries_nothing_else():
"""Kein Bildsatz, keine Fusszeile, kein Zaehler: die Station ist im Begriff zu
verschwinden, und die einzige Auskunft, auf die es jetzt ankommt, ist "Stecker?"."""
state = {"mode": "final", "final": "poweroff", "imageset": "mit-wlan",
"imageset_ok": True, "served": 7}
blocks = render.blocks(state)
assert not any(role == render.ROLE_FOOTER for _, role in blocks)
assert not any("Bildsatz" in text or "Ausgeliefert" in text for text, _ in blocks)
def test_an_unknown_final_kind_claims_neither():
"""Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder
gebootet wird."""
text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"}))
assert "AUSGESCHALTET" not in text
assert "getrennt" not in text
def test_both_final_screens_are_actually_drawable():
for kind in ("poweroff", "reboot"):
image = render.compose({"mode": "final", "final": kind})
assert image.size == (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX)
assert _ink_box(image) is not None, kind
def test_run_final_draws_fully_and_sleeps(monkeypatch):
"""Ueber die ECHTE display.run_final(): Vollaufbau (kein Teilaufbau - dieses eine Bild
ueberdauert alle anderen und darf keine Reste des vorigen tragen) und danach schlafen
legen, damit das Panel nicht unter Bias in den Ruhezustand geht."""
calls = []
class _Panel:
def __init__(self, revision):
calls.append(("init", revision))
def clear(self):
calls.append(("clear", None))
def draw_full(self, image):
calls.append(("full", image))
def draw_partial(self, image):
calls.append(("partial", image))
return False
def sleep(self):
calls.append(("sleep", None))
monkeypatch.setattr(display, "Panel", _Panel)
display.run_final("V3", "reboot")
kinds = [k for k, _ in calls]
assert kinds == ["init", "clear", "full", "sleep"], kinds
# ... und gezeichnet wurde wirklich das Neustart-Bild, nicht irgendeines.
image = dict((k, v) for k, v in calls)["full"]
expected = render.compose({"mode": "final", "final": "reboot"})
assert list(image.getdata()) == list(expected.getdata())
assert list(image.getdata()) != list(
render.compose({"mode": "final", "final": "poweroff"}).getdata())
def test_display_main_passes_the_final_kind_through(monkeypatch):
"""Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild
landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste
ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart
"Strom kann getrennt werden"."""
drawn = []
class _Panel:
def __init__(self, revision):
pass
def clear(self):
pass
def draw_full(self, image):
drawn.append(image)
def sleep(self):
pass
monkeypatch.setattr(display, "Panel", _Panel)
monkeypatch.setenv("EPD_REVISION", "V3")
for kind in ("reboot", "poweroff"):
drawn.clear()
monkeypatch.setattr(sys, "argv", ["display.py", "--final", kind])
display.main()
assert len(drawn) == 1
expected = render.compose({"mode": "final", "final": kind})
assert list(drawn[0].getdata()) == list(expected.getdata()), kind
# --- Die Entbehrlichkeitszusage der Anzeige (Abschlussreview, I1) --------------------
#
# Zusage aus dem Modul-Docstring, ueber fuenf Fixrunden erkaempft: fehlt Panel, SPI,
# Pillow oder Schrift, gibt es GENAU EINE Protokollzeile und sys.exit(0). Der Grund ist
# Restart=on-failure mit RestartSec=5 in ninlight-display.service - ein Exit != 0 macht
# daraus auf dem Zero W eine Neustartschleife, die das Journal flutet, und der Sinn der
# Zusage ("ein Fehler in der Anzeige darf niemals eine Migration stoeren") faellt still
# weg.
#
# Das Abschlussreview hat belegt, dass diese Zusage ungetestet war: sys.exit(0) ->
# sys.exit(1) liess alle 225 Tests gruen. Die vorhandenen Tests pruefen nur, dass die
# richtigen Ausnahmetypen bis main() DURCHKOMMEN - niemand hat main() selbst in den
# except-Zweig getrieben und auf den Rueckgabewert gesehen. Beim Tasterdienst ist
# dasselbe laengst getestet (tests/test_button.py, test_missing_gpiozero_...).
#
# Alle vier Modi, weil jeder sein eigenes run_*() hat: --test, --clear, --final und der
# Daemon. Alle vier bauen ihr Panel selbst, ein Panel(), das schon im Konstruktor wirft,
# deckt daher jeden der vier Einstiege ab.
_DISPLAY_MODES = [
pytest.param(["display.py", "--test"], id="test"),
pytest.param(["display.py", "--clear"], id="clear"),
pytest.param(["display.py", "--final", "poweroff"], id="final"),
pytest.param(["display.py"], id="daemon"),
]
def _panel_raising(exc):
class _Panel:
def __init__(self, revision):
raise exc
return _Panel
@pytest.mark.parametrize("argv", _DISPLAY_MODES)
def test_missing_panel_logs_exactly_one_line_and_exits_zero(argv, monkeypatch, caplog):
"""Kein Panel/SPI -> DisplayUnavailable -> eine Zeile, Exit 0 - in JEDEM Modus."""
monkeypatch.setattr(display, "Panel",
_panel_raising(epdconfig.DisplayUnavailable("kein SPI-Geraet")))
monkeypatch.setattr(sys, "argv", argv)
monkeypatch.setenv("EPD_REVISION", "V3")
with caplog.at_level("INFO", logger="ninlight-display"):
with pytest.raises(SystemExit) as excinfo:
display.main()
assert excinfo.value.code == 0, (
f"{argv[1:] or ['daemon']}: Exit {excinfo.value.code} statt 0 - "
"Restart=on-failure macht daraus eine Neustartschleife")
assert len(caplog.records) == 1, \
f"erwartet genau eine Protokollzeile, bekommen: {[r.getMessage() for r in caplog.records]}"
assert "kein SPI-Geraet" in caplog.records[0].getMessage()
@pytest.mark.parametrize("argv", _DISPLAY_MODES)
def test_missing_pillow_logs_exactly_one_line_and_exits_zero(argv, monkeypatch, caplog):
"""Der zweite Ast derselben Zusage (Task 6a, C4): Pillow kommt auf dem Ziel-Pi erst
per "apt install python3-pil" nach, render.compose() importiert es erst beim
Zeichnen. Ein ModuleNotFoundError (ImportError-Unterklasse) muss deshalb genauso
enden wie ein fehlendes Panel - und nicht als Absturz."""
monkeypatch.setattr(display, "Panel",
_panel_raising(ModuleNotFoundError("No module named 'PIL'")))
monkeypatch.setattr(sys, "argv", argv)
monkeypatch.setenv("EPD_REVISION", "V3")
with caplog.at_level("INFO", logger="ninlight-display"):
with pytest.raises(SystemExit) as excinfo:
display.main()
assert excinfo.value.code == 0, (
f"{argv[1:] or ['daemon']}: Exit {excinfo.value.code} statt 0")
assert len(caplog.records) == 1, \
f"erwartet genau eine Protokollzeile, bekommen: {[r.getMessage() for r in caplog.records]}"
assert "PIL" in caplog.records[0].getMessage()
# --- Fusszeile des Bildsatz-Umschalters (Geraetetermin 2026-09-15) ------------------
#
# Am Geraet kam die Zeile gekuerzt an: "Lampe -> Onboarding-AP (ohne WLA...". Ausgerechnet
# die Klammer, die den Zustand benennt, fiel weg - und mit dem Zusatz " - SATZ FEHLT" war
# die WARNUNG, dass der gewaehlte Satz gar nicht auf dem Pi liegt, in beiden Richtungen
# unsichtbar (367,5 px bzw. 306 px bei 246 px Platz).
#
# Warum die Testsuite das nie bemerkt hat: _load_font() nimmt den ersten Pfad, der sich
# oeffnen laesst. Auf dem Pi ist das DejaVu Sans Bold, auf dem Mac des Entwicklers faellt
# die Kette bis "Arial Bold.ttf" durch - und Arial baut schmaler. Dieselbe Zeile misst
# dort 246,0 von 246 px (haarscharf drin), auf dem Pi 283,5 px. Das Layout war damit auf
# die Schrift der ENTWICKLUNGSMASCHINE ausgelegt, nicht auf die des Zielgeraets.
#
# Dieser Test misst deshalb mit einer ausdruecklichen Marge: die Zusage lautet nicht "es
# passt mit der hier zufaellig installierten Schrift", sondern "es passt auch mit einer
# spuerbar breiteren". 25 % decken den gemessenen Abstand Arial -> DejaVu (+15 %) mit
# Reserve ab.
FOOTER_WIDTH_SAFETY = 1.25
def _footer_width_px(text: str) -> float:
from PIL import Image, ImageDraw, ImageFont
draw = ImageDraw.Draw(Image.new("1", (render.PANEL_WIDTH_PX,
render.PANEL_HEIGHT_PX), 255))
font = render._load_font(ImageFont, render.FOOTER_FONT_PX, bold=True)
return draw.textlength(text, font=font)
def _fixed_labels():
"""Jede fest im Code stehende Beschriftung mit der Rolle, in der sie gezeichnet wird.
Bewusst VOLLSTAENDIG und nicht auf die Fusszeile beschraenkt: der Geraetetermin hat
denselben Fehler an zwei Stellen gezeigt (Fusszeile UND das Abschlussbild "Strom kann
getrennt werden", 262,1 px bei 246 px Platz). Eine Pruefung, die nur die zuerst
entdeckte Stelle abdeckt, haette die zweite wieder durchgelassen."""
out = []
for kind, (title, hero, details) in render.FINAL_LABELS.items():
out.append(("FINAL/%s/titel" % kind, title, render.ROLE_HEADER))
out.append(("FINAL/%s/hero" % kind, hero, render.ROLE_HERO))
for i, detail in enumerate(details):
out.append(("FINAL/%s/detail%d" % (kind, i), detail, render.ROLE_DETAIL))
for name, label in render.IMAGESET_LABELS.items():
out.append(("BILDSATZ/%s" % name, label, render.ROLE_FOOTER))
for name, label in render.IMAGESET_MISSING_LABELS.items():
out.append(("BILDSATZ-FEHLT/%s" % name, label, render.ROLE_FOOTER))
out.append(("FREIGABE", render.ARMED_LABEL, render.ROLE_HERO))
return out
_ROLE_FONTS = {
render.ROLE_HEADER: (lambda: render.HEADER_FONT_PX, True),
render.ROLE_HERO: (lambda: render.HERO_FONT_PX, True),
render.ROLE_DETAIL: (lambda: render.DETAIL_FONT_PX, False),
render.ROLE_BIG: (lambda: render.BIG_FONT_PX, True),
render.ROLE_FOOTER: (lambda: render.FOOTER_FONT_PX, True),
}
@pytest.mark.parametrize("source,text,role", _fixed_labels(),
ids=[x[0] for x in _fixed_labels()])
def test_fixed_labels_fit_the_panel_with_room_for_a_wider_font(source, text, role):
"""Jede feste Beschriftung muss UNGEKUERZT auf das Panel passen, auch wenn die
Schrift des Zielgeraets breiter baut als die hier installierte."""
from PIL import Image, ImageDraw, ImageFont
draw = ImageDraw.Draw(Image.new("1", (render.PANEL_WIDTH_PX,
render.PANEL_HEIGHT_PX), 255))
size_px, bold = _ROLE_FONTS[role]
font = render._load_font(ImageFont, size_px(), bold=bold)
width_px = draw.textlength(text, font=font)
max_width_px = render.PANEL_WIDTH_PX - 2 * render.TEXT_MARGIN_PX
assert width_px * FOOTER_WIDTH_SAFETY <= max_width_px, (
"{}: {!r} misst {:.1f} px; mit {:.0f} % Reserve fuer eine breitere Schrift "
"bleiben nur {:.1f} px Platz".format(
source, text, width_px, (FOOTER_WIDTH_SAFETY - 1) * 100, max_width_px))