Files
ninlight-w600/tools/station/tests/test_render.py
T
Michael Sauer bb39105ac4 feat(display): grosse Schrift, invertierter Kopf, GPIO-Uebergabe
Zwei Befunde der Hardware-Abnahme.

1) Die Schrift war zu klein: compose() zeichnete alles in
ImageFont.load_default() (rund 10 px) - auf 250x122 aus zwei Metern
nicht lesbar. blocks() vergibt jetzt Rollen (header/hero/detail/big),
compose() setzt jede Rolle eigen: Modus fett und weiss auf schwarzem
Balken, die wichtigste Zeile gross (Migration: erkannte Lampe,
Rettung: SSID), Beiwerk klein, "Station aus" gross und mittig.

Die Schrift kommt per ImageFont.truetype() aus FONT_SEARCH_PATHS mit
DejaVu (/usr/share/fonts/truetype/dejavu/) als Hauptweg - auf beiden
Pis vorhanden. load_default(size=...) scheidet aus: das gibt es erst
ab Pillow 10.1, Pi 2 hat 9.4.0. Letzter Rueckfall bleibt
load_default() ohne Argument, damit eine fehlende Schrift die Anzeige
(und damit nie eine Migration) aufhaelt. lines() bleibt ohne Pillow
importierbar, die Breitenkuerzung misst weiter in Pixeln.

2) "display test" scheiterte bei laufendem Anzeigedienst mit einem
rohen "lgpio.error: 'GPIO busy'": lgpio.error ist keine
gpiozero.GPIOZeroError und fiel durch die Ausnahmeliste in
epdconfig.module_init(). Sie ist jetzt aufgefuehrt - weiterhin
gezielt, TypeError/AttributeError/NameError bleiben Programmierfehler -
und die Meldung nennt bei belegten Leitungen den Anzeigedienst.
"display test|clear" stoppt den Dienst fuer die Dauer des Handgriffs
und startet ihn danach wieder, aber nur, wenn er vorher lief.
2026-09-14 02:24:14 +02:00

1031 lines
45 KiB
Python

import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display"))
import display # noqa: E402
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_does_not_draw_into_the_last_column_for_long_lines():
"""Regressionstest zu Fixrunde 3: der Reviewer hat per Mutation belegt, dass eine
deaktivierte Kuerzung in compose() (fitted = line) die vorherige Testsuite NICHT
aufgehalten haette, weil der alte Test die Kuerzung selbst nachbaute (mit derselben
_truncate_to_width()-Funktion, die auch geprueft werden sollte) statt das von
compose() tatsaechlich GEZEICHNETE BILD zu inspizieren.
Dieser Test prueft stattdessen die Pixel direkt: bei korrekt wirkender Kuerzung
bleibt die letzte Panel-Spalte (x = PANEL_WIDTH_PX - 1) fuer eine absichtlich sehr
lange Zeile weiss, weil die Kuerzung auf PANEL_WIDTH_PX - 2*TEXT_MARGIN_PX zielt
(empirisch mit echtem Pillow bestaetigt: die rechte Kante liegt bei ca. Spalte 237,
deutlich vor Spalte 249). Mit deaktivierter Kuerzung fuellt der zeichenweise laengere
Text die Zeile bis zum Rand - ebenfalls empirisch bestaetigt (die letzte Spalte
enthaelt dann einen schwarzen Pixel).
Geprueft wird erst UNTERHALB des Kopfbalkens: der ist seit der Schriftumstellung
absichtlich vollflaechig schwarz (weisse Schrift auf schwarzem Grund) und faerbt die
letzte Spalte in seinen eigenen Zeilen mit - das ist Layout, keine ueberlaufende
Zeile. Die absichtlich zu lange Zeile (der Produktname) steht ohnehin darunter."""
state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [],
"ssid": "qconnex-firmware",
"last_seen": {"product_name": "W" * 200, "target": "e27_rgb",
"variant": "dev", "lamp_version": "V1"}}
image = render.compose(state)
pixels = image.load()
last_col = render.PANEL_WIDTH_PX - 1
black_in_last_column = any(
pixels[last_col, y] == 0
for y in range(render.HEADER_BAR_HEIGHT_PX, render.PANEL_HEIGHT_PX))
assert not black_in_last_column, "Text erreicht die letzte Spalte - Kuerzung wirkt nicht"
# --- Schriftgroesse/Layout (Hardware-Abnahme: "die Schrift ist zu klein") -----------
#
# Alle Tests hier messen die TINTE im tatsaechlich gezeichneten Bild, nicht die
# Konstanten oben: eine Zusage ueber die Schriftgroesse, die nur die Konstante liest,
# haelt niemanden auf, der die Schriftsuche entfernt und wieder auf
# ImageFont.load_default() (rund 10 px) zurueckfaellt - genau die Mutation, gegen die
# diese Tests wachen sollen.
def _ink_box(image, y0=0, y1=None):
"""(links, oben, rechts, unten) der schwarzen Pixel im Band [y0, y1), oder None."""
y1 = image.height if y1 is None else y1
pixels = image.load()
xs, ys = [], []
for y in range(y0, y1):
for x in range(image.width):
if pixels[x, y] == 0:
xs.append(x)
ys.append(y)
if not xs:
return None
return (min(xs), min(ys), max(xs), max(ys))
def _migration_state():
return {"mode": "migration", "phase": "armed", "armed_target": "gu10_rgb",
"served": 3, "ssid": "qconnex-firmware",
"last_seen": {"product_name": "Klyqa GU10 RGB", "target": "gu10_rgb",
"variant": "prod", "lamp_version": "V1.1.0.1138"},
"recent": [{"target": "gu10_rgb", "status": "ok", "ts": "2026-09-13T13:04:02Z"}]}
def test_off_screen_is_drawn_big_and_centered():
""""Station aus" hat fast keinen Inhalt und muss aus zwei Metern lesbar sein.
Gemessen mit echtem Pillow: mit der Schriftsuche ist die Tinte 19 px hoch und links
wie rechts 56 px vom Rand entfernt; faellt compose() auf load_default() zurueck, sind
es 8 px - die Schwelle 14 trennt beide Faelle eindeutig."""
image = render.compose({"mode": "off"})
box = _ink_box(image)
assert box is not None, "gar nichts gezeichnet"
height = box[3] - box[1] + 1
assert height >= 14, f"Schrift nur {height} px hoch - Schriftsuche wirkt nicht"
left, right = box[0], render.PANEL_WIDTH_PX - 1 - box[2]
assert abs(left - right) <= 3, f"nicht waagerecht mittig (links {left}, rechts {right})"
top, bottom = box[1], render.PANEL_HEIGHT_PX - 1 - box[3]
assert abs(top - bottom) <= 4, f"nicht senkrecht mittig (oben {top}, unten {bottom})"
def test_header_is_a_black_bar_with_white_text():
"""Die Kopfzeile ist invertiert: schwarzer Balken ueber die volle Breite, Modus in
Weiss darin. Geprueft an einer Zeile mitten im Balken - die muss ueberwiegend schwarz
sein UND weisse Pixel enthalten (sonst stuende dort kein Text, sondern nur ein
Balken)."""
image = render.compose(_migration_state())
pixels = image.load()
row = render.HEADER_BAR_HEIGHT_PX // 2
black = sum(1 for x in range(render.PANEL_WIDTH_PX) if pixels[x, row] == 0)
white = sum(1 for x in range(render.PANEL_WIDTH_PX) if pixels[x, row] != 0)
assert black > 150, f"Kopfzeile ist kein schwarzer Balken (nur {black} schwarze Pixel)"
assert white >= 5, "keine weisse Schrift im Balken - der Balken uebermalt den Modus"
def test_hero_line_is_bigger_than_the_details():
"""Die wichtigste Zeile (hier: der erkannte Produktname) muss sich vom Beiwerk
abheben - sonst ist die Rollenvergabe in blocks() folgenlos. Gemessen: 15 px Tinte
fuer die grosse Zeile gegen 10 px fuer die erste Beiwerkzeile."""
image = render.compose(_migration_state())
hero_top = render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX
hero_bottom = hero_top + render.HERO_LINE_HEIGHT_PX
hero = _ink_box(image, hero_top, hero_bottom)
detail = _ink_box(image, hero_bottom, hero_bottom + render.DETAIL_LINE_HEIGHT_PX)
assert hero is not None and detail is not None
hero_height = hero[3] - hero[1] + 1
detail_height = detail[3] - detail[1] + 1
assert hero_height >= detail_height + 3, (
f"grosse Zeile ({hero_height} px) hebt sich nicht vom Beiwerk "
f"({detail_height} px) ab")
def test_the_last_delivery_line_still_fits_on_the_panel():
"""Der vollste Zustand (Kopfbalken, grosse Zeile, sechs Beiwerkzeilen) muss
vollstaendig aufs Panel passen - mit groesserer Schrift ist das keine
Selbstverstaendlichkeit mehr, und ausgerechnet die letzte Zeile ("zuletzt
ausgeliefert") faellt sonst stumm hinten herunter."""
state = _migration_state()
expected = render.lines(state)
assert len(expected) == 8, expected # sonst misst dieser Test etwas anderes
image = render.compose(state)
last_line_top = (render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX
+ render.HERO_LINE_HEIGHT_PX + 5 * render.DETAIL_LINE_HEIGHT_PX)
assert _ink_box(image, last_line_top, render.PANEL_HEIGHT_PX) is not None, (
"letzte Zeile fehlt - sie passt nicht mehr aufs Panel")
def test_compose_still_works_without_any_font_file(monkeypatch):
"""Die Zusage "eine fehlende Schrift haelt die Anzeige nicht auf" gilt unveraendert:
ohne einen einzigen Treffer in der Suchkette faellt compose() auf load_default()
zurueck und zeichnet - klein, aber vorhanden."""
monkeypatch.setattr(render, "FONT_SEARCH_PATHS", ())
image = render.compose({"mode": "off"})
assert _ink_box(image) is not None
# --- Die Schriftsuche selbst, ohne echte Schriftdateien und ohne altes Pillow -------
class _FakeImageFontModule:
"""Nachbau der zwei ImageFont-Funktionen, die _load_font() benutzt.
`available` sind die Pfade, die es "gibt"; jeder andere truetype()-Aufruf scheitert
mit OSError, genau wie Pillow es bei einer fehlenden Datei tut.
load_default() bildet Pillow 9.4.0 auf Pi 2 nach: KEIN size-Argument (das gibt es
erst ab 10.1). Ein `size=`-Aufruf schlaegt hier deshalb mit demselben TypeError fehl
wie am Geraet - so laesst sich die 9.4-Lage pruefen, ohne Pillow herunterzustufen."""
def __init__(self, available=()):
self.available = set(available)
self.truetype_calls = []
self.load_default_calls = 0
def truetype(self, path, size):
self.truetype_calls.append((path, size))
if path not in self.available:
raise OSError(f"cannot open resource: {path}")
return ("truetype", path, size)
def load_default(self, *args, **kwargs):
self.load_default_calls += 1
if args or kwargs:
raise TypeError(
"load_default() got an unexpected keyword argument 'size'")
return ("default",)
DEJAVU_REGULAR = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
DEJAVU_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
def test_font_search_takes_dejavu_first_on_the_pi():
"""Auf beiden Pis liegt DejaVu unter /usr/share/fonts/truetype/dejavu/ - das ist der
Hauptweg, und er muss VOR jedem Rueckfall kommen. Rot, sobald jemand die Suche
entfernt und wieder direkt auf load_default() geht."""
image_font = _FakeImageFontModule(available=(DEJAVU_REGULAR, DEJAVU_BOLD))
regular = render._load_font(image_font, 11)
bold = render._load_font(image_font, 20, bold=True)
assert regular == ("truetype", DEJAVU_REGULAR, 11)
assert bold == ("truetype", DEJAVU_BOLD, 20)
assert image_font.truetype_calls[0] == (DEJAVU_REGULAR, 11), \
"DejaVu wurde nicht zuerst probiert"
assert image_font.load_default_calls == 0, \
"load_default() benutzt, obwohl DejaVu da ist - die Schrift waere wieder winzig"
def test_font_search_falls_through_to_a_later_candidate():
"""Auf der Entwicklungsmaschine (macOS) gibt es den Debian-Pfad nicht - dann muss die
Kette weiterlaufen statt sofort auf load_default() zu fallen, sonst laeuft der Test
oben dort gegen eine 10-px-Schrift."""
arial = "/System/Library/Fonts/Supplemental/Arial.ttf"
image_font = _FakeImageFontModule(available=(arial,))
assert render._load_font(image_font, 16) == ("truetype", arial, 16)
assert image_font.load_default_calls == 0
def test_font_search_ends_at_load_default_without_a_size_argument():
"""Der letzte Rueckfall auf einem Rechner ohne jede Schriftdatei. Entscheidend ist,
dass load_default() OHNE size gerufen wird: auf Pi 2 (Pillow 9.4.0) waere
load_default(size=...) ein TypeError - der Fake wirft ihn hier genauso."""
image_font = _FakeImageFontModule()
assert render._load_font(image_font, 26, bold=True) == ("default",)
assert image_font.load_default_calls == 1
assert len(image_font.truetype_calls) == len(render.FONT_SEARCH_PATHS)
def test_font_search_survives_a_pillow_without_freetype():
"""Ein ohne FreeType gebautes Pillow laesst truetype() mit ImportError scheitern -
auch das darf die Anzeige nicht umbringen."""
class _NoFreetype(_FakeImageFontModule):
def truetype(self, path, size):
raise ImportError("The _imagingft C module is not installed")
image_font = _NoFreetype()
assert render._load_font(image_font, 20) == ("default",)
def test_missing_fields_do_not_raise():
"""status.json kann jederzeit halb geschrieben oder aus einer aelteren Version sein."""
for state in ({}, {"mode": "migration"}, {"mode": "migration", "last_seen": {}}):
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)
# --- "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) -> _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."""
clock = _FakeClock(duration_s)
panel = _RecordingPanel(clock)
try:
display.run_daemon("V3", clock=clock, panel=panel,
read_state=lambda: state_at(clock.monotonic()))
except _StopDaemon:
pass
panel._finish(clock.monotonic())
return panel
def _times(panel: _RecordingPanel, call: str) -> list[float]:
return [t for t, name in panel.calls if name == call]
def _changing_every(period_s, mode="recovery"):
"""Zustand, der sich alle `period_s` Sekunden aendert (und sonst gleich bleibt)."""
return lambda t: {"mode": mode, "served": int(t // period_s), "recent": []}
def test_static_content_never_triggers_another_full_refresh():
"""Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die
Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle Tests gruen.
Genau diese Bedingung verhindert aber den 33-%-Bias-Zyklus: bei UNVERAENDERTEM Inhalt
gibt es keine Geisterbilder aus Teilaufbauten auszugleichen, ein Vollaufbau alle 30
Minuten waere reine Alterung - und weckt das Panel jedesmal fuer weitere 10 Minuten
auf."""
panel = _drive_daemon(lambda t: {"mode": "recovery", "served": 0, "recent": []},
SIX_HOURS_S)
assert _times(panel, "full") == [0.0], (
f"nur der Erstaufbau darf voll sein, tatsaechlich: {_times(panel, 'full')}")
assert _times(panel, "partial") == []
assert _times(panel, "sleep") == [600.0], "genau einmal einschlafen, dann Ruhe"
assert _times(panel, "sleep-noop") == [], (
"run_daemon() hat das Einschlafen nicht zurueckgemeldet - panel.sleep() wird "
"seitdem in jeder Runde erneut gerufen")
assert panel.awake_s <= 700, (
f"Panel war {panel.awake_s} s wach - erwartet nur die ersten 10 min")
def test_change_every_3_minutes_stays_awake_and_compensates_every_30_minutes():
panel = _drive_daemon(_changing_every(3 * 60), SIX_HOURS_S)
fulls = _times(panel, "full")
assert _times(panel, "sleep") == [], "alle 3 min Verkehr - das Panel darf nicht schlafen"
assert len(_times(panel, "partial")) > 100, "die Aenderungen muessen Teilaufbauten sein"
assert len(fulls) >= 10, f"Ghosting-Ausgleich alle 30 min fehlt: {fulls}"
gaps = [b - a for a, b in zip(fulls, fulls[1:])]
assert all(gap >= display.FULL_REFRESH_INTERVAL_S for gap in gaps), (
f"Vollaufbauten zu dicht: {fulls}")
def test_change_every_9_minutes_still_never_sleeps():
"""9 Minuten liegen knapp unter SLEEP_AFTER_IDLE_S (10 min) - das Panel bleibt wach,
die Teilaufbauten sammeln sich, und die 30-Minuten-Uhr gleicht sie aus."""
panel = _drive_daemon(_changing_every(9 * 60), SIX_HOURS_S)
fulls = _times(panel, "full")
assert _times(panel, "sleep") == []
assert _times(panel, "partial"), "Aenderungen bei wachem Panel sind Teilaufbauten"
assert len(fulls) >= 5, f"Ghosting-Ausgleich fehlt: {fulls}"
gaps = [b - a for a, b in zip(fulls, fulls[1:])]
assert all(gap >= display.FULL_REFRESH_INTERVAL_S for gap in gaps)
def test_change_every_11_minutes_sleeps_between_and_wakes_with_a_full_refresh():
"""11 Minuten liegen knapp ueber SLEEP_AFTER_IDLE_S: das Panel schlaeft zwischen zwei
Aenderungen ein, und der naechste Aufbau wird von Panel.draw_partial() selbst zu einem
Vollaufbau aufgewertet - es darf also gar kein Teilaufbau mehr entstehen, und die
30-Minuten-Uhr darf nie zusaetzlich feuern (jeder Aufbau IST schon ein Vollaufbau).
Genau hier faellt auch auf, wenn run_daemon() die Rueckmeldung der Aufwertung
(was_full=upgraded) verwirft: dann zaehlen die Aufwertungen als Teilaufbauten und die
30-Minuten-Uhr schiebt zusaetzliche Vollaufbauten ein (Fixrunde 5, N2)."""
panel = _drive_daemon(_changing_every(11 * 60), SIX_HOURS_S)
assert _times(panel, "partial") == [], (
"nach dem Schlafen wertet das Panel jeden Aufbau selbst zu einem Vollaufbau auf")
assert len(_times(panel, "wake-full")) == SIX_HOURS_S // (11 * 60), (
f"pro Aenderung ein Aufbau erwartet: {_times(panel, 'wake-full')}")
assert len(_times(panel, "sleep")) >= 15, "zwischen den Aenderungen muss geschlafen werden"
assert _times(panel, "sleep-noop") == []
# Nur der Erstaufbau ist ein regulaerer Vollaufbau: jeder weitere Aufbau IST wegen der
# Aufwertung schon vollstaendig, ein zusaetzlicher "stale"-Vollaufbau waere ein Zeichen,
# dass die Aufwertung nicht als Vollaufbau verbucht wurde (F4).
assert _times(panel, "full") == [0.0], (
f"unerwartete Vollaufbauten: {_times(panel, 'full')}")
def test_burst_then_quiet_still_gets_its_ghosting_compensation():
"""Fixrunde 4, N1 - der Dauerzustand, den jemand am laengsten ansieht: ein Buendel von
30 Teilaufbauten in 2,5 Minuten (Migration mehrerer Lampen), danach stundenlang Ruhe.
Die Fassung aus Fixrunde 3 setzte partials_since_full beim Einschlafen auf 0; das
Panel schlief 10 Minuten nach dem Buendel mit dem entstandenen Geisterbild ein, stale
konnte danach nie mehr feuern, und genau dieses Bild stand die restlichen rund 5 h 50
min unveraendert da. Der faellige Ausgleich muss stattdessen nachgeholt werden - und
das Panel danach wieder zur Ruhe kommen."""
def state_at(t):
# 30 Aenderungen im 5-Sekunden-Takt (t = 5 .. 150), danach unveraendert.
step = min(int(t // display.POLL_INTERVAL_S), 30)
return {"mode": "migration", "served": step, "recent": []}
panel = _drive_daemon(state_at, SIX_HOURS_S)
assert len(_times(panel, "partial")) == 30, (
f"das Buendel muss aus Teilaufbauten bestehen: {_times(panel, 'partial')}")
fulls = _times(panel, "full")
assert len(fulls) == 2, f"Erstaufbau + genau ein Ausgleich erwartet, war: {fulls}"
ausgleich = fulls[1]
assert display.FULL_REFRESH_INTERVAL_S <= ausgleich <= 35 * 60, (
f"Ghosting-Ausgleich faellt aus oder kommt zu spaet (t={ausgleich})")
# ... und danach wirklich Ruhe: einmal nach dem Buendel, einmal nach dem Ausgleich.
assert len(_times(panel, "sleep")) == 2, f"unruhiger Schlaf: {_times(panel, 'sleep')}"
assert panel.awake_s <= 30 * 60, (
f"Panel war {panel.awake_s} s von {SIX_HOURS_S} s wach - der Ausgleich darf nicht "
"in einen Wachzyklus ausarten")
def test_daemon_draws_fully_on_mode_change_and_partially_on_content_change():
"""Die Zuordnung Aktion -> Panel-Methode direkt an run_daemon() (Fixrunde 5, N2):
ruft der "full"-Zweig panel.draw_partial() statt panel.draw_full(), gaebe es auf einem
wachen Panel NIE wieder einen echten Vollaufbau - Moduswechsel wuerden nur teilweise
gezeichnet und der Ghosting-Ausgleich waere wirkungslos. Kurzer Verlauf, damit die
Zuordnung Zeitpunkt fuer Zeitpunkt nachvollziehbar ist."""
def state_at(t):
if t < 60:
return {"mode": "recovery", "served": 0, "recent": []}
if t < 120:
return {"mode": "recovery", "served": 1, "recent": []} # nur Inhalt
return {"mode": "migration", "served": 1, "recent": []} # Moduswechsel
panel = _drive_daemon(state_at, 180)
assert panel.calls == [
(0.0, "clear"),
(0.0, "full"), # Erstaufbau (Modus zum ersten Mal bekannt)
(60.0, "partial"), # nur der Inhalt hat sich geaendert
(120.0, "full"), # Moduswechsel - muss ein Vollaufbau sein
], panel.calls
def test_planner_counts_an_upgraded_partial_as_a_full_refresh():
"""Direkt an der Zustandsmaschine (F4): meldet der Aufrufer eine von
Panel.draw_partial() selbst aufgewertete Teilaktualisierung als Vollaufbau, beginnt die
30-Minuten-Uhr neu und es gibt nichts mehr auszugleichen."""
planner = display.RefreshPlanner(0.0)
planner.record_draw(0.0, ["a"], "recovery", was_full=True)
planner.record_draw(10.0, ["b"], "recovery", was_full=False)
assert planner.partials_since_full == 1
planner.record_draw(20.0, ["c"], "recovery", was_full=True) # aufgewerteter Teilaufbau
assert planner.partials_since_full == 0
assert planner.decide(20.0 + display.FULL_REFRESH_INTERVAL_S, ["c"], "recovery") != "full"
def test_planner_draws_fully_on_mode_change():
planner = display.RefreshPlanner(0.0)
planner.record_draw(0.0, ["RETTUNG"], "recovery", was_full=True)
assert planner.decide(5.0, ["MIGRATION"], "migration") == "full"