import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display")) import display # noqa: E402 import epdconfig # noqa: E402 import render # noqa: E402 def test_off_mode_says_so_and_shows_nothing_else(): lines = render.lines({"mode": "off"}) assert any("Station aus" in l for l in lines) assert not any("SSID" in l for l in lines) def test_migration_shows_detected_lamp(): lines = render.lines({ "mode": "migration", "phase": "auto", "served": 3, "ssid": "qconnex-firmware", "last_seen": {"product_name": "Klyqa GU10 RGB CW/WW", "target": "gu10_rgb", "variant": "prod", "lamp_version": "V1.1.0.1138"}, "recent": [{"target": "gu10_rgb", "status": "ok", "ts": "2026-09-13T13:04:02Z"}]}) text = "\n".join(lines) assert "MIGRATION" in text assert "Klyqa GU10 RGB CW/WW" in text assert "V1.1.0.1138" in text assert "prod" in text assert "13:04" in text def test_recovery_mode_without_traffic(): lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0, "recent": []}) text = "\n".join(lines) assert "RETTUNG" in text assert "ninlight-recovery" in text def test_long_product_name_is_not_truncated_by_lines(): """lines() kuerzt bewusst NICHT (mehr) - Breitenkuerzung braucht eine echte Textmessung (schriftabhaengig, siehe render.py-Modul-Docstring/Fixrunde 2) und passiert deshalb erst in compose(). lines() bleibt so ohne Pillow testbar und liefert hier den vollen, ungekuerzten Produktnamen zurueck.""" lines = render.lines({"mode": "migration", "phase": "listen", "served": 0, "recent": [], "ssid": "qconnex-firmware", "last_seen": {"product_name": "X" * 90, "target": "e27_rgb", "variant": "dev", "lamp_version": "V1"}}) assert "X" * 90 in lines def test_truncate_to_width_shortens_and_appends_ellipsis(): """_truncate_to_width() selbst importiert kein Pillow - eine einfache Zeichenzahl- Messfunktion reicht, um die Kuerzungslogik ohne Hardware/Pillow zu pruefen.""" result = render._truncate_to_width("X" * 50, measure=len, max_width_px=10) assert len(result) <= 10 assert result.endswith("…") def test_truncate_to_width_leaves_short_text_untouched(): assert render._truncate_to_width("kurz", measure=len, max_width_px=10) == "kurz" def test_compose_keeps_every_line_within_panel_width(): """Regressionstest zu Fixrunde 2: eine Kuerzung nach Zeichenzahl (die fruehere MAX_COLS-Fassung) ist bei der von compose() verwendeten Proportionalschrift (PIL.ImageFont.load_default(), seit Pillow 10.1 eine TrueType-Schrift) keine gueltige Breitengrenze - eine grossbuchstabenlastige Zeile wie ein Produktname aus der Lampenerkennung ("KLYQA GU10 RGB CW/WW") ist pro Zeichen breiter als eine gemischte Beispielzeile und lief trotz weniger Zeichen als die alte Grenze ueber die Panelbreite hinaus. Pillow ist in dieser Testumgebung vorhanden (siehe Bericht); auf dem Ziel-Pi kommt es erst per "apt install python3-pil" hinzu, genau wie fuer compose() selbst.""" from PIL import Image, ImageDraw, ImageFont state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [], "ssid": "qconnex-firmware", "last_seen": {"product_name": "KLYQA GU10 RGB CW/WW " + "W" * 40, "target": "e27_rgb", "variant": "dev", "lamp_version": "V1"}} # compose() muss uebrigens auch ohne Ausnahme durchlaufen - reine Rauchprobe. image = render.compose(state) assert image.size == (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX) # Dieselbe Kuerzung, die compose() intern verwendet (echte Schrift/Messfunktion), # muss jede Zeile unter die verfuegbare Panelbreite bringen. dummy = Image.new("1", (1, 1)) draw = ImageDraw.Draw(dummy) font = ImageFont.load_default() max_width_px = render.PANEL_WIDTH_PX - 2 * render.TEXT_MARGIN_PX for raw_line in render.lines(state): fitted = render._truncate_to_width( raw_line, measure=lambda s: draw.textlength(s, font=font), max_width_px=max_width_px) assert draw.textlength(fitted, font=font) <= max_width_px 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) def test_armed_phase_is_visible(): lines = render.lines({"mode": "migration", "phase": "armed", "armed_target": "g95_cw", "served": 0, "recent": [], "ssid": "qconnex-firmware"}) assert "FREIGEGEBEN" in "\n".join(lines) # --- display.build_state() (Fixrunde 1, I3) --------------------------------------- # # Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal # nachgebessert werden musste: Phase/armed_target kommen aus control.json und sind nur im # Migrationsmodus gueltig; ein control.json-Rest aus einer frueheren Migration darf im # Rettungsmodus nicht als "FREIGEGEBEN" durchschlagen. def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch): monkeypatch.setattr(display, "_service_active", lambda service_name: True) (tmp_path / "mode").write_text("recovery") (tmp_path / "control.json").write_text(json.dumps({"phase": "armed", "armed_target": "g95_cw"})) conf_path = tmp_path / "station.conf" conf_path.write_text("SSID='ninlight-recovery'\n") state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x") assert state["mode"] == "recovery" assert "phase" not in state assert "armed_target" not in state assert "FREIGEGEBEN" not in "\n".join(render.lines(state)) def test_build_state_off_when_service_not_active(tmp_path, monkeypatch): monkeypatch.setattr(display, "_service_active", lambda service_name: False) # Selbst ein noch so frisches/gueltiges status.json darf "off" nicht verhindern - # "ninlight-station off" laesst status.json bewusst unangetastet liegen. (tmp_path / "mode").write_text("migration") (tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5})) state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", service_name="x") assert state == {"mode": "off"} def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch): """Der Modus-Vorrang liegt bei RUN_DIR/mode, nicht bei status.json's "mode"-Feld - status.json kann veraltet sein (Fixrunde 1, Kleinigkeit: der Vorrang war zuerst umgekehrt).""" monkeypatch.setattr(display, "_service_active", lambda service_name: True) (tmp_path / "mode").write_text("recovery") (tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 1})) conf_path = tmp_path / "station.conf" conf_path.write_text("SSID='ninlight-recovery'\n") state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x") assert state["mode"] == "recovery" assert state["served"] == 1 def test_build_state_falls_back_to_recovery_without_mode_file(tmp_path, monkeypatch): monkeypatch.setattr(display, "_service_active", lambda service_name: True) state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf", service_name="x") assert state["mode"] == "recovery" def test_read_conf_accepts_export_prefix(tmp_path): """setup_pi.sh prueft beim Nachtragen fehlender Schluessel ausdruecklich auf ein optionales "export "-Praefix (^(export[[:space:]]+)?KEY=) - eine bereits vorhandene Konfigurationsdatei mit "export SSID=..." muss also genauso gelesen werden koennen.""" conf_path = tmp_path / "station.conf" conf_path.write_text("export SSID='ninlight-recovery'\nexport MIGRATE_SSID='qconnex-firmware'\n") values = display._read_conf(conf_path) assert values["SSID"] == "ninlight-recovery" assert values["MIGRATE_SSID"] == "qconnex-firmware" # --- Panel-Stubs fuer die V2/V3-Aufwach-/Schlafsequenz (Fixrunde 2, N1) -------------- # # gpiozero/spidev sind auf der Entwicklungsmaschine nicht installiert (und selbst wenn # sie es waeren, haengt hier kein echtes Panel) - diese Fakes implementieren nur genau # die Teilmenge der Schnittstelle, die epdconfig.py und die vendored Panel-Treiber # tatsaechlich aufrufen, damit Panel() end-to-end (ohne echte Hardware) durchlaufen kann. class _FakeDigitalOutputDevice: def __init__(self, pin): self.pin = pin self.value = False def close(self): pass class _FakeDigitalInputDevice: def __init__(self, pin, pull_up=False): self.pin = pin self.value = 0 # "idle", nie "busy" - sonst haengt ReadBusy() in einer Schleife def close(self): pass class _FakeGpiozeroModule: DigitalOutputDevice = _FakeDigitalOutputDevice DigitalInputDevice = _FakeDigitalInputDevice class GPIOZeroError(Exception): pass class _FakeSpiDev: def __init__(self): self.max_speed_hz = None self.mode = None def open(self, bus, device): pass def writebytes(self, data): pass def writebytes2(self, data): pass def close(self): pass class _FakeSpidevModule: SpiDev = _FakeSpiDev def _stub_hardware(monkeypatch): """Ersetzt epdconfig's Sicht auf gpiozero/spidev durch die Fakes oben, taeuscht ein vorhandenes /dev/spidev0.0 vor, macht delay_ms() ein No-op (epd.sleep() wartet sonst echte 2 Sekunden) und stellt sicher, dass epdconfig mit "nichts offen" in den Test geht - epdconfig haelt seinen Zustand in Modulglobalen, die sonst zwischen Tests durchsickern koennten.""" monkeypatch.setattr(epdconfig, "gpiozero", _FakeGpiozeroModule) monkeypatch.setattr(epdconfig, "spidev", _FakeSpidevModule) monkeypatch.setattr(epdconfig.os.path, "exists", lambda path: True) monkeypatch.setattr(epdconfig, "delay_ms", lambda ms: None) monkeypatch.setattr(epdconfig, "_spi", None) monkeypatch.setattr(epdconfig, "_rst_pin", None) monkeypatch.setattr(epdconfig, "_dc_pin", None) monkeypatch.setattr(epdconfig, "_busy_pin", None) 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 Absicherung fuer V2 (Fixrunde 2, N3): epd2in13_V2.sleep() ist Deep Sleep Mode 2 (RAM/Register verworfen); ein init(PART_UPDATE) direkt danach setzt nur einen Teil der fuer einen Teilaufbau noetigen Register (0x01/0x11/0x44/0x45/0x4E/0x4F setzt ausschliesslich der FULL_UPDATE-Zweig) - keine Ausnahme, aber eine auf echter Hardware unbrauchbare Sequenz. draw_partial() muss auch hier nach sleep() zu einem vollwertigen FULL_UPDATE-Vollaufbau (inklusive displayPartBaseImage()) aufwerten.""" _stub_hardware(monkeypatch) panel = display.Panel("V2") image = render.compose({"mode": "off"}) panel.clear() panel.draw_full(image) panel.sleep() panel.draw_partial(image) 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).""" _stub_hardware(monkeypatch) panel = display.Panel("V3") panel.clear() panel.sleep() panel.sleep() # darf nicht erneut versuchen, epd.sleep() aufzurufen