fix(display): Freigabe-Ziel lesbar, Tests schriftunabhaengig

Sechs Review-Befunde an e2d836a/2aab1b0.

C1: die Pixelschwellen in test_render.py waren gegen Pillow 12/Arial
kalibriert und auf dem Pi wie auf Debian 13 (DejaVu) rot - "> 150
schwarze Pixel in Zeile 11" sind dort nur 143. Die Zusagen ueber den
Kopfbalken sind jetzt Anteile und Symmetrien statt Pixelzahlen
(mindestens 55 % des Balkens schwarz, weisse Schrift links UND rechts
der Mitte, Balken ueber die volle Breite). Zwei weitere schriftabhaengige
Schwellen ebenso: die grosse Zeile hebt sich ueber das VERHAELTNIS der
Tintenhoehen vom Beiwerk ab, und die Randgrenze zaehlt Zeilenbaender
statt Tinten-Cluster.

I2: im Zustand "armed" stand "FREIGEGEBEN: <ziel>" in der Kopfzeile,
wo 112 px Platz sind und der Text 182 px misst - gezeichnet wurde
"FREIGEGEBE…", bei jedem Ziel gleich. Im Kopf steht jetzt nur noch das
Label, der Zielname als eigene Beiwerkzeile ueber die volle Breite; die
SSID-Zeile gibt dafuer in "armed" ihren Platz ab. Geprueft wird das
gezeichnete Bild (Pixelvergleich gegen eine Referenzzeile), nicht
lines().

I3: der Kommentar an display_handover() begruendete die
Rueckgabewert-Durchreichung damit, dass der Handgriff bei stummem Panel
rot werde. Das stimmt nicht - display.py beendet sich bei fehlendem
Panel/SPI/Pillow mit 0. Der Kommentar sagt jetzt, dass man die Revision
am Panel beurteilt und nicht am Exit-Code.

I4: zwischen "systemctl stop" und "systemctl start" gab es keinen
Signalhandler - ein Strg-C waehrend "display test" (der dokumentierte
Weg, die Revision zu bestimmen, bei falscher Revision haengt das Panel)
liess den Anzeigedienst gestoppt zurueck. trap auf INT/TERM/HUP, das
Wiederanfahren in display_service_resume(), Handler beenden selbst mit
128 + Signalnummer.

M2: HEADER_PHASE_MIN_WIDTH_PX bleibt als Sicherheitsnetz, ist jetzt aber
getestet statt toter Code.

M4: compose() baute je Aufruf fuenf Schriften neu. Prozess-Cache (mit
ImageFont-Modul und FONT_SEARCH_PATHS im Schluessel, damit die Tests
nicht verfaelscht werden) und faules Laden je Rolle: 25,0 ms -> 7,0 ms.

Die Pixeltests laufen zusaetzlich unter Pillow 11.1 mit echtem DejaVu
und unter fuenf weiteren Schriften. Sieben Mutationsproben angewandt,
angesehen und zurueckgenommen. tools/test_host.sh gruen.
This commit is contained in:
Michael Sauer
2026-09-14 15:12:45 +02:00
parent e77d3da292
commit f105a0eb69
4 changed files with 516 additions and 49 deletions
+297 -23
View File
@@ -148,25 +148,94 @@ def test_off_screen_is_drawn_big_and_centered():
assert abs(top - bottom) <= 4, f"nicht senkrecht mittig (oben {top}, unten {bottom})"
def test_header_is_a_black_bar_with_white_text():
"""Die Kopfzeile ist invertiert: schwarzer Balken ueber die volle Breite, Modus in
Weiss darin. Geprueft an einer Zeile mitten im Balken - die muss ueberwiegend schwarz
sein UND weisse Pixel enthalten (sonst stuende dort kein Text, sondern nur ein
Balken)."""
image = render.compose(_migration_state())
pixels = image.load()
row = render.HEADER_BAR_HEIGHT_PX // 2
HEADER_MIN_BLACK_SHARE = 0.55
black = sum(1 for x in range(render.PANEL_WIDTH_PX) if pixels[x, row] == 0)
white = sum(1 for x in range(render.PANEL_WIDTH_PX) if pixels[x, row] != 0)
assert black > 150, f"Kopfzeile ist kein schwarzer Balken (nur {black} schwarze Pixel)"
assert white >= 5, "keine weisse Schrift im Balken - der Balken uebermalt den Modus"
def _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. Gemessen: 15 px Tinte
fuer die grosse Zeile gegen 10 px fuer die erste Beiwerkzeile."""
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
@@ -177,9 +246,10 @@ def test_hero_line_is_bigger_than_the_details():
hero_height = hero[3] - hero[1] + 1
detail_height = detail[3] - detail[1] + 1
assert hero_height >= detail_height + 3, (
assert hero_height >= detail_height * 1.2, (
f"grosse Zeile ({hero_height} px) hebt sich nicht vom Beiwerk "
f"({detail_height} px) ab")
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():
@@ -239,12 +309,22 @@ def test_compose_stops_drawing_details_before_the_bottom_margin(monkeypatch):
image = render.compose({"mode": "migration"})
pixels = image.load()
rows_with_ink = [
any(pixels[x, y] == 0 for x in range(render.PANEL_WIDTH_PX))
for y in range(render.HEADER_BAR_HEIGHT_PX, render.PANEL_HEIGHT_PX)]
# 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, has_ink in enumerate(rows_with_ink)
if has_ink and not (i > 0 and rows_with_ink[i - 1]))
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 - "
@@ -350,6 +430,115 @@ def test_long_armed_target_does_not_overlap_the_mode_in_the_header():
)
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()
@@ -451,10 +640,95 @@ def test_missing_fields_do_not_raise():
assert render.lines(state)
def test_armed_phase_is_visible():
# --- "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"})
assert "FREIGEGEBEN" in "\n".join(lines)
text = "\n".join(lines)
assert render.ARMED_LABEL in text
assert "g95_cw" in text
# --- display.build_state() (Fixrunde 1, I3) ---------------------------------------