fix(station): Ghosting-Ausgleich nach Buendel, Zeitsteuerung testbar

Der Reset von partials_since_full beim Einschlafen liess den faelligen
Ausgleichs-Vollaufbau dauerhaft ausfallen: nach einem Buendel Teilaufbauten
schlief das Panel mit dem Geisterbild ein und zeigte es stundenlang weiter,
weil stale nie mehr feuern konnte. Reset ersatzlos gestrichen - der Ausgleich
bleibt ausstehend und wird zum 30-Minuten-Termin nachgeholt; der in Fixrunde 3
beseitigte Bias-Zyklus kommt dadurch nicht zurueck.

Die gesamte Zeitsteuerung steckt jetzt in RefreshPlanner statt in run_daemon()s
Endlosschleife und ist ohne Schleife, ohne time.sleep() und ohne Hardware
pruefbar. Neue Tests spielen sechs Stunden Betrieb durch (statischer Inhalt,
Aenderung alle 3/9/11 Minuten, Buendel-dann-Ruhe) und schliessen die drei vom
Reviewer per Mutation belegten Luecken aus Fixrunde 3.
This commit is contained in:
Michael Sauer
2026-09-13 22:04:39 +02:00
parent 38c70ac5ce
commit b8c7febb8f
2 changed files with 322 additions and 48 deletions
+208
View File
@@ -450,3 +450,211 @@ def test_panel_sleep_sends_no_commands_when_already_asleep(monkeypatch):
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)
# --- RefreshPlanner: die Zeitsteuerung ohne Schleife durchspielen (Fixrunde 4) ---------
#
# run_daemon() hatte null Testabdeckung - die gesamte Zeitsteuerung steckte in einer
# Endlosschleife. Sie liegt jetzt in display.RefreshPlanner und laesst sich hier als
# Zeitreihe durchspielen: pro Poll-Runde decide() fragen, die Aktion "ausfuehren" (das
# Panel-Verhalten wird dabei nur so weit nachgebildet, wie es die Entscheidung beeinflusst:
# ein Teilaufbau auf einem schlafenden Panel wertet sich selbst zu einem Vollaufbau auf,
# siehe Panel.draw_partial()) und das Ergebnis zurueckmelden - genau die Reihenfolge, die
# run_daemon() ebenfalls verwendet.
SIX_HOURS_S = 6 * 60 * 60
def _run_schedule(content_at, duration_s, mode="recovery",
poll=display.POLL_INTERVAL_S, planner=None):
"""Spielt `duration_s` Sekunden Anzeigebetrieb durch. `content_at(t)` liefert die
Zeilen, die build_state()/render.lines() zum Zeitpunkt t liefern wuerden.
Gibt (Protokoll, Wachsekunden) zurueck; Protokoll ist eine Liste von (t, Aktion)."""
planner = planner if planner is not None else display.RefreshPlanner(0.0)
log: list[tuple[float, str]] = []
awake = True # run_daemon() ruft direkt nach dem Start panel.clear()
awake_s = 0.0
t = 0.0
while t <= duration_s:
lines = content_at(t)
action = planner.decide(t, lines, mode)
if action == "full":
planner.record_draw(t, lines, mode, was_full=True)
awake = True
elif action == "partial":
# Panel.draw_partial() wertet auf einem schlafenden Panel selbst zu einem
# Vollaufbau auf und meldet das zurueck (F4, siehe Test oben) - im Protokoll
# als "wake-full" unterschieden, weil so ein Aufbau das Panel eben NICHT nur
# teilweise auffrischt.
upgraded = not awake
planner.record_draw(t, lines, mode, was_full=upgraded)
awake = True
if upgraded:
action = "wake-full"
elif action == "sleep":
planner.record_sleep(t)
awake = False
log.append((t, action))
if awake:
awake_s += poll
t += poll
return log, awake_s
def _times(log, action):
return [t for t, a in log if a == action]
def _changing_every(period_s):
"""Inhalt, der sich alle `period_s` Sekunden aendert (und sonst gleich bleibt)."""
return lambda t: ["RETTUNG", f"Ausgeliefert: {int(t // period_s)}"]
def test_static_content_never_triggers_another_full_refresh(monkeypatch):
"""Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die
Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle 21 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."""
log, awake_s = _run_schedule(lambda t: ["RETTUNG", "Ausgeliefert: 0"], SIX_HOURS_S)
assert _times(log, "full") == [0.0], (
f"nur der Erstaufbau darf voll sein, tatsaechlich: {_times(log, 'full')}")
assert _times(log, "partial") == []
assert _times(log, "sleep") == [600.0], "genau einmal einschlafen, dann Ruhe"
assert awake_s <= 700, f"Panel war {awake_s} s wach - erwartet nur die ersten 10 min"
def test_change_every_3_minutes_stays_awake_and_compensates_every_30_minutes():
log, _ = _run_schedule(_changing_every(3 * 60), SIX_HOURS_S)
fulls = _times(log, "full")
assert _times(log, "sleep") == [], "alle 3 min Verkehr - das Panel darf nicht schlafen"
assert len(_times(log, "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."""
log, _ = _run_schedule(_changing_every(9 * 60), SIX_HOURS_S)
fulls = _times(log, "full")
assert _times(log, "sleep") == []
assert _times(log, "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)."""
log, _ = _run_schedule(_changing_every(11 * 60), SIX_HOURS_S)
assert _times(log, "partial") == [], (
"nach dem Schlafen wertet das Panel jeden Aufbau selbst zu einem Vollaufbau auf")
assert len(_times(log, "wake-full")) == SIX_HOURS_S // (11 * 60), (
f"pro Aenderung ein Aufbau erwartet: {_times(log, 'wake-full')}")
assert len(_times(log, "sleep")) >= 15, "zwischen den Aenderungen muss geschlafen werden"
# 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(log, "full") == [0.0], f"unerwartete Vollaufbauten: {_times(log, '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 content_at(t):
# 30 Aenderungen im 5-Sekunden-Takt (t = 5 .. 150), danach unveraendert.
step = min(int(t // display.POLL_INTERVAL_S), 30)
return ["MIGRATION", f"Ausgeliefert: {step}"]
log, awake_s = _run_schedule(content_at, SIX_HOURS_S, mode="migration")
assert len(_times(log, "partial")) == 30, (
f"das Buendel muss aus Teilaufbauten bestehen: {_times(log, 'partial')}")
fulls = _times(log, "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(log, "sleep")) == 2, f"unruhiger Schlaf: {_times(log, 'sleep')}"
assert awake_s <= 30 * 60, (
f"Panel war {awake_s} s von {SIX_HOURS_S} s wach - der Ausgleich darf nicht in "
"einen Wachzyklus ausarten")
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"