"""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): ch = chr(frame[j + 1]) # Das Gradzeichen entsteht ueber einen Zeichensatzwechsel und # steht als DEGREE_CHAR ("/") im Telegramm. Hier wieder in das # zurueckuebersetzen, was auf der Anzeige zu sehen ist. if (frame[i + 8:j].startswith(b"\x1bz") and ch == config.DEGREE_CHAR): ch = config.DEGREE_MARKER out[(x, y)] = ch 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)) # So viele *volle* Zellen passen in eine Zeile. Das ist weniger als # config.CHARS_PER_LINE: Dessen 11 Zeichen gehen nur auf, wenn mindestens ein # schmales dabei ist (config.CELL_NARROW). Fuer Tests mit normalen Zeichen ist # das hier die Zeilenbreite. VOLLE_ZELLEN = (config.DISPLAY_WIDTH - config.LINE_X) // config.CELL_WIDTH def aufbereitet(text): """Erwartungswert: Text so, wie er auf der Zeile landet. Nutzt bewusst dieselbe Funktion wie der Treiber. Wie breit eine Zeile ist, haengt seit config.CELL_NARROW davon ab, welche Zeichen darin stehen -- eine zweite Nachbildung hier waere nur eine Fehlerquelle. Was der Wert *bedeutet*, pruefen die Tests darunter mit eigenen Zusicherungen. """ return Display._aufbereiten(text) 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)), VOLLE_ZELLEN) self.assertEqual(len(zeile(frames, 1)), VOLLE_ZELLEN) 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)), VOLLE_ZELLEN) 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)), VOLLE_ZELLEN) class TestSchmaleZeichen(unittest.TestCase): """Der Dezimalpunkt der Zehntelminuten braucht weniger als eine volle Zelle. In der Sperrschrift stand "RA 18:37.1" sonst sehr luftig -- der Punkt belegt 6 px, obwohl er nur ein, zwei Pixel breit ist. 3 px am Geraet ausprobiert (2026-07-27). Moeglich ist das nur, weil jedes Zeichen ohnehin einzeln positioniert wird. """ def setUp(self): self.t = FakeTransport(echo=False) self.d = Display(self.t, response=True) def _neu(self): frames = self.t.frames[:] self.t.frames.clear() return frames def test_punkt_bekommt_weniger_platz(self): pos = Display._positionen("1.2") self.assertEqual(pos[1] - pos[0], config.CELL_WIDTH) # vor dem Punkt self.assertEqual(pos[2] - pos[1], config.CELL_NARROW["."]) # nach dem Punkt def test_zeile_mit_punkt_wird_schmaler_und_bleibt_mittig(self): def breite(positionen): return positionen[-1] + config.CELL_WIDTH - positionen[0] ohne = Display._positionen("RA 18:3701") # gleiche Laenge, kein Punkt mit = Display._positionen("RA 18:37.1") self.assertEqual(breite(ohne) - breite(mit), config.CELL_WIDTH - config.CELL_NARROW["."]) # Weiterhin vollstaendig innerhalb der Anzeige. self.assertGreaterEqual(mit[0], 0) self.assertLessEqual(mit[-1] + config.CELL_WIDTH, config.DISPLAY_WIDTH) def test_zehntel_wechsel_schreibt_weiter_nur_wenige_zellen(self): """Der Alltagsfall bei Niedrigpraezision -- das Nachfuehren muss greifen.""" self.d.show_lines("RA 18:37.1", "DE +38°47'") self._neu() self.d.show_lines("RA 18:37.2", "DE +38°47'") frames = self._neu() self.assertEqual(len(frames), 1) # DEC unveraendert self.assertLessEqual(len(zellen(frames)), 2) # nur die Zehntelziffer def test_strukturwechsel_schreibt_die_zeile_vollstaendig_neu(self): """Kommt ein Punkt dazu, sitzen alle folgenden Zellen woanders. Ein Vergleich Zeichen fuer Zeichen waere dann sinnlos -- es muss alles neu geschrieben werden. """ self.d.show_lines("RA 18:3701") # ohne Punkt: 10 volle Zellen self._neu() self.d.show_lines("RA 18:37.1") # mit Punkt: andere Positionen gesetzt = zellen(self._neu()) # Alles neu -- und dank des schmalen Punktes passt sogar ein Zeichen mehr. self.assertEqual(len(gesetzt), len(Display._aufbereiten("RA 18:37.1"))) self.assertGreater(len(gesetzt), VOLLE_ZELLEN) 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), aufbereitet("AB")) self.assertEqual(zeile(self._alle(), 1), aufbereitet("CD")) # Es wird immer die volle Breite geschrieben, damit nichts stehen bleibt. self.assertEqual(len(zeile(self._alle(), 0)), VOLLE_ZELLEN) def test_kuerzere_zeile_steht_linksbuendig(self): """Nutzerwunsch 2026-07-27: beide Zeilen buendig untereinander. Zentriert standen sie um wenige Pixel gegeneinander versetzt, weil die RA-Zeile mit ihrem schmalen Dezimalpunkt kuerzer ist als die DEC-Zeile. """ self.d.show_lines("+38°47'") # 7 Zeichen in 10 Zellen text = zeile(self._alle(), 0) self.assertEqual(text, "+38°47'" + " " * (VOLLE_ZELLEN - 7)) def test_beide_zeilen_beginnen_an_derselben_stelle(self): """Der eigentliche Punkt: RA und DE muessen buendig starten.""" self.d.show_lines("RA:18h37.1", "DE:+38°47'") gesetzt = zellen(self._alle()) erste_oben = min(x for (x, y) in gesetzt if y == config.LINE_Y[0]) erste_unten = min(x for (x, y) in gesetzt if y == config.LINE_Y[1]) self.assertEqual(erste_oben, erste_unten) 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(VOLLE_ZELLEN): 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 + VOLLE_ZELLEN * 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" * VOLLE_ZELLEN) 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()