"""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 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()