fix(display): Phase im Kopfbalken, Rand unten, Wants-Fehler behoben
Drei Befunde der Hardware-Abnahme: die Migrationsphase stand als eigene kleine Zeile unter dem Lampennamen und ging dort unter - sie steht jetzt rechtsbuendig neben dem Modus in der invertierten Kopfzeile (mit Kuerzung/kleinerer Schrift/Weglassen als gestaffelte Antwort auf eine zu lange Phase, nie mit Ueberlappung). Die letzte Beiwerkzeile klebte am unteren Panelrand - durch das Verschieben der Phase in den Kopf bleiben nur noch fuenf statt sechs Beiwerkzeilen, zusaetzlich durchgesetzt per BOTTOM_MARGIN_PX in der Abbruchbedingung von compose(). Ausserdem ein am Geraet nachgewiesener Fund: ninlight-display.service trug Wants=ninlight-station.service NEBEN dem gewollten After= - Wants= aktiviert die genannte Unit aber, statt nur zu ordnen, und zog den absichtlich deaktivierten Stationsdienst bei jedem Boot ohne Zugangspunkt in einen "failed"-Zustand. Wants= entfernt, After= bleibt.
This commit is contained in:
@@ -183,21 +183,173 @@ def test_hero_line_is_bigger_than_the_details():
|
||||
|
||||
|
||||
def test_the_last_delivery_line_still_fits_on_the_panel():
|
||||
"""Der vollste Zustand (Kopfbalken, grosse Zeile, sechs 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."""
|
||||
"""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 + 5 * render.DETAIL_LINE_HEIGHT_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()
|
||||
|
||||
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)]
|
||||
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]))
|
||||
|
||||
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_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()
|
||||
|
||||
Reference in New Issue
Block a user