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:
@@ -301,6 +301,104 @@ class Panel:
|
|||||||
self._awake = False
|
self._awake = False
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshPlanner:
|
||||||
|
"""Die gesamte Zeitsteuerung der Anzeige - wann voll aufgebaut, wann teilweise, wann
|
||||||
|
geschlafen wird - als reine Zustandsmaschine ohne Schleife, ohne time.sleep() und ohne
|
||||||
|
Hardware.
|
||||||
|
|
||||||
|
Vorher steckte diese Logik direkt in run_daemon()s Endlosschleife und war dadurch
|
||||||
|
ungetestet (Fixrunde 4): drei Korrekturen aus Fixrunde 3 (die stale-Bedingung, die
|
||||||
|
Rueckmeldung einer intern aufgewerteten Teilaktualisierung, die Zeilenumbruchbehandlung
|
||||||
|
in compose()) liessen sich per Mutation aendern, ohne dass ein einziger Test rot wurde.
|
||||||
|
Hier laesst sich stattdessen ein kompletter Tagesverlauf in Millisekunden durchspielen
|
||||||
|
(siehe test_render.py, _run_schedule()).
|
||||||
|
|
||||||
|
Benutzung: pro Runde einmal decide() fragen, die zurueckgegebene Aktion ausfuehren und
|
||||||
|
das Ergebnis per record_draw()/record_sleep() zurueckmelden. decide() selbst aendert
|
||||||
|
nichts - der Aufrufer meldet, was tatsaechlich passiert ist (insbesondere, ob
|
||||||
|
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat).
|
||||||
|
|
||||||
|
Aktionen:
|
||||||
|
"full" - kompletter Bildaufbau (Moduswechsel oder faelliger Ghosting-Ausgleich)
|
||||||
|
"partial" - Teilaktualisierung (Inhalt hat sich geaendert)
|
||||||
|
"sleep" - Panel schlafen legen (laenger nichts passiert)
|
||||||
|
"idle" - nichts tun
|
||||||
|
"""
|
||||||
|
|
||||||
|
_NO_MODE = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
|
||||||
|
|
||||||
|
def __init__(self, now: float,
|
||||||
|
full_refresh_interval_s: float = FULL_REFRESH_INTERVAL_S,
|
||||||
|
sleep_after_idle_s: float = SLEEP_AFTER_IDLE_S) -> None:
|
||||||
|
self.full_refresh_interval_s = full_refresh_interval_s
|
||||||
|
self.sleep_after_idle_s = sleep_after_idle_s
|
||||||
|
self.last_lines: list[str] | None = None
|
||||||
|
self.last_mode: object = self._NO_MODE
|
||||||
|
self.last_full = now
|
||||||
|
self.last_draw = now
|
||||||
|
# Zaehlt Teilaufbauten seit dem letzten Vollaufbau. full_refresh_interval_s
|
||||||
|
# existiert nur, um Geisterbilder aus AUFEINANDERFOLGENDEN Teilaufbauten
|
||||||
|
# auszugleichen - ohne mindestens einen Teilaufbau seit dem letzten Vollaufbau gibt
|
||||||
|
# es nichts auszugleichen, und ein Vollaufbau des UNVERAENDERTEN Bildes waere
|
||||||
|
# reine, nutzlose Alterung (Fixrunde 3, F3): ohne diese Absicherung entstand im
|
||||||
|
# eingeschwungenen Ruhezustand ein endloser 10-Minuten-schlafen/10-Minuten-wach-
|
||||||
|
# Zyklus (33% Bias-Anteil) rein aus der 30-Minuten-Uhr, obwohl sich der Inhalt nie
|
||||||
|
# aenderte.
|
||||||
|
#
|
||||||
|
# Der Zaehler wird beim Einschlafen BEWUSST NICHT zurueckgesetzt (Fixrunde 4, N1):
|
||||||
|
# das tat die Fassung aus Fixrunde 3 und liess den faelligen Ghosting-Ausgleich
|
||||||
|
# damit dauerhaft ausfallen - nach einem Buendel Teilaufbauten (z.B. 30 Lampen in
|
||||||
|
# 2,5 Minuten) schlief das Panel nach 10 Minuten mit dem entstandenen Geisterbild
|
||||||
|
# ein und zeigte genau dieses Bild stundenlang weiter, weil stale wegen
|
||||||
|
# partials_since_full == 0 nie mehr feuern konnte. So bleibt der Ausgleich
|
||||||
|
# ausstehend und wird zum 30-Minuten-Termin nachgeholt (das weckt das Panel einmal
|
||||||
|
# kurz auf; danach ist der Zaehler 0 und der eingeschwungene Ruhezustand bleibt
|
||||||
|
# wie in F3 gewuenscht bei 3% Wachanteil). Die Alternative - vor dem Einschlafen
|
||||||
|
# einmal draw_full() ziehen - kostet denselben einen Vollaufbau, aber JEDES Mal,
|
||||||
|
# wenn ein Buendel endet, auch wenn kurz darauf ohnehin neue Inhalte kommen (die
|
||||||
|
# das Panel nach dem Schlafen sowieso ueber draw_partial()s Aufwertung voll
|
||||||
|
# aufbauen). Diese Variante altert das Panel im Betriebsfall also mehr, nicht
|
||||||
|
# weniger, und der Ausgleich hier kommt spaetestens zum ohnehin vorgesehenen
|
||||||
|
# Termin.
|
||||||
|
self.partials_since_full = 0
|
||||||
|
self.asleep = False
|
||||||
|
|
||||||
|
def decide(self, now: float, lines: list[str], mode: object) -> str:
|
||||||
|
"""Was jetzt zu tun ist. Aendert nichts - das Ergebnis wird per record_*()
|
||||||
|
zurueckgemeldet."""
|
||||||
|
mode_changed = mode != self.last_mode
|
||||||
|
stale = (self.partials_since_full > 0
|
||||||
|
and (now - self.last_full) >= self.full_refresh_interval_s)
|
||||||
|
|
||||||
|
if lines != self.last_lines or mode_changed or stale:
|
||||||
|
return "full" if (mode_changed or stale) else "partial"
|
||||||
|
if not self.asleep and (now - self.last_draw) >= self.sleep_after_idle_s:
|
||||||
|
# Nach laengerer Ruhe schlafen legen (Fixrunde 1, I4: die Pixel bleiben -
|
||||||
|
# e-Paper ist bistabil - aber ohne dauerhafte Spannung altert das Panel
|
||||||
|
# deutlich weniger).
|
||||||
|
return "sleep"
|
||||||
|
return "idle"
|
||||||
|
|
||||||
|
def record_draw(self, now: float, lines: list[str], mode: object,
|
||||||
|
was_full: bool) -> None:
|
||||||
|
"""`was_full` ist True fuer einen regulaeren Vollaufbau UND dann, wenn
|
||||||
|
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat (Fixrunde 3,
|
||||||
|
F4) - sonst kaeme der naechste "stale"-Vollaufbau zu frueh und altert das Panel
|
||||||
|
zusaetzlich, obwohl gerade erst vollstaendig aufgebaut wurde."""
|
||||||
|
self.last_lines = lines
|
||||||
|
self.last_mode = mode
|
||||||
|
self.last_draw = now
|
||||||
|
self.asleep = False
|
||||||
|
if was_full:
|
||||||
|
self.last_full = now
|
||||||
|
self.partials_since_full = 0
|
||||||
|
else:
|
||||||
|
self.partials_since_full += 1
|
||||||
|
|
||||||
|
def record_sleep(self, now: float) -> None:
|
||||||
|
self.asleep = True
|
||||||
|
|
||||||
|
|
||||||
def _test_image(revision: str):
|
def _test_image(revision: str):
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
@@ -337,22 +435,13 @@ def run_clear(revision: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def run_daemon(revision: str) -> None:
|
def run_daemon(revision: str) -> None:
|
||||||
|
"""Endlosschleife: alle POLL_INTERVAL_S Sekunden den Zustand einlesen und die von
|
||||||
|
RefreshPlanner entschiedene Aktion ausfuehren. Hier steht bewusst KEINE Zeitlogik mehr
|
||||||
|
(Fixrunde 4) - die steckt vollstaendig in RefreshPlanner und ist dort ohne Schleife,
|
||||||
|
ohne time.sleep() und ohne Hardware pruefbar."""
|
||||||
panel = Panel(revision)
|
panel = Panel(revision)
|
||||||
panel.clear()
|
panel.clear()
|
||||||
|
planner = RefreshPlanner(time.monotonic())
|
||||||
last_lines: list[str] | None = None
|
|
||||||
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
|
|
||||||
last_full = time.monotonic()
|
|
||||||
last_draw = time.monotonic()
|
|
||||||
# Zaehlt Teilaufbauten seit dem letzten Vollaufbau. FULL_REFRESH_INTERVAL_S existiert
|
|
||||||
# nur, um Geisterbilder aus AUFEINANDERFOLGENDEN Teilaufbauten auszugleichen - ohne
|
|
||||||
# mindestens einen Teilaufbau seit dem letzten Vollaufbau gibt es nichts auszugleichen,
|
|
||||||
# und ein Vollaufbau des UNVERAENDERTEN Bildes waere reine, nutzlose Alterung
|
|
||||||
# (Fixrunde 3, F3): ohne diese Absicherung entstand im eingeschwungenen Ruhezustand ein
|
|
||||||
# endloser 10-Minuten-schlafen/10-Minuten-wach-Zyklus (33% Bias-Anteil) rein aus der
|
|
||||||
# 30-Minuten-Uhr, obwohl sich der Inhalt nie aenderte - genau das, was I4 verhindern
|
|
||||||
# sollte.
|
|
||||||
partials_since_full = 0
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
state = build_state()
|
state = build_state()
|
||||||
@@ -360,41 +449,18 @@ def run_daemon(revision: str) -> None:
|
|||||||
mode = state.get("mode")
|
mode = state.get("mode")
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
||||||
mode_changed = mode != last_mode
|
action = planner.decide(now, current_lines, mode)
|
||||||
stale = partials_since_full > 0 and (now - last_full) >= FULL_REFRESH_INTERVAL_S
|
if action == "full":
|
||||||
|
panel.draw_full(render.compose(state))
|
||||||
if current_lines != last_lines or mode_changed or stale:
|
planner.record_draw(now, current_lines, mode, was_full=True)
|
||||||
image = render.compose(state)
|
elif action == "partial":
|
||||||
if mode_changed or stale:
|
# Panel.draw_partial() wertet sich selbst zu einem Vollaufbau auf, wenn das
|
||||||
panel.draw_full(image)
|
# Panel nicht wach ist (siehe Panel-Klasse) - das meldet es hier zurueck.
|
||||||
last_full = now
|
upgraded = panel.draw_partial(render.compose(state))
|
||||||
partials_since_full = 0
|
planner.record_draw(now, current_lines, mode, was_full=upgraded)
|
||||||
else:
|
elif action == "sleep":
|
||||||
upgraded = panel.draw_partial(image)
|
|
||||||
if upgraded:
|
|
||||||
# Panel.draw_partial() hat selbst zu einem Vollaufbau aufgewertet
|
|
||||||
# (Panel war nicht wach, siehe Panel-Klasse) - das zaehlt fuer die
|
|
||||||
# 30-Minuten-Uhr wie ein regulaerer Vollaufbau (Fixrunde 3, F4), sonst
|
|
||||||
# kaeme der naechste "stale"-Vollaufbau zu frueh.
|
|
||||||
last_full = now
|
|
||||||
partials_since_full = 0
|
|
||||||
else:
|
|
||||||
partials_since_full += 1
|
|
||||||
last_draw = now
|
|
||||||
last_lines = current_lines
|
|
||||||
last_mode = mode
|
|
||||||
elif (now - last_draw) >= SLEEP_AFTER_IDLE_S:
|
|
||||||
# Nach laengerer Ruhe schlafen legen (I4: die Pixel bleiben - e-Paper ist
|
|
||||||
# bistabil - aber ohne dauerhafte Spannung altert das Panel deutlich
|
|
||||||
# weniger). Panel.sleep() ist idempotent, ein wiederholter Aufruf hier
|
|
||||||
# (solange weiterhin nichts passiert) ist also unschaedlich.
|
|
||||||
panel.sleep()
|
panel.sleep()
|
||||||
# Nach dem Schlafen wertet Panel.draw_partial() den naechsten Aufbau ohnehin
|
planner.record_sleep(now)
|
||||||
# zu einem Vollaufbau auf - der 30-Minuten-Zaehler soll dieses "natuerliche"
|
|
||||||
# Zuruecksetzen nicht durch einen zusaetzlichen, inhaltslosen "stale"-
|
|
||||||
# Vollaufbau vorwegnehmen, falls vor dem Einschlafen schon Teilaufbauten
|
|
||||||
# stattgefunden hatten (Fixrunde 3, F3, zweiter Teil).
|
|
||||||
partials_since_full = 0
|
|
||||||
|
|
||||||
time.sleep(POLL_INTERVAL_S)
|
time.sleep(POLL_INTERVAL_S)
|
||||||
|
|
||||||
|
|||||||
@@ -450,3 +450,211 @@ def test_panel_sleep_sends_no_commands_when_already_asleep(monkeypatch):
|
|||||||
panel.sleep() # bereits asleep - darf keine weiteren Kommandos senden
|
panel.sleep() # bereits asleep - darf keine weiteren Kommandos senden
|
||||||
|
|
||||||
assert recorder.commands == []
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user