Fixrunde 3 zu Task 6a. EnvironmentFile=-/etc/ninlight-station.conf statt -EnvironmentFile= (Fixrunde 2 hatte das Praefix vor den Direktivennamen statt vor den Dateipfad gesetzt - systemd verwarf die Zeile komplett, die Konfigurationsdatei wurde ueberhaupt nicht mehr gelesen). clear() stellte die in Panel dokumentierte Awake-Invariante nicht her (kein 0x26-Referenzpuffer) und verwendet jetzt wie draw_full() displayPartBaseImage(). Die 30-Minuten- Vollaufbau-Uhr feuerte bisher auch ohne vorherige Teilaufbauten und erzeugte im eingeschwungenen Ruhezustand einen endlosen Schlaf-/Wach-Zyklus mit 33 Prozent Bias-Anteil - sie greift jetzt nur noch, wenn seit dem letzten Vollaufbau tatsaechlich Teilaufbauten stattfanden, und der Zaehler wird beim Einschlafen zurueckgesetzt. draw_partial() meldet zurueck, ob es intern zu einem Vollaufbau aufgewertet hat, damit die 30-Minuten-Uhr korrekt bleibt. compose() ersetzt eingebettete Zeilenumbrueche vor der Breitenmessung. Dazu ein Kommando-Rekorder fuer die Tests, der die tatsaechlich gesendete SPI-Byte-Folge protokolliert: die vorherigen Tests fuer den Referenzpuffer (N2) und die Registerfolge nach dem Schlafen (N3) pruefften nur "keine Ausnahme" und blieben laut Reviewer-Mutation gruen, wenn die eigentliche Fehlerursache wieder eingefuehrt wurde. Die neuen Tests pruefen die gesendeten Kommandos direkt und wurden gegen genau diese Mutationen verifiziert.
453 lines
19 KiB
Python
453 lines
19 KiB
Python
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_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)."""
|
|
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.PANEL_HEIGHT_PX))
|
|
assert not black_in_last_column, "Text erreicht die letzte Spalte - Kuerzung wirkt nicht"
|
|
|
|
|
|
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 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 == []
|