Am Geraet kam die Zeile gekuerzt an ('... (ohne WLA...'), und mit dem Zusatz
' - SATZ FEHLT' war die Warnung in beiden Richtungen ausserhalb der Panelbreite -
also genau die Zeile, die sagt, dass die Station nichts ausliefern kann.
Unbemerkt blieb das, weil _load_font() auf dem Pi DejaVu nimmt und auf dem Mac bis
Arial durchfaellt: dieselbe Zeile misst dort 246,0 von 246 px, auf dem Pi 283,5 px.
Der neue Test misst deshalb mit 25 % Reserve gegen eine breitere Schrift.
Die Fehlt-Warnung ersetzt die Zeile jetzt, statt sie zu verlaengern.
2113 lines
96 KiB
Python
2113 lines
96 KiB
Python
import json
|
|
import sys
|
|
import time
|
|
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_migration_shows_where_the_dev_classification_came_from():
|
|
"""Der Hardwarebefund vom 2026-09-15: die Station lieferte ein falsch signiertes Image
|
|
aus, ohne dass am Panel etwas darauf hindeutete. Steht die Quelle der Einstufung
|
|
daneben, sieht der Bediener VOR der Freigabe, worauf sie beruht."""
|
|
def variant_line(last_seen):
|
|
state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [],
|
|
"ssid": "qconnex-firmware", "last_seen": last_seen}
|
|
return [l for l in render.lines(state) if l.startswith("g95_cw (")][0]
|
|
|
|
base = {"product_name": "Klyqa G95 Vintage CW/WW", "target": "g95_cw",
|
|
"lamp_version": "V1.1.1.2357-dev"}
|
|
assert variant_line(dict(base, variant="dev", variant_source="version")) == "g95_cw (dev/Ver)"
|
|
assert variant_line(dict(base, variant="dev", variant_source="product_id")) == "g95_cw (dev/ID)"
|
|
assert variant_line(dict(base, variant="dev", variant_source="both")) == "g95_cw (dev/ID+Ver)"
|
|
# prod hat keine Quelle - da gibt es nirgends ein "-dev", das man anzeigen koennte.
|
|
assert variant_line(dict(base, variant="prod", variant_source="none")) == "g95_cw (prod)"
|
|
# Eine aeltere Serverfassung ohne das Feld bleibt darstellbar.
|
|
assert variant_line(dict(base, variant="dev")) == "g95_cw (dev)"
|
|
|
|
|
|
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})"
|
|
|
|
|
|
HEADER_MIN_BLACK_SHARE = 0.55
|
|
|
|
|
|
def _assert_header_bar_with_text_on_both_sides(image, hint=""):
|
|
"""Die Zusage ueber den Kopfbalken, SCHRIFTUNABHAENGIG formuliert (dritte
|
|
Hardware-Abnahme, Befund C1).
|
|
|
|
Vorher stand hier eine absolute Pixelzahl ("> 150 schwarze Pixel in Zeile 11"), gegen
|
|
Pillow 12 mit ARIAL auf der Entwicklungsmaschine kalibriert. Auf dem Pi und auf der
|
|
Debian-13-Maschine zeichnet DejaVu, das merklich breiter laeuft - dort blieben 149
|
|
schwarze Pixel uebrig und die Suite war rot, ohne dass sich am Code etwas geaendert
|
|
hatte. Die Schwelle einfach auf 145 nachzuziehen haette nur gegen die naechste
|
|
Schrift kalibriert.
|
|
|
|
Stattdessen drei Zusagen, die keine Schrift kennen:
|
|
* ANTEIL statt Anzahl, und ueber den GANZEN Balken statt ueber eine einzelne Zeile:
|
|
mindestens 55 % des Balkens sind schwarz. Ueber sechs sehr verschiedene Schriften
|
|
gemessen (Arial, Arial Black, Verdana, Courier New, Impact, Georgia) liegt der
|
|
Anteil zwischen 69 % und 93 %, mit load_default() bei 93 % - die Schwelle trennt
|
|
also "Balken mit Schrift darin" von "kein Balken" (ohne Rechteck: rund 5 %), ohne
|
|
an einer Schriftbreite zu haengen. Eine einzelne Zeile allein taugt dafuer nicht:
|
|
dieselben sechs Schriften streuen in Zeile 11 zwischen 43 % und 69 %.
|
|
* Weisse Schrift LINKS UND RECHTS der Panelmitte: der Modus steht links, die Phase
|
|
rechts. Faellt eine von beiden aus (oder uebermalt der Balken den Text), ist die
|
|
betreffende Haelfte leer.
|
|
* Der Balken reicht ueber die volle Breite: die aeussersten Spalten sind
|
|
durchgehend schwarz - sie liegen vor TEXT_MARGIN_PX, dort kann keine Schrift
|
|
stehen."""
|
|
pixels = image.load()
|
|
height = render.HEADER_BAR_HEIGHT_PX
|
|
width = render.PANEL_WIDTH_PX
|
|
middle = width // 2
|
|
|
|
black = sum(1 for y in range(height) for x in range(width) if pixels[x, y] == 0)
|
|
share = black / float(height * width)
|
|
assert share >= HEADER_MIN_BLACK_SHARE, (
|
|
f"Kopfzeile ist kein schwarzer Balken (nur {share:.0%} schwarz){hint}")
|
|
|
|
white_left = sum(1 for y in range(height) for x in range(middle) if pixels[x, y] != 0)
|
|
white_right = sum(1 for y in range(height) for x in range(middle, width)
|
|
if pixels[x, y] != 0)
|
|
assert white_left >= 5, f"kein Modus-Text links im Balken{hint}"
|
|
assert white_right >= 5, f"keine Phase rechts im Balken{hint}"
|
|
|
|
for x in (0, width - 1):
|
|
assert all(pixels[x, y] == 0 for y in range(height)), (
|
|
f"Spalte {x} des Kopfbalkens ist nicht durchgehend schwarz{hint}")
|
|
|
|
|
|
def test_header_is_a_black_bar_with_white_text():
|
|
"""Die Kopfzeile ist invertiert: schwarzer Balken ueber die volle Breite, Modus links
|
|
und Phase rechts in Weiss darin."""
|
|
_assert_header_bar_with_text_on_both_sides(render.compose(_migration_state()))
|
|
|
|
|
|
def test_header_promise_survives_a_noticeably_wider_font(monkeypatch):
|
|
"""Gegenprobe zu C1: dieselbe Zusage muss auch dann halten, wenn die Schrift deutlich
|
|
mehr Platz braucht als die hier gefundene.
|
|
|
|
Statt eine zweite Schriftdatei vorauszusetzen (DejaVu gibt es auf der macOS-
|
|
Entwicklungsmaschine nicht, Arial nicht auf dem Pi - ein Test, der die eine oder die
|
|
andere braucht, waere auf je einer der beiden Maschinen nur ein Skip) wird dieselbe
|
|
Schrift 25 % groesser gezogen: das ist genau der Effekt, den eine breitere Schrift auf
|
|
diese Zusage hat, und es laeuft auf jeder Maschine gleich. Der Abstand DejaVu-zu-Arial
|
|
liegt bei rund 13 % Breite ("MIGRATION": 128 px gegen 113 px), hier also mit Puffer
|
|
ueberschritten. Dass die Phase dabei gekuerzt wird, ist erlaubt - sie darf nur nicht
|
|
verschwinden oder den Modus ueberlaufen."""
|
|
monkeypatch.setattr(render, "HEADER_FONT_PX", int(render.HEADER_FONT_PX * 1.25))
|
|
monkeypatch.setattr(render, "HEADER_PHASE_FONT_PX",
|
|
int(render.HEADER_PHASE_FONT_PX * 1.25))
|
|
|
|
_assert_header_bar_with_text_on_both_sides(
|
|
render.compose(_migration_state()), hint=" (mit 25 % breiterer Schrift)")
|
|
|
|
|
|
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.
|
|
|
|
Als VERHAELTNIS der Tintenhoehen, nicht als absoluter Pixelabstand (dritte
|
|
Hardware-Abnahme, C1: auch die uebrigen Schwellen dieser Datei schriftunabhaengig
|
|
machen). Die Zusage kommt aus dem Verhaeltnis der Schriftgroessen -
|
|
HERO_FONT_PX/DETAIL_FONT_PX = 16/11 = 1,45 - und das ueberlebt einen
|
|
Schriftwechsel, ein "mindestens 3 px mehr" nicht: ueber sieben Schriften gemessen
|
|
liegt das Verhaeltnis zwischen 1,30 (Courier New) und 1,60 (Verdana), der absolute
|
|
Abstand dagegen zwischen 3 px und 6 px - mit 3 px waere Courier New genau auf der
|
|
Kante. Mit load_default() (beide Rollen rund 8 px) liegt das Verhaeltnis bei 1,0,
|
|
der Test also weiterhin rot, wenn die Schriftsuche ausfaellt."""
|
|
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 * 1.2, (
|
|
f"grosse Zeile ({hero_height} px) hebt sich nicht vom Beiwerk "
|
|
f"({detail_height} px) ab - Verhaeltnis "
|
|
f"{hero_height / float(detail_height):.2f}, erwartet mindestens 1,20")
|
|
|
|
|
|
def test_the_last_delivery_line_still_fits_on_the_panel():
|
|
"""Der vollste Zustand (Kopfbalken mit Modus+Phase, grosse Zeile, fuenf
|
|
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. Die Phase steckt seit der
|
|
zweiten Hardware-Abnahme im Kopfbalken (ROLE_HEADER_PHASE) statt als eigene
|
|
Beiwerkzeile - deshalb acht Bloecke insgesamt, aber nur noch fuenf Beiwerkzeilen
|
|
vor der letzten."""
|
|
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 + 4 * 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_bottom_margin_stays_free_of_ink_even_in_the_fullest_state():
|
|
"""Zweite Hardware-Abnahme, Befund 2: der Rahmen des echten Panels frisst den
|
|
unteren Rand, der volleste Zustand wirkte dort gedraengt. Geprueft an echten
|
|
gezeichneten Pixeln (nicht an nachgerechneten Koordinaten): die letzten
|
|
BOTTOM_MARGIN_PX Zeilen duerfen im volleren Zustand keine einzige schwarze Tinte
|
|
enthalten, sonst waere der Abstand nur auf dem Papier vorhanden."""
|
|
image = render.compose(_migration_state())
|
|
pixels = image.load()
|
|
margin_top = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
|
|
|
|
ink_in_margin = any(
|
|
pixels[x, y] == 0
|
|
for y in range(margin_top, render.PANEL_HEIGHT_PX)
|
|
for x in range(render.PANEL_WIDTH_PX))
|
|
assert not ink_in_margin, (
|
|
f"Text reicht bis in die letzten {render.BOTTOM_MARGIN_PX}px - kein sichtbarer "
|
|
"Rand mehr")
|
|
|
|
|
|
def test_compose_stops_drawing_details_before_the_bottom_margin(monkeypatch):
|
|
"""Direkter Test der Abbruchbedingung in compose(): der heutige volleste Zustand
|
|
(fuenf Beiwerkzeilen) erreicht die 122 px ohnehin nicht mehr (s.o.) - eine einzelne
|
|
Zeile weniger oder mehr an der Faltkante der Abbruchbedingung ist dort nur 1-2px
|
|
Tinte, kein verlässlicher Ausschlag. Dieser Test ersetzt blocks() deshalb durch eine
|
|
synthetische Liste mit zehn Beiwerkzeilen - deutlich mehr, als aufs Panel passen -
|
|
und ZAEHLT die tatsaechlich gezeichneten Zeilen (als getrennte Tinten-Cluster
|
|
unterhalb des Kopfbalkens), statt nur zu prüfen, ob irgendein Pixel in den letzten
|
|
Zeilen liegt: mit der Abbruchbedingung "> PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX" werden
|
|
hier sechs Zeilen gezeichnet, ohne den reservierten Rand ("> PANEL_HEIGHT_PX" allein,
|
|
die Mutation) waeren es sieben - am Geraet nachgemessen (siehe Mutationsprobe im
|
|
Abnahmebericht)."""
|
|
synthetic = [("MIGRATION", render.ROLE_HEADER)] + [
|
|
(f"Zeile {i}", render.ROLE_DETAIL) for i in range(10)]
|
|
monkeypatch.setattr(render, "blocks", lambda state: synthetic)
|
|
|
|
image = render.compose({"mode": "migration"})
|
|
pixels = image.load()
|
|
|
|
# Je Zeile IHR eigenes Band gezaehlt, nicht zusammenhaengende Tinten-Cluster
|
|
# (dritte Hardware-Abnahme, C1: "auch anderswo schriftabhaengige Schwellen suchen"):
|
|
# die Clusterzaehlung setzte stillschweigend voraus, dass zwischen zwei Zeilen eine
|
|
# komplett tintenfreie Pixelzeile bleibt. Das ist eine Aussage ueber die
|
|
# Schrifthoehe bei DETAIL_LINE_HEIGHT_PX Zeilenabstand - mit einer hoeher bauenden
|
|
# Schrift waeren zwei Zeilen zu einem Cluster verschmolzen und der Test rot, ohne
|
|
# dass am Code etwas falsch waere. Bandweise gezaehlt ist es egal, ob sich die
|
|
# Zeilen beruehren.
|
|
first_top = render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX
|
|
bands = range((render.PANEL_HEIGHT_PX - first_top) // render.DETAIL_LINE_HEIGHT_PX)
|
|
drawn_lines = sum(
|
|
1 for i in bands
|
|
if any(pixels[x, y] == 0
|
|
for y in range(first_top + i * render.DETAIL_LINE_HEIGHT_PX,
|
|
first_top + (i + 1) * render.DETAIL_LINE_HEIGHT_PX)
|
|
for x in range(render.PANEL_WIDTH_PX)))
|
|
|
|
assert drawn_lines == 6, (
|
|
f"{drawn_lines} Beiwerkzeilen gezeichnet, sechs erwartet - "
|
|
"BOTTOM_MARGIN_PX wird in der Abbruchbedingung nicht mehr beruecksichtigt")
|
|
|
|
margin_top = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
|
|
ink_in_margin = any(
|
|
pixels[x, y] == 0
|
|
for y in range(margin_top, render.PANEL_HEIGHT_PX)
|
|
for x in range(render.PANEL_WIDTH_PX))
|
|
assert not ink_in_margin, (
|
|
"eine ueberlaufende Beiwerkzeile wurde trotzdem in den reservierten unteren "
|
|
"Rand gezeichnet")
|
|
|
|
|
|
# --- Kopfzeile: Phase neben dem Modus (zweite Hardware-Abnahme, Befund 1) -----------
|
|
#
|
|
# Frueher stand die Phase als eigene kleine Beiwerkzeile unter dem Lampennamen und ging
|
|
# dort unter. Jetzt steht sie rechtsbuendig im selben schwarzen Balken wie der Modus,
|
|
# der dafuer nach links ruecken darf. Rettung/aus kennen keine Phase und behalten die
|
|
# alte, zentrierte Kopfzeile.
|
|
|
|
def _header_white_columns(image):
|
|
"""Spalten (x) im Kopfbalken, die irgendwo eine weisse (Text-)Zelle enthalten."""
|
|
pixels = image.load()
|
|
return [x for x in range(render.PANEL_WIDTH_PX)
|
|
if any(pixels[x, y] == 255 for y in range(render.HEADER_BAR_HEIGHT_PX))]
|
|
|
|
|
|
def test_phase_appears_right_aligned_next_to_the_mode_in_the_header():
|
|
"""Grundfall: Modus links, Phase rechts, beide im selben Balken sichtbar - nicht mehr
|
|
als eigene Zeile darunter."""
|
|
state = _migration_state()
|
|
state["phase"] = "auto"
|
|
del state["armed_target"]
|
|
lines_out = render.lines(state)
|
|
assert lines_out[0] == "MIGRATION"
|
|
assert lines_out[1] == "AUTO"
|
|
|
|
image = render.compose(state)
|
|
white_cols = _header_white_columns(image)
|
|
assert white_cols, "Kopfzeile zeigt keinen Text"
|
|
# Modus (links) und Phase (rechts): die Schrift muss deutlich in der linken UND in
|
|
# der rechten Panelhaelfte stehen - stuende alles zentriert (die alte Darstellung),
|
|
# waere die rechte Haelfte praktisch leer.
|
|
left_half = [x for x in white_cols if x < render.PANEL_WIDTH_PX // 2]
|
|
right_half = [x for x in white_cols if x >= render.PANEL_WIDTH_PX // 2]
|
|
assert left_half, "kein Modus-Text in der linken Kopfzeilenhaelfte"
|
|
assert right_half, "keine Phase in der rechten Kopfzeilenhaelfte"
|
|
# Und die Phase muss tatsaechlich an den rechten Rand reichen (rechtsbuendig).
|
|
assert max(white_cols) >= render.PANEL_WIDTH_PX - render.TEXT_MARGIN_PX - 10
|
|
|
|
|
|
def test_recovery_header_stays_centered_without_a_phase():
|
|
"""Rettung kennt keine Phase - die Kopfzeile bleibt wie vor der zweiten
|
|
Hardware-Abnahme: der Modus steht waagerecht mittig, nicht links."""
|
|
image = render.compose({"mode": "recovery", "ssid": "ninlight-recovery",
|
|
"served": 0, "recent": []})
|
|
white_cols = _header_white_columns(image)
|
|
assert white_cols
|
|
left_gap = white_cols[0]
|
|
right_gap = render.PANEL_WIDTH_PX - 1 - white_cols[-1]
|
|
assert abs(left_gap - right_gap) <= 3, (
|
|
f"Modus nicht mehr mittig (links {left_gap}, rechts {right_gap}) - "
|
|
"Rettung darf die Kopfzeile nicht umbauen")
|
|
|
|
|
|
def test_long_armed_target_does_not_overlap_the_mode_in_the_header():
|
|
"""Mutationswache fuer Befund 1: eine sehr lange Phasenbezeichnung ("FREIGEGEBEN:
|
|
<langer Zielname>") darf den Modus links nicht ueberdecken. Geprueft an echten
|
|
Pixeln, nicht an Koordinaten: zwischen dem Ende des Modus-Texts und dem Anfang der
|
|
Phase muss eine durchgehend schwarze Luecke bleiben, die deutlich breiter ist als
|
|
eine normale Buchstabenluecke innerhalb eines Worts (die bei einer echten
|
|
Ueberlappung/einem fehlenden Kuerzungs-Clamp verschwinden wuerde, weil die Phase
|
|
dann weit nach links unter/ueber den Modus gezeichnet wird)."""
|
|
state = _migration_state()
|
|
state["phase"] = "armed"
|
|
state["armed_target"] = "x" * 80
|
|
|
|
image = render.compose(state)
|
|
pixels = image.load()
|
|
header_rows = range(render.HEADER_BAR_HEIGHT_PX)
|
|
white_cols = [x for x in range(render.PANEL_WIDTH_PX)
|
|
if any(pixels[x, y] == 255 for y in header_rows)]
|
|
assert white_cols
|
|
|
|
def is_black_column(x):
|
|
return all(pixels[x, y] != 255 for y in header_rows)
|
|
|
|
first, last = white_cols[0], white_cols[-1]
|
|
best_gap = 0
|
|
run = 0
|
|
for x in range(first, last + 1):
|
|
if is_black_column(x):
|
|
run += 1
|
|
best_gap = max(best_gap, run)
|
|
else:
|
|
run = 0
|
|
assert best_gap >= render.HEADER_PHASE_GAP_PX - 1, (
|
|
f"kein durchgehender Zwischenraum von mindestens "
|
|
f"{render.HEADER_PHASE_GAP_PX - 1}px zwischen Modus und Phase gefunden "
|
|
f"(groesste Luecke: {best_gap}px) - Modus und Phase ueberlappen sich"
|
|
)
|
|
|
|
|
|
def test_header_phase_is_dropped_when_too_little_room_is_left(monkeypatch):
|
|
"""Deckt den "Weglassen"-Zweig ab (dritte Hardware-Abnahme, Befund M2): bleibt neben
|
|
dem Modus weniger als HEADER_PHASE_MIN_WIDTH_PX uebrig, wird die Phase ganz
|
|
weggelassen statt ein einsames, rechtsbuendiges "…" zu zeichnen, das sich mit dem
|
|
Modus ueberlagert.
|
|
|
|
Mit den heutigen Werten kann der Zweig nicht von selbst feuern - der Modus-Text im
|
|
Migrationsmodus ist die Konstante "MIGRATION", available_px also fest (112 px DejaVu,
|
|
127 px Arial). Hier wird deshalb der Mindestabstand hochgedreht: das ist der
|
|
direkteste, von jeder Schrift unabhaengige Weg, available_px unter die Schwelle zu
|
|
druecken, und genau das ist die Bedingung, um die es geht."""
|
|
monkeypatch.setattr(render, "HEADER_PHASE_GAP_PX", 200)
|
|
|
|
image = render.compose(_migration_state())
|
|
pixels = image.load()
|
|
header_rows = range(render.HEADER_BAR_HEIGHT_PX)
|
|
white_cols = [x for x in range(render.PANEL_WIDTH_PX)
|
|
if any(pixels[x, y] == 255 for y in header_rows)]
|
|
|
|
assert white_cols, "der Modus fehlt jetzt auch"
|
|
# Vorbedingung: der Modus selbst reicht nicht in den geprueften rechten Streifen -
|
|
# sonst wuerde der Test etwas anderes messen als "die Phase fehlt".
|
|
reserved_from = render.PANEL_WIDTH_PX - render.HEADER_PHASE_MIN_WIDTH_PX
|
|
assert max(white_cols) < reserved_from - 4, (
|
|
f"der Modus-Text reicht bis Spalte {max(white_cols)} - der Test kann so nicht "
|
|
"zwischen Modus und Phase unterscheiden")
|
|
assert not [x for x in white_cols if x >= reserved_from], (
|
|
"die Phase wurde trotz fehlenden Platzes gezeichnet - rechtsbuendig, also "
|
|
"quer ueber den Modus")
|
|
|
|
|
|
# --- Schriftladen: Prozess-Cache und faules Laden (Befund M4) ------------------------
|
|
#
|
|
# compose() baute je Aufruf fuenf Schriften neu: auf der Entwicklungsmaschine 21,2 ms von
|
|
# 25,0 ms, auf einem ARMv6-Kern grob 0,5-1 s - bei JEDER ausgelieferten Lampe, auf
|
|
# demselben Kern wie server.py.
|
|
|
|
def test_font_cache_reuses_the_same_font_object():
|
|
"""Zweimal dieselbe Rolle -> die Schriftdatei wird nur einmal gelesen."""
|
|
image_font = _FakeImageFontModule(available=(DEJAVU_REGULAR, DEJAVU_BOLD))
|
|
|
|
first = render._load_font(image_font, 11)
|
|
second = render._load_font(image_font, 11)
|
|
|
|
assert first is second
|
|
assert len(image_font.truetype_calls) == 1, (
|
|
f"Schrift mehrfach geladen: {image_font.truetype_calls}")
|
|
|
|
|
|
def test_font_cache_keeps_different_modules_and_search_paths_apart(monkeypatch):
|
|
"""Der Cache-Schluessel muss das hereingereichte ImageFont-Modul UND
|
|
FONT_SEARCH_PATHS enthalten - beides wird in den Tests ersetzt. Ein Cache, der nur
|
|
Groesse/Fettung kennt, wuerde z.B. test_compose_still_works_without_any_font_file
|
|
(leere Suchkette) eine vorher geladene echte Schrift unterschieben und die Zusage
|
|
"eine fehlende Schrift haelt nichts auf" stillschweigend entwerten."""
|
|
first_module = _FakeImageFontModule(available=(DEJAVU_REGULAR,))
|
|
second_module = _FakeImageFontModule(available=(DEJAVU_REGULAR,))
|
|
|
|
render._load_font(first_module, 11)
|
|
render._load_font(second_module, 11)
|
|
assert len(second_module.truetype_calls) == 1, "Cache reicht Modulgrenzen hinueber"
|
|
|
|
monkeypatch.setattr(render, "FONT_SEARCH_PATHS", ())
|
|
assert render._load_font(first_module, 11) == ("default",), (
|
|
"leere Suchkette liefert eine zwischengespeicherte Schrift von vorher")
|
|
|
|
|
|
def test_compose_loads_only_the_roles_it_actually_draws(monkeypatch):
|
|
""""Station aus" zeichnet genau eine Rolle - die anderen vier Schriften duerfen gar
|
|
nicht erst gebaut werden (faules Laden, Befund M4)."""
|
|
real_load_font = render._load_font
|
|
requested = []
|
|
|
|
def spy(image_font, size_px, bold=False):
|
|
requested.append(size_px)
|
|
return real_load_font(image_font, size_px, bold=bold)
|
|
|
|
monkeypatch.setattr(render, "_load_font", spy)
|
|
render.compose({"mode": "off"})
|
|
|
|
assert requested == [render.BIG_FONT_PX], (
|
|
f"compose() hat {len(requested)} Schriften angefordert, gezeichnet wird eine: "
|
|
f"{requested}")
|
|
|
|
|
|
def test_compose_reads_no_font_file_a_second_time(monkeypatch):
|
|
"""Der Prozess-Cache am echten Pillow: der zweite compose()-Aufruf desselben
|
|
Prozesses fasst keine Schriftdatei mehr an. Das ist die Zusage, an der auf dem Pi die
|
|
Zeichenzeit haengt - der Daemon zeichnet bei jeder ausgelieferten Lampe neu."""
|
|
from PIL import ImageFont
|
|
|
|
state = _migration_state()
|
|
render.compose(state) # Cache fuellen
|
|
|
|
truetype_calls = []
|
|
real_truetype = ImageFont.truetype
|
|
|
|
def counting_truetype(*args, **kwargs):
|
|
truetype_calls.append(args)
|
|
return real_truetype(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(ImageFont, "truetype", counting_truetype)
|
|
render.compose(state)
|
|
|
|
assert truetype_calls == [], (
|
|
f"Schriften werden bei jedem compose() neu von der Platte gelesen: "
|
|
f"{truetype_calls}")
|
|
|
|
|
|
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)
|
|
|
|
|
|
# --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) -----
|
|
#
|
|
# Frueher stand "FREIGEGEBEN: <ziel>" als Phase im Kopfbalken. Neben dem Modus bleiben
|
|
# dort 112 px (DejaVu) - der Text misst 182 px, gezeichnet wurde "FREIGEGEBE…", also ein
|
|
# Wortfragment, das bei jedem Ziel gleich aussieht. Der alte Test dazu
|
|
# (test_armed_phase_is_visible) prueft lines(), also den UNGEKUERZTEN Text, und war
|
|
# deshalb gruen, waehrend auf dem Panel nichts davon zu lesen war. Die Tests hier pruefen
|
|
# das GEZEICHNETE BILD.
|
|
|
|
def _armed_state():
|
|
"""Wie _migration_state(), aber mit einem Produktnamen OHNE Unterlaengen: die
|
|
Bildvergleiche unten schneiden die erste Beiwerkzeile zeilengenau aus, und ein 'y'
|
|
oder 'q' der grossen Zeile darueber ragte in diesen Ausschnitt hinein."""
|
|
state = _migration_state()
|
|
state["last_seen"] = dict(state["last_seen"], product_name="KLYQA GU10 RGB")
|
|
return state
|
|
|
|
|
|
def _detail_band(index):
|
|
"""(oben, unten) der `index`-ten Beiwerkzeile unter der grossen Zeile."""
|
|
top = (render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX
|
|
+ render.HERO_LINE_HEIGHT_PX + index * render.DETAIL_LINE_HEIGHT_PX)
|
|
return top, top + render.DETAIL_LINE_HEIGHT_PX
|
|
|
|
|
|
def _band_pixels(image, top, bottom):
|
|
pixels = image.load()
|
|
return [tuple(pixels[x, y] for x in range(render.PANEL_WIDTH_PX))
|
|
for y in range(top, bottom)]
|
|
|
|
|
|
def _reference_detail_line(text, top):
|
|
"""Zeichnet `text` allein, mit derselben Schrift und an derselben Stelle, die
|
|
compose() fuer eine Beiwerkzeile benutzt. Der Vergleich mit dem echten Bild ist damit
|
|
schriftunabhaengig: er sagt "genau dieser Text, ungekuerzt", ohne eine Pixelbreite zu
|
|
behaupten."""
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
image = Image.new("1", (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX), 255)
|
|
draw = ImageDraw.Draw(image)
|
|
draw.text((render.TEXT_MARGIN_PX, top), text,
|
|
font=render._load_font(ImageFont, render.DETAIL_FONT_PX), fill=0)
|
|
return image
|
|
|
|
|
|
def test_armed_target_is_drawn_in_full_as_its_own_line():
|
|
"""Der Zielname muss vollstaendig und lesbar auf dem Panel stehen - als eigene
|
|
Beiwerkzeile ueber die volle Breite, nicht als Rest eines im Kopf abgeschnittenen
|
|
Texts. Geprueft durch Pixelvergleich mit einer unabhaengig gezeichneten Referenzzeile:
|
|
kuerzt compose() den Namen (oder laesst die Zeile ganz weg), stimmt der Ausschnitt
|
|
nicht mehr ueberein."""
|
|
state = _armed_state()
|
|
expected = render.ARMED_DETAIL_PREFIX + state["armed_target"]
|
|
assert expected in render.lines(state), render.lines(state)
|
|
|
|
top, bottom = _detail_band(0)
|
|
drawn = _band_pixels(render.compose(state), top, bottom)
|
|
reference = _band_pixels(_reference_detail_line(expected, top), top, bottom)
|
|
|
|
assert drawn == reference, (
|
|
f"die erste Beiwerkzeile zeigt nicht ungekuerzt {expected!r} - der Bediener "
|
|
"kann nicht sehen, welche Lampe freigegeben ist")
|
|
|
|
|
|
def test_header_does_not_depend_on_the_armed_target_name():
|
|
"""Die Gegenrichtung, und die eigentliche Mutationswache fuer I2: der Kopfbalken darf
|
|
den Zielnamen gar nicht mehr enthalten. Das laesst sich ohne jede Schriftannahme
|
|
pruefen - zwei sehr verschiedene Zielnamen muessen denselben Kopfbalken ergeben.
|
|
Zeichnet ihn jemand wieder in den Kopf, unterscheiden sich die beiden Balken (oder,
|
|
bei fruehem Abschneiden, spaetestens beim langen Namen)."""
|
|
short = _armed_state()
|
|
long_name = _armed_state()
|
|
long_name["armed_target"] = "sehr_langer_zielname_" * 4
|
|
|
|
band = (0, render.HEADER_BAR_HEIGHT_PX)
|
|
assert (_band_pixels(render.compose(short), *band)
|
|
== _band_pixels(render.compose(long_name), *band)), (
|
|
"der Kopfbalken haengt am Zielnamen - dort ist kein Platz dafuer, er wird "
|
|
"abgeschnitten und ist bei jedem Ziel gleich unlesbar")
|
|
|
|
|
|
def test_armed_phase_label_is_visible():
|
|
"""Das kurze Label bleibt (es sagt, DASS etwas freigegeben ist) - nur der Zielname
|
|
steht nicht mehr daneben."""
|
|
lines = render.lines({"mode": "migration", "phase": "armed", "armed_target": "g95_cw",
|
|
"served": 0, "recent": [], "ssid": "qconnex-firmware"})
|
|
text = "\n".join(lines)
|
|
assert render.ARMED_LABEL in text
|
|
assert "g95_cw" in text
|
|
|
|
|
|
# --- Zeitschalter am Panel (Task 7e) ------------------------------------------------
|
|
#
|
|
# Ein Zeitschalter, den man am Panel nicht sieht, ist schlimmer als keiner: man glaubt,
|
|
# die Station laeuft, und sie ist laengst aus. Umgekehrt darf ohne Zeitschalter (der
|
|
# Regelfall - er entsteht nur mit "--for") keine Zeile dafuer erscheinen.
|
|
|
|
def test_autooff_line_appears_only_with_a_running_timer():
|
|
without = render.lines(_migration_state())
|
|
assert not any("Auto-Aus" in line for line in without), without
|
|
|
|
state = dict(_migration_state(), autooff_minutes=7)
|
|
with_timer = render.lines(state)
|
|
assert "Auto-Aus in 7 min" in with_timer, with_timer
|
|
|
|
|
|
def test_autooff_line_also_appears_in_recovery_mode():
|
|
"""Der Zeitschalter haengt nicht am Migrationsmodus - im Rettungsmodus reisst "on"
|
|
die SSH-Sitzung genauso ab."""
|
|
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 0,
|
|
"recent": [], "autooff_minutes": 42})
|
|
assert "Auto-Aus in 42 min" in lines, lines
|
|
|
|
|
|
def test_autooff_line_is_actually_drawn_on_the_panel():
|
|
"""Nicht nur in lines(): die Zeile muss im vollsten Zustand auch tatsaechlich aufs
|
|
Panel kommen und nicht unten herausfallen (compose() bricht Beiwerkzeilen vor dem
|
|
unteren Rand ab). Geprueft per Pixelvergleich gegen eine unabhaengig gezeichnete
|
|
Referenzzeile - schriftunabhaengig und ohne behauptete Pixelbreiten."""
|
|
state = dict(_armed_state(), autooff_minutes=7)
|
|
expected = "Auto-Aus in 7 min"
|
|
# Die wievielte BEIWERKZEILE ist es? (Kopfbalken, Phase im Kopf und grosse Zeile
|
|
# sind eigene Rollen und belegen keine Beiwerkzeile.)
|
|
details = [text for text, role in render.blocks(state) if role == render.ROLE_DETAIL]
|
|
assert expected in details, details
|
|
index = details.index(expected)
|
|
|
|
top, bottom = _detail_band(index)
|
|
drawn = _band_pixels(render.compose(state), top, bottom)
|
|
reference = _band_pixels(_reference_detail_line(expected, top), top, bottom)
|
|
|
|
assert drawn == reference, (
|
|
"die Zeitschalter-Zeile steht nicht (ungekuerzt) auf dem Panel - der Bediener "
|
|
"sieht nicht, dass sich die Station gleich abschaltet")
|
|
|
|
|
|
def test_autooff_line_says_so_when_the_deadline_has_arrived():
|
|
lines = render.lines({"mode": "recovery", "ssid": "x", "served": 0, "recent": [],
|
|
"autooff_minutes": 0})
|
|
assert "Auto-Aus steht an" in lines, lines
|
|
|
|
|
|
def test_build_state_reads_the_autooff_file(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
|
(tmp_path / "mode").write_text("recovery")
|
|
conf_path = tmp_path / "station.conf"
|
|
conf_path.write_text("SSID='ninlight-recovery'\n")
|
|
# Noch 6 min 1 s - aufgerundet 7, wie es "ninlight-station status" auch rechnet.
|
|
(tmp_path / "autooff.json").write_text(json.dumps(
|
|
{"minutes": 30, "deadline": time.time() + 361, "deferrals": 0}))
|
|
|
|
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
|
|
|
|
assert state["autooff_minutes"] == 7
|
|
assert "Auto-Aus in 7 min" in render.lines(state)
|
|
|
|
|
|
def test_build_state_without_autooff_file_has_no_timer_field(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
|
(tmp_path / "mode").write_text("recovery")
|
|
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 "autooff_minutes" not in state
|
|
assert not any("Auto-Aus" in line for line in render.lines(state))
|
|
|
|
|
|
def test_broken_autooff_file_is_treated_as_no_timer(tmp_path):
|
|
"""Dieselbe Zusage wie fuer status.json/control.json: halb geschrieben, kaputt oder
|
|
aus einer aelteren Fassung darf keine Ausnahme werfen - und vor allem keine
|
|
erfundene Restzeit zeigen."""
|
|
(tmp_path / "autooff.json").write_text("{kein json")
|
|
assert display._read_autooff_minutes(tmp_path) is None
|
|
|
|
(tmp_path / "autooff.json").write_text(json.dumps({"minutes": 30}))
|
|
assert display._read_autooff_minutes(tmp_path) is None
|
|
|
|
(tmp_path / "autooff.json").write_text(json.dumps({"deadline": "gleich"}))
|
|
assert display._read_autooff_minutes(tmp_path) is None
|
|
|
|
|
|
def test_autooff_remaining_time_rounds_up(tmp_path):
|
|
(tmp_path / "autooff.json").write_text(json.dumps({"deadline": 1000.0}))
|
|
assert display._read_autooff_minutes(tmp_path, now=1000.0 - 600) == 10
|
|
assert display._read_autooff_minutes(tmp_path, now=1000.0 - 601) == 11
|
|
assert display._read_autooff_minutes(tmp_path, now=1000.0 - 1) == 1
|
|
assert display._read_autooff_minutes(tmp_path, now=1000.0) == 0
|
|
assert display._read_autooff_minutes(tmp_path, now=1000.0 + 90) == 0
|
|
|
|
|
|
def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
|
|
""""off" raeumt die Zustandsdatei ab - aber selbst wenn sie durch einen Absturz
|
|
liegen bliebe, darf neben "Station aus" keine Restzeit stehen."""
|
|
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
|
|
(tmp_path / "autooff.json").write_text(json.dumps(
|
|
{"minutes": 30, "deadline": time.time() + 600, "deferrals": 0}))
|
|
|
|
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
|
|
service_name="x",
|
|
imageset_path=tmp_path / "imageset")
|
|
|
|
assert state["mode"] == "off"
|
|
assert "autooff_minutes" not in state
|
|
assert not any("Auto-Aus" in line for line in render.lines(state))
|
|
|
|
|
|
# --- 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",
|
|
imageset_path=tmp_path / "imageset")
|
|
|
|
# "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im
|
|
# ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll).
|
|
assert state["mode"] == "off"
|
|
assert set(state) == {"mode", "imageset", "imageset_ok"}
|
|
|
|
|
|
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, ui_at=None) -> _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,
|
|
`ui_at(t)` den Bedienzustand, den read_ui() dort liefern wuerde (Vorgabe: keiner -
|
|
dann verhaelt sich die Schleife wie ohne Taster)."""
|
|
clock = _FakeClock(duration_s)
|
|
panel = _RecordingPanel(clock)
|
|
if ui_at is None:
|
|
def ui_at(t):
|
|
return {}
|
|
try:
|
|
display.run_daemon("V3", clock=clock, panel=panel,
|
|
read_state=lambda: state_at(clock.monotonic()),
|
|
read_ui=lambda: ui_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"
|
|
|
|
|
|
# --- Kopplung an den Tasterdienst ----------------------------------------------------
|
|
#
|
|
# Der Tasterdienst (tools/station/button/button.py) fasst den SPI-Bus NICHT an - zwei
|
|
# Prozesse auf denselben GPIO-/SPI-Leitungen ergeben "lgpio.error: 'GPIO busy'". Er
|
|
# schreibt stattdessen seinen Bedienzustand in eine Datei, die hier gelesen und gezeichnet
|
|
# wird. Geprueft wird deshalb beides: dass ein Bedienzustand das Statusbild ersetzt, und
|
|
# dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein.
|
|
|
|
_MENU_UI = {"view": "menu", "title": "MENUE", "hero": "> Station aus",
|
|
"details": ["Anzeige auffrischen", "Herunterfahren"], "active": True}
|
|
|
|
|
|
def test_the_ui_file_lives_outside_the_stations_runtime_directory():
|
|
"""Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim
|
|
regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station aus", in dem
|
|
das Menue "Rettung starten"/"Migration starten" anbietet."""
|
|
assert str(display.UI_FILE) == "/run/ninlight-button/ui.json"
|
|
assert not str(display.UI_FILE).startswith("/run/ninlight-station/")
|
|
|
|
|
|
def test_a_running_operation_replaces_the_status_screen():
|
|
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7,
|
|
"recent": [], "ui": _MENU_UI})
|
|
assert lines == ["MENUE", "> Station aus", "Anzeige auffrischen", "Herunterfahren"]
|
|
assert not any("ninlight-recovery" in l for l in lines)
|
|
|
|
|
|
def test_the_menu_uses_the_same_roles_as_the_status_screen():
|
|
"""Kopfbalken, grosse Zeile, Beiwerk - damit Schriftgroessen, Breitenkuerzung und
|
|
unterer Rand dieselben bleiben wie im Statusbild."""
|
|
roles = [role for _text, role in render.blocks({"mode": "recovery", "ui": _MENU_UI})]
|
|
assert roles[:2] == [render.ROLE_HEADER, render.ROLE_HERO]
|
|
assert set(roles[2:]) <= {render.ROLE_DETAIL}
|
|
|
|
|
|
def test_the_menu_is_actually_drawn_on_the_panel():
|
|
image = render.compose({"mode": "recovery", "ssid": "ninlight-recovery",
|
|
"ui": _MENU_UI})
|
|
reference = render.compose({"mode": "recovery", "ssid": "ninlight-recovery"})
|
|
assert list(image.getdata()) != list(reference.getdata())
|
|
assert _ink_box(image) is not None
|
|
|
|
|
|
def test_a_broken_ui_file_does_not_break_the_display():
|
|
"""Die Datei kommt aus einem fremden Prozess, der abgestuerzt, aelter oder neuer sein
|
|
kann - genau wie status.json darf sie die Anzeige nie zum Absturz bringen."""
|
|
lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17,
|
|
"hero": None, "details": "nope"}})
|
|
assert lines == ["Bedienung"]
|
|
render.compose({"mode": "recovery", "ui": {"view": "menu"}})
|
|
|
|
|
|
def test_read_ui_ignores_a_missing_broken_or_idle_file(tmp_path):
|
|
path = tmp_path / "ui.json"
|
|
assert display.read_ui(path) == {}
|
|
path.write_text("{kein json")
|
|
assert display.read_ui(path) == {}
|
|
path.write_text(json.dumps({"view": "idle", "active": False}))
|
|
assert display.read_ui(path) == {}
|
|
# Auch ein Menue, das sich selbst nicht mehr als laufend meldet, zaehlt nicht - so
|
|
# kann ein stehengebliebener Bedienzustand das Statusbild nicht dauerhaft verdecken.
|
|
path.write_text(json.dumps({"view": "menu", "active": False}))
|
|
assert display.read_ui(path) == {}
|
|
path.write_text(json.dumps(_MENU_UI))
|
|
assert display.read_ui(path)["hero"] == "> Station aus"
|
|
|
|
|
|
def test_the_daemon_shows_an_operation_within_half_a_second():
|
|
"""Solange eine Bedienung laeuft, muss die Anzeige schneller nachsehen als in ihrem
|
|
ueblichen 5-Sekunden-Takt - sonst ist das Menue unbenutzbar."""
|
|
panel = _drive_daemon(lambda t: {"mode": "recovery", "served": 0, "recent": []},
|
|
duration_s=20.0,
|
|
ui_at=lambda t: _MENU_UI if t >= 3.0 else {})
|
|
drawn = [t for t, name in panel.calls if name in ("partial", "full", "wake-full")
|
|
and t >= 3.0]
|
|
assert drawn, "das Menue wurde ueberhaupt nicht gezeichnet"
|
|
assert drawn[0] <= 3.5
|
|
|
|
|
|
def test_a_keypress_costs_the_panel_no_full_refresh():
|
|
"""Alterungsschutz: Vollaufbauten sind teuer, und das Panel soll nicht bei jedem
|
|
Tastendruck einen bekommen. Der Modus bleibt ueber eine Bedienung hinweg derselbe,
|
|
also kommt jeder Bedienschritt als Teilaufbau."""
|
|
views = [dict(_MENU_UI, hero="> Eintrag {}".format(i)) for i in range(4)]
|
|
|
|
def ui_at(t):
|
|
if t < 3.0:
|
|
return {}
|
|
return views[min(int((t - 3.0) // 2), len(views) - 1)]
|
|
|
|
panel = _drive_daemon(lambda t: {"mode": "recovery", "served": 0, "recent": []},
|
|
duration_s=12.0, ui_at=ui_at)
|
|
assert len(_times(panel, "partial")) >= 4
|
|
# Genau einer: der allererste Bildaufbau nach dem Start.
|
|
assert _times(panel, "full") == [0.0]
|
|
|
|
|
|
def test_the_expensive_station_state_is_still_only_read_every_five_seconds():
|
|
"""Der schnelle Takt gilt NUR fuer die kleine Bedienzustandsdatei. build_state()
|
|
forkt fuer "systemctl is-active" - das zehnmal so oft zu tun waere auf einem
|
|
ARMv6-Kern spuerbar und voellig unnoetig."""
|
|
calls = []
|
|
|
|
def state_at(t):
|
|
calls.append(t)
|
|
return {"mode": "recovery", "served": 0, "recent": []}
|
|
|
|
_drive_daemon(state_at, duration_s=10.0)
|
|
assert calls == [0.0, 5.0, 10.0]
|
|
|
|
|
|
# --- Bildsatz-Umschalter in der Fusszeile -------------------------------------------
|
|
#
|
|
# Zusage: welcher Migrations-Bildsatz gilt, steht DAUERHAFT auf dem Panel - nicht nur im
|
|
# Menue. Der Zustand ueberlebt Neustarts und kann von vorletzter Woche stammen; wer ihn
|
|
# erst beim Blaettern im Menue saehe, hat womoeglich schon zwanzig Lampen mit den eigenen
|
|
# WLAN-Zugangsdaten geflasht.
|
|
|
|
def _flat(rows):
|
|
"""_band_pixels() liefert Zeilen-Tupel - hier interessieren die einzelnen Pixel."""
|
|
return [px for row in rows for px in row]
|
|
|
|
|
|
def _imageset_line(state):
|
|
texts = [text for text, role in render.blocks(state) if role == render.ROLE_FOOTER]
|
|
assert len(texts) <= 1, texts
|
|
return texts[0] if texts else None
|
|
|
|
|
|
def test_the_image_set_is_shown_in_every_status_screen():
|
|
for state in ({"mode": "off"},
|
|
{"mode": "recovery", "ssid": "ninlight-recovery"},
|
|
dict(_migration_state()),
|
|
{"mode": None}):
|
|
for name in ("ohne-wlan", "mit-wlan"):
|
|
s = dict(state, imageset=name, imageset_ok=True)
|
|
line = _imageset_line(s)
|
|
assert line == render.IMAGESET_LABELS[name], (state.get("mode"), name)
|
|
|
|
|
|
def test_the_two_image_sets_read_differently():
|
|
"""Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine
|
|
Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos."""
|
|
assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"]
|
|
assert "WLAN" in render.IMAGESET_LABELS["mit-wlan"]
|
|
|
|
|
|
def test_a_missing_image_set_says_so_on_the_panel():
|
|
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
|
|
line = _imageset_line(state)
|
|
assert line is not None and "FEHLT" in line
|
|
|
|
|
|
def test_without_an_image_set_field_nothing_is_claimed():
|
|
"""Ein Zustand aus einer aelteren display.py-Fassung bekommt keine erfundene
|
|
Fusszeile."""
|
|
assert _imageset_line({"mode": "off"}) is None
|
|
|
|
|
|
def test_the_image_set_line_is_actually_drawn_at_the_bottom():
|
|
"""Nicht nur in blocks(): auf dem Panel, unterhalb aller Beiwerkzeilen und ueber dem
|
|
unteren Rand."""
|
|
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=True)
|
|
footer_top = (render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
|
|
- render.FOOTER_BAND_HEIGHT_PX)
|
|
|
|
with_footer = render.compose(state)
|
|
without = render.compose({k: v for k, v in state.items()
|
|
if k not in ("imageset", "imageset_ok")})
|
|
|
|
bottom = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
|
|
band = _band_pixels(with_footer, footer_top, bottom)
|
|
assert 0 in _flat(band), "im Fussband ist gar keine Tinte"
|
|
assert band != _band_pixels(without, footer_top, bottom), \
|
|
"Fussband sieht mit und ohne Bildsatz gleich aus"
|
|
|
|
|
|
def test_the_image_set_line_keeps_the_bottom_margin_free():
|
|
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
|
|
image = render.compose(state)
|
|
margin = _flat(_band_pixels(image, render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX,
|
|
render.PANEL_HEIGHT_PX))
|
|
assert set(margin) <= {255}, "der untere Rand ist nicht mehr frei"
|
|
|
|
|
|
def test_the_image_set_line_survives_the_fullest_state():
|
|
"""Im vollsten Zustand faellt eine Beiwerkzeile heraus - aber nie die Fusszeile:
|
|
sie steht an festem Platz und nicht im Zeilenfluss."""
|
|
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=True,
|
|
autooff_minutes=17)
|
|
assert _imageset_line(state) is not None
|
|
# ... und der Zeilenfluss endet wirklich vor dem Fussband.
|
|
image = render.compose(state)
|
|
footer_top = (render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
|
|
- render.FOOTER_BAND_HEIGHT_PX)
|
|
# Die Zeile direkt ueber dem Trennstrich darf ihn nicht beruehren.
|
|
assert set(_flat(_band_pixels(image, footer_top - 1, footer_top))) <= {255}
|
|
|
|
|
|
def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
|
|
"""Dieselbe Datei, die das CLI schreibt und server.py liest - kein zweiter Weg."""
|
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
|
(tmp_path / "mode").write_text("migration")
|
|
images = tmp_path / "images"
|
|
(images / "migrate-nocreds").mkdir(parents=True)
|
|
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
|
(images / "migrate-wlan").mkdir()
|
|
(images / "migrate-wlan" / "g95_cw_dev.img").write_bytes(b"IMG")
|
|
conf = tmp_path / "station.conf"
|
|
conf.write_text(f"MIGRATE_SSID='qconnex-firmware'\nIMAGE_DIR='{images}'\n")
|
|
switch = tmp_path / "imageset"
|
|
|
|
def state():
|
|
return display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
|
imageset_path=switch)
|
|
|
|
# Ohne Datei: die sichere Vorgabe.
|
|
assert state()["imageset"] == "ohne-wlan"
|
|
switch.write_text("mit-wlan\n")
|
|
assert state()["imageset"] == "mit-wlan"
|
|
# Unbekannter Inhalt darf NIE auf den teureren Satz zeigen.
|
|
switch.write_text("irgendwas")
|
|
assert state()["imageset"] == "ohne-wlan"
|
|
|
|
|
|
def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
|
|
""""Vorhanden" heisst: Verzeichnis da UND mindestens ein Image darin - dieselbe Regel
|
|
wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts."""
|
|
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
|
|
images = tmp_path / "images"
|
|
(images / "migrate-nocreds").mkdir(parents=True)
|
|
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
|
|
conf = tmp_path / "station.conf"
|
|
conf.write_text(f"IMAGE_DIR='{images}'\n")
|
|
switch = tmp_path / "imageset"
|
|
|
|
switch.write_text("ohne-wlan")
|
|
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
|
imageset_path=switch)["imageset_ok"] is True
|
|
switch.write_text("mit-wlan") # migrate-wlan/ gibt es nicht
|
|
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
|
imageset_path=switch)
|
|
assert state["imageset_ok"] is False
|
|
assert "FEHLT" in _imageset_line(state)
|
|
|
|
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
|
|
(images / "migrate-wlan").mkdir()
|
|
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
|
|
imageset_path=switch)["imageset_ok"] is False
|
|
|
|
|
|
def test_the_display_and_the_server_agree_on_the_set_names():
|
|
"""Zwei Prozesse, eine Wahrheit: waeren die Namen oder die Vorgabe verschieden,
|
|
behauptete das Panel etwas anderes, als der Server tut."""
|
|
import importlib.util
|
|
spec = importlib.util.spec_from_file_location(
|
|
"server_for_names", Path(__file__).resolve().parents[1] / "server.py")
|
|
server = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(server)
|
|
assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS
|
|
assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET
|
|
|
|
|
|
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart -----------------------
|
|
#
|
|
# E-Paper ist bistabil: das zuletzt gezeichnete Bild bleibt fuer immer stehen, auch
|
|
# stromlos. Ohne dieses Bild behauptet das Panel nach dem Ausschalten weiter den Zustand
|
|
# von vorher - bei der Abnahme stand dort die Rueckfrage des Tastermenues, und der Pi sah
|
|
# aus, als warte er noch auf eine Eingabe.
|
|
|
|
def _final_text(kind):
|
|
return " ".join(render.lines({"mode": "final", "final": kind}))
|
|
|
|
|
|
def test_the_shutdown_and_the_reboot_screen_differ():
|
|
"""Der wichtigste Unterschied im ganzen Bild: beim Ausschalten DARF man den Stecker
|
|
ziehen, beim Neustart NICHT (an diesem Pi hat ein Stromzug schon eine SD-Karte
|
|
gekostet)."""
|
|
off, reboot = _final_text("poweroff"), _final_text("reboot")
|
|
assert off != reboot
|
|
assert "AUSGESCHALTET" in off and "NEUSTART" in reboot
|
|
# Ausschalten: erlaubt das Trennen ausdruecklich.
|
|
assert "kann getrennt werden" in off
|
|
# Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil.
|
|
assert "NICHT trennen" in reboot
|
|
assert "kann getrennt werden" not in reboot
|
|
|
|
|
|
def test_the_reboot_screen_says_how_long_it_stays():
|
|
"""Ein Zero W braucht gut anderthalb Minuten bis zum Anzeigedienst - ohne diesen Satz
|
|
sieht das stehende Bild nach "haengt" aus, und genau dann greift jemand zum Stecker."""
|
|
assert "min" in _final_text("reboot")
|
|
|
|
|
|
def test_the_final_screen_carries_nothing_else():
|
|
"""Kein Bildsatz, keine Fusszeile, kein Zaehler: die Station ist im Begriff zu
|
|
verschwinden, und die einzige Auskunft, auf die es jetzt ankommt, ist "Stecker?"."""
|
|
state = {"mode": "final", "final": "poweroff", "imageset": "mit-wlan",
|
|
"imageset_ok": True, "served": 7}
|
|
blocks = render.blocks(state)
|
|
assert not any(role == render.ROLE_FOOTER for _, role in blocks)
|
|
assert not any("Bildsatz" in text or "Ausgeliefert" in text for text, _ in blocks)
|
|
|
|
|
|
def test_an_unknown_final_kind_claims_neither():
|
|
"""Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder
|
|
gebootet wird."""
|
|
text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"}))
|
|
assert "AUSGESCHALTET" not in text
|
|
assert "getrennt" not in text
|
|
|
|
|
|
def test_both_final_screens_are_actually_drawable():
|
|
for kind in ("poweroff", "reboot"):
|
|
image = render.compose({"mode": "final", "final": kind})
|
|
assert image.size == (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX)
|
|
assert _ink_box(image) is not None, kind
|
|
|
|
|
|
def test_run_final_draws_fully_and_sleeps(monkeypatch):
|
|
"""Ueber die ECHTE display.run_final(): Vollaufbau (kein Teilaufbau - dieses eine Bild
|
|
ueberdauert alle anderen und darf keine Reste des vorigen tragen) und danach schlafen
|
|
legen, damit das Panel nicht unter Bias in den Ruhezustand geht."""
|
|
calls = []
|
|
|
|
class _Panel:
|
|
def __init__(self, revision):
|
|
calls.append(("init", revision))
|
|
|
|
def clear(self):
|
|
calls.append(("clear", None))
|
|
|
|
def draw_full(self, image):
|
|
calls.append(("full", image))
|
|
|
|
def draw_partial(self, image):
|
|
calls.append(("partial", image))
|
|
return False
|
|
|
|
def sleep(self):
|
|
calls.append(("sleep", None))
|
|
|
|
monkeypatch.setattr(display, "Panel", _Panel)
|
|
display.run_final("V3", "reboot")
|
|
|
|
kinds = [k for k, _ in calls]
|
|
assert kinds == ["init", "clear", "full", "sleep"], kinds
|
|
# ... und gezeichnet wurde wirklich das Neustart-Bild, nicht irgendeines.
|
|
image = dict((k, v) for k, v in calls)["full"]
|
|
expected = render.compose({"mode": "final", "final": "reboot"})
|
|
assert list(image.getdata()) == list(expected.getdata())
|
|
assert list(image.getdata()) != list(
|
|
render.compose({"mode": "final", "final": "poweroff"}).getdata())
|
|
|
|
|
|
def test_display_main_passes_the_final_kind_through(monkeypatch):
|
|
"""Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild
|
|
landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste
|
|
ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart
|
|
"Strom kann getrennt werden"."""
|
|
drawn = []
|
|
|
|
class _Panel:
|
|
def __init__(self, revision):
|
|
pass
|
|
|
|
def clear(self):
|
|
pass
|
|
|
|
def draw_full(self, image):
|
|
drawn.append(image)
|
|
|
|
def sleep(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(display, "Panel", _Panel)
|
|
monkeypatch.setenv("EPD_REVISION", "V3")
|
|
for kind in ("reboot", "poweroff"):
|
|
drawn.clear()
|
|
monkeypatch.setattr(sys, "argv", ["display.py", "--final", kind])
|
|
display.main()
|
|
assert len(drawn) == 1
|
|
expected = render.compose({"mode": "final", "final": kind})
|
|
assert list(drawn[0].getdata()) == list(expected.getdata()), kind
|
|
|
|
|
|
# --- Die Entbehrlichkeitszusage der Anzeige (Abschlussreview, I1) --------------------
|
|
#
|
|
# Zusage aus dem Modul-Docstring, ueber fuenf Fixrunden erkaempft: fehlt Panel, SPI,
|
|
# Pillow oder Schrift, gibt es GENAU EINE Protokollzeile und sys.exit(0). Der Grund ist
|
|
# Restart=on-failure mit RestartSec=5 in ninlight-display.service - ein Exit != 0 macht
|
|
# daraus auf dem Zero W eine Neustartschleife, die das Journal flutet, und der Sinn der
|
|
# Zusage ("ein Fehler in der Anzeige darf niemals eine Migration stoeren") faellt still
|
|
# weg.
|
|
#
|
|
# Das Abschlussreview hat belegt, dass diese Zusage ungetestet war: sys.exit(0) ->
|
|
# sys.exit(1) liess alle 225 Tests gruen. Die vorhandenen Tests pruefen nur, dass die
|
|
# richtigen Ausnahmetypen bis main() DURCHKOMMEN - niemand hat main() selbst in den
|
|
# except-Zweig getrieben und auf den Rueckgabewert gesehen. Beim Tasterdienst ist
|
|
# dasselbe laengst getestet (tests/test_button.py, test_missing_gpiozero_...).
|
|
#
|
|
# Alle vier Modi, weil jeder sein eigenes run_*() hat: --test, --clear, --final und der
|
|
# Daemon. Alle vier bauen ihr Panel selbst, ein Panel(), das schon im Konstruktor wirft,
|
|
# deckt daher jeden der vier Einstiege ab.
|
|
|
|
_DISPLAY_MODES = [
|
|
pytest.param(["display.py", "--test"], id="test"),
|
|
pytest.param(["display.py", "--clear"], id="clear"),
|
|
pytest.param(["display.py", "--final", "poweroff"], id="final"),
|
|
pytest.param(["display.py"], id="daemon"),
|
|
]
|
|
|
|
|
|
def _panel_raising(exc):
|
|
class _Panel:
|
|
def __init__(self, revision):
|
|
raise exc
|
|
return _Panel
|
|
|
|
|
|
@pytest.mark.parametrize("argv", _DISPLAY_MODES)
|
|
def test_missing_panel_logs_exactly_one_line_and_exits_zero(argv, monkeypatch, caplog):
|
|
"""Kein Panel/SPI -> DisplayUnavailable -> eine Zeile, Exit 0 - in JEDEM Modus."""
|
|
monkeypatch.setattr(display, "Panel",
|
|
_panel_raising(epdconfig.DisplayUnavailable("kein SPI-Geraet")))
|
|
monkeypatch.setattr(sys, "argv", argv)
|
|
monkeypatch.setenv("EPD_REVISION", "V3")
|
|
|
|
with caplog.at_level("INFO", logger="ninlight-display"):
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
display.main()
|
|
|
|
assert excinfo.value.code == 0, (
|
|
f"{argv[1:] or ['daemon']}: Exit {excinfo.value.code} statt 0 - "
|
|
"Restart=on-failure macht daraus eine Neustartschleife")
|
|
assert len(caplog.records) == 1, \
|
|
f"erwartet genau eine Protokollzeile, bekommen: {[r.getMessage() for r in caplog.records]}"
|
|
assert "kein SPI-Geraet" in caplog.records[0].getMessage()
|
|
|
|
|
|
@pytest.mark.parametrize("argv", _DISPLAY_MODES)
|
|
def test_missing_pillow_logs_exactly_one_line_and_exits_zero(argv, monkeypatch, caplog):
|
|
"""Der zweite Ast derselben Zusage (Task 6a, C4): Pillow kommt auf dem Ziel-Pi erst
|
|
per "apt install python3-pil" nach, render.compose() importiert es erst beim
|
|
Zeichnen. Ein ModuleNotFoundError (ImportError-Unterklasse) muss deshalb genauso
|
|
enden wie ein fehlendes Panel - und nicht als Absturz."""
|
|
monkeypatch.setattr(display, "Panel",
|
|
_panel_raising(ModuleNotFoundError("No module named 'PIL'")))
|
|
monkeypatch.setattr(sys, "argv", argv)
|
|
monkeypatch.setenv("EPD_REVISION", "V3")
|
|
|
|
with caplog.at_level("INFO", logger="ninlight-display"):
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
display.main()
|
|
|
|
assert excinfo.value.code == 0, (
|
|
f"{argv[1:] or ['daemon']}: Exit {excinfo.value.code} statt 0")
|
|
assert len(caplog.records) == 1, \
|
|
f"erwartet genau eine Protokollzeile, bekommen: {[r.getMessage() for r in caplog.records]}"
|
|
assert "PIL" in caplog.records[0].getMessage()
|
|
|
|
|
|
# --- Fusszeile des Bildsatz-Umschalters (Geraetetermin 2026-09-15) ------------------
|
|
#
|
|
# Am Geraet kam die Zeile gekuerzt an: "Lampe -> Onboarding-AP (ohne WLA...". Ausgerechnet
|
|
# die Klammer, die den Zustand benennt, fiel weg - und mit dem Zusatz " - SATZ FEHLT" war
|
|
# die WARNUNG, dass der gewaehlte Satz gar nicht auf dem Pi liegt, in beiden Richtungen
|
|
# unsichtbar (367,5 px bzw. 306 px bei 246 px Platz).
|
|
#
|
|
# Warum die Testsuite das nie bemerkt hat: _load_font() nimmt den ersten Pfad, der sich
|
|
# oeffnen laesst. Auf dem Pi ist das DejaVu Sans Bold, auf dem Mac des Entwicklers faellt
|
|
# die Kette bis "Arial Bold.ttf" durch - und Arial baut schmaler. Dieselbe Zeile misst
|
|
# dort 246,0 von 246 px (haarscharf drin), auf dem Pi 283,5 px. Das Layout war damit auf
|
|
# die Schrift der ENTWICKLUNGSMASCHINE ausgelegt, nicht auf die des Zielgeraets.
|
|
#
|
|
# Dieser Test misst deshalb mit einer ausdruecklichen Marge: die Zusage lautet nicht "es
|
|
# passt mit der hier zufaellig installierten Schrift", sondern "es passt auch mit einer
|
|
# spuerbar breiteren". 25 % decken den gemessenen Abstand Arial -> DejaVu (+15 %) mit
|
|
# Reserve ab.
|
|
FOOTER_WIDTH_SAFETY = 1.25
|
|
|
|
|
|
def _footer_width_px(text: str) -> float:
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
draw = ImageDraw.Draw(Image.new("1", (render.PANEL_WIDTH_PX,
|
|
render.PANEL_HEIGHT_PX), 255))
|
|
font = render._load_font(ImageFont, render.FOOTER_FONT_PX, bold=True)
|
|
return draw.textlength(text, font=font)
|
|
|
|
|
|
@pytest.mark.parametrize("name", sorted(render.IMAGESET_LABELS))
|
|
@pytest.mark.parametrize("missing", [False, True])
|
|
def test_imageset_footer_fits_the_panel_with_room_for_a_wider_font(name, missing):
|
|
"""Jede Fusszeile des Umschalters - beide Saetze, mit und ohne Fehlt-Warnung - muss
|
|
ungekuerzt auf das Panel passen, auch wenn die Schrift des Zielgeraets breiter baut
|
|
als die hier installierte. Der Fehlt-Fall ist der wichtigere der vier: er sagt, dass
|
|
der GEWAEHLTE Bildsatz nicht auf dem Pi liegt, und genau der war unsichtbar."""
|
|
text, = (t for t, role in render._imageset_blocks(
|
|
{"imageset": name, "imageset_ok": not missing}) if role == render.ROLE_FOOTER)
|
|
max_width_px = render.PANEL_WIDTH_PX - 2 * render.TEXT_MARGIN_PX
|
|
width_px = _footer_width_px(text)
|
|
|
|
assert width_px * FOOTER_WIDTH_SAFETY <= max_width_px, (
|
|
"Fusszeile {!r} misst {:.1f} px; mit {:.0f} %% Reserve fuer eine breitere "
|
|
"Schrift bleiben nur {:.1f} px Platz".format(
|
|
text, width_px, (FOOTER_WIDTH_SAFETY - 1) * 100, max_width_px))
|