Files
grossanzeige/test_display.py
T
admin dae7694a07 Nur geaenderte Zellen schreiben -- gegen das Flackern im Sekundentakt
Seit der Einzelpositionierung blitzten bei jeder Aktualisierung willkuerliche
Pixel auf, in beiden Zeilen. Ursache ist die Telegrammlaenge: Eine volle Zeile
kostet mit einzeln gesetzten Zeichen rund 100 Bytes statt 20, bei 19200 Baud
und 8E1 (11 Bit je Byte) dauerte ein kompletter Bildwechsel damit rund 300 ms.
Die Anzeige stellt schon waehrend des Empfangs dar, also war der Aufbau zu
sehen -- im Sekundentakt ein Drittel der Zeit.

Von einer Sekunde zur naechsten aendert sich aber fast nichts: "18h36m56s" ->
"18h36m57s" ist eine einzige Ziffer, die Deklination steht meist still.
show_lines vergleicht deshalb gegen den zuletzt dargestellten Inhalt und
schreibt nur die betroffenen Zellen; eine unveraenderte Zeile erzeugt gar kein
Telegramm mehr. Am Geraet gemessen: 306 ms -> 66 ms je Bildwechsel.

Dabei am Geraet aufgefallen und mitbehoben: Dem "s" hinter den Sekunden fehlten
die linken Pixel, dem "m" hinter den Minuten ebenso -- beides Zeichen direkt
rechts von einer Stelle, die sich gerade geaendert hatte. Die Anzeige malt je
Zeichen 7 px breit (daher auch der 7-px-Auto-Vorschub), gesetzt wird aber auf
6-px-Raster: Ein neu geschriebenes Zeichen loescht die erste Pixelspalte seines
rechten Nachbarn. Beim vollstaendigen Neuaufbau fiel das nie auf, weil der
Nachbar gleich danach ohnehin neu gemalt wurde. _zelle_faellig zieht ihn jetzt
mit.

Weitere Details:
- clear() verwirft den gemerkten Inhalt, sonst haelt der Vergleich Zellen
  faelschlich fuer vorhanden und die Anzeige bliebe teilweise leer.
- Schlaegt ein Telegramm fehl, wird der gemerkte Inhalt der Zeile verworfen --
  was angekommen ist, ist dann ungewiss.
- Der Zeichensatz wird nur noch bei der ersten Ausgabe einer Zeile gesetzt. Das
  spart nicht nur Bytes: Jeder Wechsel beschaedigt die Home-Zelle. Findet doch
  einer statt (neues Gradzeichen), wird die oberste Zeile vollstaendig
  nachgezogen.

Zu beiden Befunden gibt es Tests mit Gegenprobe (mit der alten Fassung schlagen
sie fehl). 155 Tests gruen, am Geraet bestaetigt: ruhig, keine fehlenden Pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:18:11 +02:00

342 lines
14 KiB
Python

"""Tests fuer den Anzeigetreiber -- Zeilenaufbau und die festen Hinweistexte.
python3 -m unittest test_display
"""
import unittest
import config
import migra
from display import Display
from transport import FakeTransport
def _lesbar(frame):
"""Telegramm als lesbarer Text; Steuerzeichen werden zu '.'."""
return "".join(chr(b) if 32 <= b < 127 else "." for b in frame)
def zellen(frames):
"""Aus gesendeten Telegrammen zurueckrechnen, was wo auf der Anzeige steht.
Seit jedes Zeichen einzeln positioniert wird (config.CELL_WIDTH), steht der
Text nicht mehr am Stueck im Telegramm -- zwischen je zwei Zeichen liegt eine
Cursor-Sequenz. Ein Test auf "AB " im Rohtelegramm ginge deshalb ins Leere.
Diese Funktion liest die Cursor-Sequenzen mit und liefert {(x, y): zeichen},
also das, was die Anzeige am Ende wirklich zeigt.
Aufbau je Zeichen: ESC 'C' xxx yyy SEP <zeichen>
Beim Gradzeichen schiebt sich ein Zeichensatzwechsel dazwischen
(ESC 'z' nn), das Zeichen ist dann das erste nach dem naechsten SEP.
"""
out = {}
for frame in frames:
i = 0
while i < len(frame):
if frame[i] == migra.ESC and i + 7 < len(frame) and frame[i + 1:i + 2] == b"C":
x = int(frame[i + 2:i + 5])
y = int(frame[i + 5:i + 8])
j = frame.find(bytes([migra.SEP]), i + 8)
if j >= 0 and j + 1 < len(frame):
out[(x, y)] = chr(frame[j + 1])
i += 8
else:
i += 1
return out
def zeile(frames, index):
"""Den Text einer Anzeigezeile aus den Telegrammen rekonstruieren."""
y = config.LINE_Y[index]
treffer = [(x, ch) for (x, yy), ch in zellen(frames).items() if yy == y]
return "".join(ch for _, ch in sorted(treffer))
def zentriert(text):
"""Erwartungswert: Text so, wie _emit_line ihn auf die Zeile legt."""
text = text[:config.CHARS_PER_LINE]
rest = config.CHARS_PER_LINE - len(text)
links = rest // 2 if config.CENTER_LINES else 0
return " " * links + text + " " * (rest - links)
class TestFesteTexte(unittest.TestCase):
"""Die Hinweiszeilen muessen in die Anzeige passen.
Am Geraet aufgefallen: "MONTIERUNG" hat 10 Zeichen, die Anzeige fasst 9 --
angezeigt wurde "MONTIERUN". _emit_line schneidet stillschweigend ab, also
faellt so etwas sonst erst auf der Anzeige auf.
"""
def test_hinweiszeilen_passen_in_die_zeilenbreite(self):
for name in ("NOLINK_LINES", "NOWIFI_LINES"):
for zeile in getattr(config, name):
self.assertLessEqual(
len(zeile), config.CHARS_PER_LINE,
"config.%s: %r hat %d Zeichen, die Anzeige fasst %d"
% (name, zeile, len(zeile), config.CHARS_PER_LINE))
def test_es_gibt_nicht_mehr_hinweiszeilen_als_zeilen(self):
for name in ("NOLINK_LINES", "NOWIFI_LINES"):
self.assertLessEqual(len(getattr(config, name)), len(config.LINE_Y))
class TestNurGeaenderteZellen(unittest.TestCase):
"""Der Grund fuer den Vergleich: eine ruhige Anzeige.
Mit Einzelpositionierung kostet eine volle Zeile rund 100 Bytes; bei 19200
Baud dauerte ein kompletter Bildwechsel damit rund 300 ms, und der Aufbau
war als Flackern zu sehen (am Geraet gemessen 2026-07-27). Deshalb wird nur
geschrieben, was sich geaendert hat.
"""
def setUp(self):
self.t = FakeTransport(echo=False)
self.d = Display(self.t, response=True)
def _neu(self):
"""Telegramme seit dem letzten Aufruf."""
frames = self.t.frames[:]
self.t.frames.clear()
return frames
def test_sekundenwechsel_schreibt_nur_die_noetigen_zellen(self):
"""Der Alltagsfall: RA tickt, alles andere steht still.
Geschrieben werden die geaenderte Ziffer *und* ihr rechter Nachbar --
siehe test_rechter_nachbar_wird_mitgeschrieben.
"""
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
self.d.show_lines("18h36m57s", "+38°47'01\"")
frames = self._neu()
# Die Deklination ist unveraendert -- dafuer darf gar nichts gesendet werden.
self.assertEqual(len(frames), 1)
x = lambda i: config.LINE_X + i * config.CELL_WIDTH
self.assertEqual(zellen(frames), {(x(7), config.LINE_Y[0]): "7",
(x(8), config.LINE_Y[0]): "s"})
def test_rechter_nachbar_wird_mitgeschrieben(self):
"""Am Geraet aufgefallen (2026-07-27): dem "s" fehlten die linken Pixel.
Die Anzeige malt je Zeichen 7 px breit, gesetzt wird auf 6-px-Raster --
ein neu geschriebenes Zeichen loescht die erste Pixelspalte seines
rechten Nachbarn. Der muss deshalb mit, sonst bleibt er beschaedigt.
"""
self.d.show_lines("AAAAAAAAAA")
self._neu()
self.d.show_lines("AAAAXAAAAA") # nur Zelle 4 aendert sich
gesetzt = zellen(self._neu())
x = lambda i: config.LINE_X + i * config.CELL_WIDTH
y = config.LINE_Y[0]
self.assertIn((x(4), y), gesetzt) # die geaenderte Zelle
self.assertIn((x(5), y), gesetzt) # ihr rechter Nachbar
self.assertNotIn((x(3), y), gesetzt) # links davon bleibt unberuehrt
self.assertNotIn((x(6), y), gesetzt) # und uebernaechste auch
def test_letzte_zelle_hat_keinen_nachbarn(self):
"""Aendert sich die aeusserste rechte Zelle, gibt es nichts nachzuziehen."""
self.d.show_lines("AAAAAAAAAA")
self._neu()
self.d.show_lines("AAAAAAAAAX")
gesetzt = zellen(self._neu())
x = lambda i: config.LINE_X + i * config.CELL_WIDTH
self.assertEqual(list(gesetzt), [(x(9), config.LINE_Y[0])])
def test_unveraenderte_zeilen_erzeugen_kein_telegramm(self):
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
self.d.show_lines("18h36m56s", "+38°47'01\"")
self.assertEqual(self._neu(), [])
def test_telegramm_wird_deutlich_kuerzer(self):
"""Vorher-nachher in Bytes -- das ist die eigentliche Wirkung."""
self.d.show_lines("18h36m56s", "+38°47'01\"")
voll = sum(len(f) for f in self._neu())
self.d.show_lines("18h36m57s", "+38°47'01\"")
diff = sum(len(f) for f in self._neu())
# Gemessen: 236 Bytes gegen 30 (Faktor knapp 8). Die Schranke liegt
# bewusst darunter, damit der Test nicht bei jeder Kleinigkeit kippt --
# er soll die Groessenordnung sichern, nicht eine exakte Zahl.
self.assertLess(diff * 5, voll,
"Der Sekundenwechsel sollte um ein Vielfaches "
"kuerzer sein als der Vollaufbau (%d vs %d Bytes)"
% (diff, voll))
def test_clear_erzwingt_vollstaendiges_neuschreiben(self):
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
self.d.show_lines("18h36m56s", "+38°47'01\"", clear=True)
frames = self._neu()
# Loeschtelegramm + beide Zeilen vollstaendig
self.assertEqual(len(frames), 3)
self.assertEqual(len(zeile(frames, 0)), config.CHARS_PER_LINE)
self.assertEqual(len(zeile(frames, 1)), config.CHARS_PER_LINE)
def test_nach_clear_kein_rest_der_alten_anzeige(self):
"""clear muss den gemerkten Inhalt verwerfen.
Sonst haelt der Vergleich Zellen faelschlich fuer schon vorhanden und
die Anzeige bliebe nach dem Loeschen teilweise leer.
"""
self.d.show_lines("ABCDEFGHIJ", "0123456789")
self._neu()
self.d.clear()
self._neu()
self.d.show_lines("ABCDEFGHIJ", "0123456789")
frames = self._neu()
self.assertEqual(zeile(frames, 0), "ABCDEFGHIJ")
self.assertEqual(zeile(frames, 1), "0123456789")
def test_nach_sendefehler_wird_die_zeile_voll_neu_geschrieben(self):
"""Bei einem Fehler ist ungewiss, was ankam -- also nichts annehmen."""
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
class Kaputt(Exception):
pass
def platzt(_):
raise Kaputt()
echt, self.d._t.write = self.d._t.write, platzt
with self.assertRaises(Kaputt):
self.d.show_lines("18h36m57s", "+38°47'01\"")
self.d._t.write = echt
self._neu()
# Dieselbe Zeile noch einmal: muss vollstaendig geschrieben werden.
self.d.show_lines("18h36m57s", "+38°47'01\"")
self.assertEqual(len(zeile(self._neu(), 0)), config.CHARS_PER_LINE)
def test_gradzeichen_wird_nicht_staendig_neu_gesetzt(self):
"""Spart nicht nur Bytes: Jeder Zeichensatzwechsel beschaedigt (0,0)."""
self.d.show_lines("18h36m56s", "+38°47'01\"")
self._neu()
self.d.show_lines("18h36m57s", "+38°47'02\"")
gesendet = b"".join(self._neu())
self.assertNotIn(b"\x1bz01", gesendet)
def test_neues_gradzeichen_zieht_die_oberste_zeile_nach(self):
"""Wird der Zeichensatz doch gewechselt, ist die Home-Zelle hin.
Dann muss die oberste Zeile vollstaendig nachgezogen werden, sonst
bleibt ihr erstes Zeichen zerstoert.
"""
self.d.show_lines("18h36m56s", "kein Grad")
self._neu()
self.d.show_lines("18h36m56s", "+38°47'01\"") # Gradzeichen kommt neu dazu
frames = self._neu()
self.assertIn(b"\x1bz01", b"".join(frames)) # Wechsel fand statt
# Obere Zeile trotz unveraendertem Text komplett neu geschrieben.
self.assertEqual(len(zeile(frames, 0)), config.CHARS_PER_LINE)
class TestZeilenaufbau(unittest.TestCase):
def setUp(self):
self.t = FakeTransport(echo=False)
self.d = Display(self.t, response=True)
def _alle(self):
return self.t.frames
def test_zeile_wird_auf_volle_breite_aufgefuellt(self):
"""Ohne Auffuellen blieben Reste der vorigen Anzeige stehen.
Gleichzeitig die Regressionsprobe fuer str.ljust, das MicroPython
nicht kennt (am Geraet aufgefallen).
"""
self.d.show_lines("AB", "CD")
self.assertEqual(zeile(self._alle(), 0), zentriert("AB"))
self.assertEqual(zeile(self._alle(), 1), zentriert("CD"))
# Es wird immer die volle Breite geschrieben, damit nichts stehen bleibt.
self.assertEqual(len(zeile(self._alle(), 0)), config.CHARS_PER_LINE)
def test_kuerzere_zeile_wird_mittig_gesetzt(self):
"""Zentriert heisst: links und rechts gleich viel Luft (Nutzerwunsch)."""
self.d.show_lines("+38°47'") # 7 Zeichen in 10 Zellen
text = zeile(self._alle(), 0)
links = len(text) - len(text.lstrip())
rechts = len(text) - len(text.rstrip())
self.assertLessEqual(abs(links - rechts), 1)
def test_jedes_zeichen_sitzt_auf_seiner_zelle(self):
"""Der Auto-Vorschub der Sperrschrift ist 7 px und liesse nur 9 Zeichen
zu; die Matrix ist aber 6 px breit. Deshalb wird jedes Zeichen selbst
gesetzt -- x muss genau LINE_X + i*CELL_WIDTH sein (am Geraet gemessen)."""
self.d.show_lines("0123456789")
gesetzt = zellen(self._alle())
y = config.LINE_Y[0]
for i in range(config.CHARS_PER_LINE):
self.assertIn((config.LINE_X + i * config.CELL_WIDTH, y), gesetzt)
def test_zeile_passt_in_die_anzeigenbreite(self):
"""Auch die letzte Zelle darf nicht ueber den rechten Rand hinausragen --
sonst quittiert die Anzeige mit Fehlercode 4 (ausserhalb)."""
rechts = config.LINE_X + config.CHARS_PER_LINE * config.CELL_WIDTH
self.assertLessEqual(rechts, config.DISPLAY_WIDTH)
self.assertGreaterEqual(config.LINE_X, 0)
def test_zu_lange_zeile_wird_abgeschnitten(self):
"""Zu breit aufgefuellt bricht das Fuell-Leerzeichen um und
beschaedigt die andere Zeile -- deshalb hart abschneiden."""
lang = "X" * (config.CHARS_PER_LINE + 5)
self.d.show_lines(lang)
self.assertEqual(zeile(self._alle(), 0), "X" * config.CHARS_PER_LINE)
def test_ohne_zentrierung_steht_der_text_links(self):
"""CENTER_LINES = False muss wieder linksbuendig setzen."""
alt = config.CENTER_LINES
try:
config.CENTER_LINES = False
self.d.show_lines("AB")
self.assertEqual(zeile(self._alle(), 0),
"AB" + " " * (config.CHARS_PER_LINE - 2))
finally:
config.CENTER_LINES = alt
def test_oberste_zeile_wird_zuletzt_gezeichnet(self):
"""Schutz der Home-Zelle: ein Zeichensatzwechsel zerstoert (0,0),
die oberste Zeile muss danach kommen und sie ueberschreiben."""
self.d.show_lines("OBEN", "UNTEN")
frames = self._alle()
self.assertEqual(len(frames), 2)
# Erst das Telegramm der unteren Zeile, dann das der oberen.
self.assertEqual(zeile(frames[:1], 1).strip(), "UNTEN")
self.assertEqual(zeile(frames[1:], 0).strip(), "OBEN")
def test_telegramme_bleiben_unter_der_groessengrenze(self):
"""Mit Einzelpositionierung kosten beide Zeilen zusammen 224 der
erlaubten 230 Bytes -- deshalb ein Telegramm je Zeile. Der Test haelt
fest, dass die Reserve nicht wieder verloren geht."""
self.d.show_lines("18h36m56s", "+38" + config.DEGREE_MARKER + "47'01\"",
clear=True)
for frame in self._alle():
self.assertLessEqual(len(frame), migra.MAX_DATA_UNIT)
def test_clear_erzeugt_genau_ein_vollbild_loeschen(self):
"""clear=True darf nur einmal loeschen -- sonst loescht das zweite
Telegramm die gerade geschriebene Zeile wieder weg."""
fuellen = bytes([migra.ESC]) + b"F" + config.BACKGROUND.encode("ascii")
self.d.show_lines("A", "B", clear=True)
mit = self.t.frames[:]
self.assertEqual(sum(f.count(fuellen) for f in mit), 1)
self.t.frames.clear()
self.d.show_lines("A", "B", clear=False)
self.assertEqual(sum(f.count(fuellen) for f in self.t.frames), 0)
def test_mehr_zeilen_als_die_anzeige_hat(self):
with self.assertRaises(ValueError):
self.d.show_lines(*(["X"] * (len(config.LINE_Y) + 1)))
if __name__ == "__main__":
unittest.main()