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 off" in l for l in lines) assert not any("SSID" in l for l in lines) def test_off_mode_shows_the_address_the_pi_is_reachable_at(): """Bei ausgeschalteter Station ist der Pi ein gewoehnlicher Rechner im Heimnetz - die Adresse am Panel erspart die Suche im Router, bevor man per SSH drangeht.""" lines = render.lines({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}) assert any("wlan0: 192.168.2.96" in l for l in lines) def test_off_mode_names_the_interface_the_address_belongs_to(): """Das Geraet gehoert dazu: Michael betreibt den Pi ohne Kabel, "LAN" las sich deshalb als Falschauskunft (Rueckmeldung 2026-09-23). Am Kabel steht dort eth0.""" lines = render.lines({"mode": "off", "ip": "192.168.1.92", "iface": "eth0"}) assert any("eth0: 192.168.1.92" in l for l in lines) assert not any("LAN" in l for l in lines) def test_off_mode_shows_the_address_even_without_an_interface_name(): """Notnagel: Adresse ohne Geraetenamen ist immer noch die Auskunft, die gebraucht wird - dann ohne Beschriftung raten, sondern schlicht "IP".""" lines = render.lines({"mode": "off", "ip": "192.168.2.96", "iface": ""}) assert any("IP: 192.168.2.96" in l for l in lines) def test_off_mode_says_when_there_is_no_network(): """Leeres Feld heisst "kein Netz" und wird ausdruecklich gesagt: das erklaert, warum SSH gerade scheitert. Eine fehlende Zeile wuerde die Frage offen lassen. "no home network" statt eines nackten "no network": ein Bediener las die fruehere Formulierung so, als sei der Migrations-/Rettungs-Zugangspunkt gemeint - der ist im Aus-Zustand ja tatsaechlich aus. Gemeint ist aber das GEGENTEIL, das Heimnetz, in dem der Pi als gewoehnlicher Rechner haengt (siehe render.py).""" lines = render.lines({"mode": "off", "ip": "", "iface": ""}) assert any("no home network" in l for l in lines) def test_off_mode_without_an_address_field_claims_nothing(): """Aeltere display.py-Fassung (oder ein von Hand gebauter Zustand): ohne das Feld bleibt die Zeile weg, statt eine Adresse zu erfinden.""" lines = render.lines({"mode": "off"}) assert not any("home network" in l or ": 192." in l for l in lines) def test_the_address_line_belongs_to_the_off_screen_only(): """Im Betrieb traegt wlan0 die Zugangspunkt-Adresse, die ohnehin auf jedem Statusbild steht - eine zweite Adresse waere irrefuehrend und kostet die knappe Zeile.""" for mode in ("migration", "recovery"): lines = render.lines({"mode": mode, "phase": "auto", "ip": "192.168.2.96", "iface": "wlan0"}) assert not any("192.168.2.96" 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 "RECOVERY" 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 off" 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: ") 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 off" 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 "ARMED: " 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-off" in line for line in without), without state = dict(_migration_state(), autooff_minutes=7) with_timer = render.lines(state) assert "Auto-off 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-off 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-off 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-off pending" 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 / "last-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", last_mode_path=tmp_path / "last-mode") assert state["autooff_minutes"] == 7 assert "Auto-off 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 / "last-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", last_mode_path=tmp_path / "last-mode") assert "autooff_minutes" not in state assert not any("Auto-off" 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 off" 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-off" 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 "ARMED" durchschlagen. def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch): monkeypatch.setattr(display, "_service_active", lambda service_name: True) (tmp_path / "last-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", last_mode_path=tmp_path / "last-mode") assert state["mode"] == "recovery" assert "phase" not in state assert "armed_target" not in state assert "ARMED" 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 / "last-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", last_mode_path=tmp_path / "last-mode") # "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im # ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll), die Adresse # fuer SSH und die Version, die die Station ausliefern WUERDE. assert state["mode"] == "off" assert set(state) == {"mode", "imageset", "imageset_ok", "ip", "iface", "image_version"} assert isinstance(state["ip"], str) def test_build_state_off_carries_the_local_address(tmp_path, monkeypatch): """Adresse und Geraet kommen aus local_address() und landen nur im Off-Zustand.""" monkeypatch.setattr(display, "_service_active", lambda service_name: False) monkeypatch.setattr(display, "local_address", lambda: ("wlan0", "192.168.2.96")) state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", service_name="x", imageset_path=tmp_path / "imageset") assert state["ip"] == "192.168.2.96" assert state["iface"] == "wlan0" def test_build_state_running_carries_no_address(tmp_path, monkeypatch): monkeypatch.setattr(display, "_service_active", lambda service_name: True) (tmp_path / "last-mode").write_text("migration") state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", service_name="x", imageset_path=tmp_path / "imageset", last_mode_path=tmp_path / "last-mode") assert "ip" not in state def test_local_address_returns_strings_and_never_raises(): """Ohne Netz gibt es keine Route - dann ("", "") statt einer Ausnahme, die das Panel aufhielte.""" iface, ip = display.local_address() assert isinstance(iface, str) and isinstance(ip, str) def test_local_address_works_without_af_inet(monkeypatch): """Der Anzeigedienst darf laut Unit NUR AF_UNIX und AF_NETLINK (siehe ninlight-display.service:RestrictAddressFamilies). Ein AF_INET-Socket scheitert dort mit EAFNOSUPPORT - genau daran ist die Adresszeile am echten Panel gescheitert, waehrend derselbe Aufruf von Hand (ohne Sandbox) die Adresse lieferte. Dieser Test sperrt AF_INET wie die Unit und verlangt die Adresse trotzdem.""" import socket as _socket def kein_af_inet(family, *a, **kw): if family == _socket.AF_INET: raise OSError(97, "Address family not supported by protocol") raise AssertionError("unerwartete Socketfamilie: %r" % (family,)) monkeypatch.setattr(_socket, "socket", kein_af_inet) monkeypatch.setattr(display, "_route_lookup", lambda: "192.0.2.1 dev wlan0 src 192.168.2.96 uid 1000") assert display.local_address() == ("wlan0", "192.168.2.96") def test_local_address_reads_interface_and_source_from_the_route(monkeypatch): """Das Geraet gehoert mit auf das Panel: Michael hat kein Kabel, "LAN" las sich deshalb falsch (Rueckmeldung 2026-09-23). Beide Angaben stehen in derselben Auskunft.""" monkeypatch.setattr(display, "_route_lookup", lambda: "192.0.2.1 dev eth0 src 192.168.1.92 uid 1000 \n cache") assert display.local_address() == ("eth0", "192.168.1.92") def test_local_address_without_a_route(monkeypatch): """Kein Netz: "ip route get" meldet einen Fehler und liefert nichts Brauchbares.""" monkeypatch.setattr(display, "_route_lookup", lambda: "") assert display.local_address() == ("", "") # --- Lebenszeichen: ein kleiner Punkt, sobald eine Anfrage jeder Art hereinkommt ------- # # Bedienerwunsch (2026-09-23): "so ist es immer raetselraten und warten das der zaehler # hochgeht um zu wissen ob es geklappt hat" - der Punkt soll VOR dem Erfolg schon zeigen, # dass ueberhaupt jemand anklopft. def test_recent_request_is_true_right_after_a_request(): status = {"last_request_ts": "2026-09-23T12:00:00Z"} now = display.datetime(2026, 9, 23, 12, 0, 5, tzinfo=display.timezone.utc).timestamp() assert display._recent_request(status, now=now) is True def test_recent_request_expires_after_the_window(): status = {"last_request_ts": "2026-09-23T12:00:00Z"} now = (display.datetime(2026, 9, 23, 12, 0, 0, tzinfo=display.timezone.utc).timestamp() + display.ACTIVITY_WINDOW_S + 1) assert display._recent_request(status, now=now) is False def test_recent_request_without_a_timestamp_is_false(): """Aeltere server.py-Fassung, oder noch nie eine Anfrage seit dem Start - kein erfundener Punkt.""" assert display._recent_request({}) is False assert display._recent_request({"last_request_ts": None}) is False assert display._recent_request({"last_request_ts": "kaputt"}) is False def test_recent_request_from_the_future_is_false(): """Eine verstellte Uhr darf den Punkt nicht auf ewig scharf halten.""" status = {"last_request_ts": "2026-09-23T12:00:10Z"} now = display.datetime(2026, 9, 23, 12, 0, 0, tzinfo=display.timezone.utc).timestamp() assert display._recent_request(status, now=now) is False def test_served_line_shows_a_dot_while_activity_is_recent(): lines_active = render.lines({"mode": "recovery", "served": 3, "recent": [], "activity": True}) lines_idle = render.lines({"mode": "recovery", "served": 3, "recent": [], "activity": False}) active_line = next(l for l in lines_active if l.startswith("Served:")) idle_line = next(l for l in lines_idle if l.startswith("Served:")) assert active_line != idle_line assert "3" in active_line and "3" in idle_line def test_served_line_without_an_activity_field_claims_nothing(): """Aeltere display.py-Fassung (oder ein von Hand gebauter Zustand im Test): ohne das Feld bleibt die Zeile wie zuvor, kein erfundener Punkt.""" lines = render.lines({"mode": "recovery", "served": 3, "recent": []}) served_line = next(l for l in lines if l.startswith("Served:")) assert served_line == "Served: 3" def test_build_state_carries_activity_in_recovery_and_migration(tmp_path, monkeypatch): monkeypatch.setattr(display, "_service_active", lambda service_name: True) (tmp_path / "last-mode").write_text("recovery") (tmp_path / "status.json").write_text(json.dumps({"last_request_ts": None})) conf = tmp_path / "station.conf" conf.write_text("") state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", imageset_path=tmp_path / "imageset", last_mode_path=tmp_path / "last-mode") assert state["activity"] is False def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch): """Der Modus-Vorrang liegt bei der dauerhaften Modusdatei (last_mode_path), 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 / "last-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", last_mode_path=tmp_path / "last-mode") 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", last_mode_path=tmp_path / "last-mode") assert state["mode"] == "recovery" def test_build_state_reads_the_durable_mode_file_even_after_a_real_stop(tmp_path, monkeypatch): """Fixrunde 1, Befund 3: RUN_DIR/mode ueberlebt nur einen "systemctl restart", NICHT das "systemctl stop", das "ninlight-station off" ausloest (die Unit setzt RuntimeDirectoryPreserve=restart, nicht "yes") - ein frueherer Docstring behauptete das Gegenteil. Dieser Test bildet GENAU diesen Ablauf nach: "migrate on" schreibt den Modus, RUN_DIR wird dann (wie bei einem echten Stop) GELEERT, und build_state() muss den Modus trotzdem noch aus der dauerhaften Datei kennen - nicht auf "recovery" zurueckfallen, waehrend tatsaechlich eine Migration ansteht.""" last_mode = tmp_path / "var" / "last-mode" run_dir = tmp_path / "run" run_dir.mkdir() (run_dir / "mode").write_text("migration") last_mode.parent.mkdir(parents=True) last_mode.write_text("migration") # "systemctl stop" leert das RuntimeDirectory - hier nachgestellt, ohne RUN_DIR # selbst zu entfernen (das legt server.py/das CLI bei Bedarf neu an). (run_dir / "mode").unlink() monkeypatch.setattr(display, "_service_active", lambda service_name: True) state = display.build_state(run_dir=run_dir, conf_path=tmp_path / "station.conf", service_name="x", imageset_path=tmp_path / "imageset", last_mode_path=last_mode) assert state["mode"] == "migration" 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": []} # --- Lebenszeichen im Aus-Zustand: die IP muss auftauchen, sobald sie da ist ----------- # # Rueckmeldung eines Bedieners (2026-09-23): am laufenden Pi blieb "no network" stehen, # obwohl der Pi laengst im Heimnetz haengt. local_address() liefert die Adresse dabei # nachweislich korrekt - der Verdacht war also die Anzeigeschleife: wird der Aus-Zustand # ueberhaupt regelmaessig neu erhoben, und fliesst die IP in den Vergleich ein, der ueber # ein Neuzeichnen entscheidet? Dieser Test treibt run_daemon() GENAU durch diesen Fall: # beim ersten Erheben keine Adresse, beim zweiten eine. def test_off_mode_redraws_once_the_address_appears(monkeypatch): """Haelt fest: run_daemon() erhebt den Zustand auch im Aus-Zustand unbedingt alle POLL_INTERVAL_S Sekunden neu (kein Ueberspringen mangels Moduswechsel), und die IP ist Teil des Vergleichs, der ueber ein Neuzeichnen entscheidet (render.lines() traegt sie, siehe render.py). Schlaegt dieser Test fehl, bleibt "no home network" auf dem Panel stehen, obwohl der Pi laengst erreichbar ist.""" seq = [{"mode": "off", "ip": "", "iface": ""}, {"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}] def state_at(t): # Erste POLL_INTERVAL_S-Erhebung (bei t=0) sieht noch keine Adresse, danach schon - # weit genug auseinander, dass zwei unterschiedliche Polls sicher dazwischenliegen. return seq[0] if t < 2 * display.POLL_INTERVAL_S else seq[1] panel = _drive_daemon(state_at, duration_s=4 * display.POLL_INTERVAL_S) # Erster Aufbau zeigt "keine Adresse" (Vollaufbau beim Start). Danach MUSS # spaetestens einer der Aufrufe die neue Adresse tatsaechlich zeichnen - das # Panel-Doppel protokolliert keinen Bildinhalt, deshalb wird direkt geprueft, dass # render.lines() zum Zeitpunkt des naechsten Aufbaus schon die neue Adresse traegt # (dieselbe Quelle, die run_daemon() intern fuer den Vergleich benutzt). assert len(panel.calls) >= 2, panel.calls draws_after_first = [name for _, name in panel.calls[1:]] assert any(name in ("full", "partial", "wake-full") for name in draws_after_first), ( "kein weiterer Aufbau nach dem ersten - die neue Adresse waere nie gezeichnet worden", panel.calls, ) assert any("wlan0: 192.168.2.96" in line for line in render.lines(state_at(4 * display.POLL_INTERVAL_S))) def test_run_daemon_survives_a_single_failed_state_read(monkeypatch): """Ein einzelner unerwarteter Fehler beim Erheben (ausserhalb dessen, was build_state()s eigene Bausteine schon abfangen) darf die Endlosschleife nicht beenden - sonst friert das Panel beim naechsten Fehlschlag fuer immer auf dem zuletzt gezeichneten Bild ein (E-Paper vergisst nichts von selbst), und zwar OHNE jede Fehlermeldung am Geraet. Erhebung 1 wirft, Erhebung 2 liefert einen echten Zustand - danach muss dieser auch tatsaechlich gezeichnet sein.""" calls = {"n": 0} good_state = {"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"} def flaky_state_at(t): calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("simulierter Lesefehler (z.B. RUN_DIR mitten im Neustart)") return good_state panel = _drive_daemon(flaky_state_at, duration_s=3 * display.POLL_INTERVAL_S) # Kein Absturz (sonst haette _drive_daemon die Ausnahme durchgereicht statt sie # abzufangen) und die Schleife lief tatsaechlich weiter: mindestens der initiale # Vollaufbau (mit dem neutralen Platzhalter) und ein weiterer, sobald der zweite # Versuch den echten Zustand liefert. assert len(panel.calls) >= 2, panel.calls 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, ["RECOVERY"], "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": "MENU", "hero": "> Station off", "details": ["Refresh display", "Shut down"], "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 off", in dem das Menue "Start recovery"/"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 == ["MENU", "> Station off", "Refresh display", "Shut down"] 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 == ["Button menu"] 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 off" 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 "Wi-Fi" 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 "MISSING" 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) last_mode = tmp_path / "last-mode" last_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, last_mode_path=last_mode) # 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. Ausdruecklich im Migrationsmodus (last_mode_path explizit auf "migration" gesetzt, nicht dem Vorgabewert ueberlassen - Fixrunde 1, Befund 3: eine fehlende Datei faellt auf "recovery", und dieser Test soll die MIGRATION pruefen) - das ist es, was die migrate-*-Verzeichnisse betrifft. Der Rettungsmodus prueft ein eigenes Paar (rescue-*), siehe test_build_state_reports_a_missing_rescue_image_set unten.""" monkeypatch.setattr(display, "_service_active", lambda service_name: True) last_mode = tmp_path / "last-mode" last_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") 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, last_mode_path=last_mode)["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, last_mode_path=last_mode) assert state["imageset_ok"] is False assert "MISSING" 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, last_mode_path=last_mode)["imageset_ok"] is False def test_build_state_reports_a_missing_rescue_image_set(tmp_path, monkeypatch): """Derselbe Umschalter gilt jetzt auch fuer die Rettung, mit einem EIGENEN Verzeichnispaar (rescue-*, nicht migrate-*) - server.py begruendet die Trennung. last_mode_path wird ausdruecklich auf "recovery" gesetzt, statt den Vorgabewert zu nutzen (Fixrunde 1, Befund 3 - ein vorheriger Zustand dieses Tests liess die Datei ganz weg und pruefte damit nur den ZUFAELLIG gleich lautenden Rueckfallwert, nicht die Wirklichkeit: mit einer fehlenden Datei haette dieser Test nicht bemerkt, wenn "recovery" faelschlich auch fuer eine echte Migration zurueckkaeme).""" monkeypatch.setattr(display, "_service_active", lambda service_name: True) last_mode = tmp_path / "last-mode" last_mode.write_text("recovery") images = tmp_path / "images" (images / "rescue-nocreds").mkdir(parents=True) (images / "rescue-nocreds" / "ninlight_g95_cw_ota.img").write_bytes(b"IMG") # Ein voll bestueckter Migrations-Bildsatz daneben - er darf das Ergebnis nicht # beeinflussen (falsche Zuordnung waere der Fehler, den dieser Test faengt). (images / "migrate-wlan").mkdir(parents=True) 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, last_mode_path=last_mode)["imageset_ok"] is True switch.write_text("mit-wlan") # rescue-wlan/ gibt es nicht state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", imageset_path=switch, last_mode_path=last_mode) assert state["imageset_ok"] is False assert "MISSING" in _imageset_line(state) # Und ein LEERES Verzeichnis zaehlt genauso als fehlend. (images / "rescue-wlan").mkdir() assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x", imageset_path=switch, last_mode_path=last_mode)["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 assert display.RESCUE_IMAGESET_DIRS == server.RESCUE_SET_DIRS # --- 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 "POWERED OFF" in off and "REBOOTING" 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 "Safe to unplug" in off # Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil. assert "Do NOT unplug" in reboot assert "Safe to unplug" 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 "Served" 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 "POWERED OFF" 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 "Safe to unplug".""" 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)) # --- Ausgelieferte Firmware-Version --------------------------------------------------- # # "Welche Version liefert die Station gerade aus?" war am Geraet nur zu beantworten, # indem man sich per SSH die Abzuege ansah. Die Anzeige sagt es jetzt selbst. def test_off_screen_shows_delivered_version_next_to_the_address(): bloecke = render.blocks({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0", "image_version": "0.4.0"}) assert ("wlan0: 192.168.2.96", render.ROLE_DETAIL) in bloecke assert ("0.4.0", render.ROLE_DETAIL_RIGHT) in bloecke # Rechts NEBEN der Adresse, also unmittelbar dahinter - sonst landete sie neben # einer anderen Zeile. assert bloecke.index(("0.4.0", render.ROLE_DETAIL_RIGHT)) == ( bloecke.index(("wlan0: 192.168.2.96", render.ROLE_DETAIL)) + 1 ) def test_rescue_shows_delivered_version_in_the_header(): bloecke = render.blocks({"mode": "recovery", "ssid": "ninlight-rescue", "image_version": "0.4.0"}) assert ("0.4.0", render.ROLE_HEADER_PHASE) in bloecke def test_migration_labels_the_delivered_version(): """Im Migrationsmodus beschriftet, weil daneben schon die Lampenversion steht.""" bloecke = render.blocks({ "mode": "migration", "phase": "auto", "image_version": "0.4.0", "last_seen": {"product_name": "Klyqa E27 RGB", "lamp_version": "1.1.1.2358"}, }) texte = [text for text, _ in bloecke] assert "FW: 1.1.1.2358" in texte assert "liefert: 0.4.0" in texte # NICHT in die Kopfzeile: dort steht die Phase. assert ("0.4.0", render.ROLE_HEADER_PHASE) not in bloecke assert ("AUTO", render.ROLE_HEADER_PHASE) in bloecke def test_unknown_version_shows_nothing(): """Lieber keine Angabe als eine erfundene - sie beantwortet sonst genau die Frage falsch, fuer die sie da ist.""" for zustand in ({"mode": "off", "ip": "192.168.2.96", "iface": "wlan0"}, {"mode": "off", "ip": "192.168.2.96", "iface": "wlan0", "image_version": ""}, {"mode": "recovery", "ssid": "x", "image_version": None}): bloecke = render.blocks(zustand) assert not [b for b in bloecke if b[1] == render.ROLE_DETAIL_RIGHT] assert not [b for b in bloecke if b[1] == render.ROLE_HEADER_PHASE] def test_mixed_sets_are_called_out(): bloecke = render.blocks({"mode": "off", "ip": "10.0.0.5", "iface": "eth0", "image_version": "gemischt"}) assert ("gemischt", render.ROLE_DETAIL_RIGHT) in bloecke