Files
admin 433c53aabd RA in Zehntelminuten mit Beschriftung: "RA 18h36.9m" / "DE +38°47'"
Die GM4000 liefert im Auslieferzustand keine Sekunden, sondern Zehntelminuten.
Bisher wurde daraus eine Sekundenanzeige gerechnet -- die sprang in
6-Sekunden-Schritten, weil die Rohdaten sie gar nicht hergeben. Die Zehntelform
ist ehrlicher und kuerzer, und der gewonnene Platz traegt die Beschriftung:

    RA 18h36.9m
    DE +38°47'

Bei Hochpraezision entfaellt die Beschriftung, dort braucht die Deklination mit
+38°47'01" alle Zellen.

Der Mock konnte das gar nicht nachstellen -- er sendete immer das
Hochpraezisionsformat. Neu: "mount_mock.py --low" bildet den Auslieferzustand ab
(RA als HH:MM.T, DEC ohne Bogensekunden). Vorgabe bleibt hoch, damit die
bestehenden Tests mit ihren festen Erwartungen unveraendert durchlaufen.

Zur Darstellung, alles am Geraet ausprobiert:

- **Schmale Zeichen** (config.CELL_NARROW): Dezimalpunkt und der Abstand
  zwischen Beschriftung und Wert bekommen 3 statt 6 px -- in der Sperrschrift
  standen sie sonst sehr luftig. Fuer die Leerzeichen am Zeilenende gilt das
  nicht, die sollen den Rest der Zeile ueberschreiben.
- Damit entscheidet die **Pixelbreite** ueber das Abschneiden, nicht mehr die
  Zeichenzahl: "RA 18h36.9m" sind 11 Zeichen und trotzdem nur 60 px.
- **Linksbuendig ab LINE_X = 0**, der Schalter zum Zentrieren ist entfallen.
  Zentriert standen die Zeilen um wenige Pixel gegeneinander versetzt, weil die
  RA-Zeile mit ihrem schmalen Punkt kuerzer ist als die DEC-Zeile.

Ein Zwischenschritt, der wieder verworfen wurde und als Warnung im Code steht:
Ein Doppelpunkt als Trenner ("RA:18h36.9m") sah in Zeichensatz 0 zu wuchtig aus;
der aus Zeichensatz 1 ist schlanker, steht aber mitten in der obersten Zeile --
und jeder Zeichensatzwechsel zerstoert die Home-Zelle. Das erste Zeichen danach
noch einmal zu schreiben machte es schlimmer: Das nachgezogene Zeichen bemalt
GLYPH_WIDTH Pixel und frisst die linke Spalte seines rechten Nachbarn. Deshalb
steht in der obersten Zeile jetzt bewusst kein Sonderzeichen.

161 Tests gruen, am Geraet bestaetigt.

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

289 lines
12 KiB
Python

#!/usr/bin/env python3
"""Tests fuer den Protokoll-Layer, gepruefte Referenz ist das Handbuch.
python3 -m unittest -v test_migra
"""
import unittest
import config
import coords
import migra
from display import Display
from transport import FakeTransport
class TestTelegramm(unittest.TestCase):
def test_handbuch_beispiel_mit_pruefsumme(self):
"""Handbuch 3.10, Beispiel 2: Zeichen 'A' an Anzeige mit Adresse 1.
Master: 02 81 80 83 F0 F1 41 FA F6 03
"""
frame = migra.build_telegram(b"A", dest=1, src=0, checksum=True, response=True)
self.assertEqual(frame, bytes.fromhex("02818083F0F141FAF603"))
def test_handbuch_beispiel_ohne_pruefsumme(self):
"""Handbuch 3.10, Beispiel 1: ohne Pruefsumme, mit Antwort.
Ohne Pruefsumme duerfen LEN-H/L gerade *nicht* mitgesendet werden.
"""
frame = migra.build_telegram(b"Hallo Welt", dest=1, src=0,
checksum=False, response=True)
self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81]))
self.assertEqual(frame[4:-1], b"Hallo Welt")
self.assertEqual(frame[-1], 0x03)
def test_frame_control_bits(self):
def fc(checksum, response):
return migra.build_telegram(b"x", checksum=checksum, response=response)[3]
self.assertEqual(fc(False, False), 0x80)
self.assertEqual(fc(False, True), 0x81) # so faehrt das alte C-Programm
self.assertEqual(fc(True, False), 0x82)
self.assertEqual(fc(True, True), 0x83)
def test_data_unit_zu_lang(self):
with self.assertRaises(migra.MigraError):
migra.build_telegram(b"x" * 231)
class TestDataUnit(unittest.TestCase):
def test_trennzeichen_nach_esc_sequenz(self):
"""Handbuch 3.9: Data-Unit = 1B 'Z01' 1B 'C002003' 1B 'A301' 1F Text."""
du = (migra.DataUnit()
.charset(1, spaced=False)
.cursor(2, 3)
.attributes(fg=migra.YELLOW, bg=migra.BLACK, blink=migra.BLINKING)
.text("Online-Text"))
self.assertEqual(
du.bytes(),
b"\x1bZ01\x1bC002003\x1bA301\x1fOnline-Text",
)
def test_kein_trennzeichen_zwischen_esc_sequenzen(self):
du = migra.DataUnit().fill(migra.BLACK).charset(0)
self.assertEqual(du.bytes(), b"\x1bF0\x1bz00")
def test_trennzeichen_nur_einmal_pro_textblock(self):
du = migra.DataUnit().cursor(0, 0).text("AB").text("CD")
self.assertEqual(du.bytes(), b"\x1bC000000\x1fABCD")
def test_cursor_ist_ascii_codiert(self):
"""Handbuch 3.7: Position 123 wird als '1' '2' '3' uebertragen."""
self.assertEqual(migra.DataUnit().cursor(123, 45).bytes(), b"\x1bC123045")
def test_loeschen_und_helligkeit(self):
self.assertEqual(migra.DataUnit().fill(migra.BLACK).bytes(), b"\x1bF0")
self.assertEqual(migra.DataUnit().brightness(100, migra.RED).bytes(), b"\x1bH2100")
self.assertEqual(migra.DataUnit().brightness(80, migra.RED).bytes(), b"\x1bH2080")
def test_helligkeit_ausserhalb_bereich(self):
with self.assertRaises(migra.MigraError):
migra.DataUnit().brightness(101)
def test_text_encoding_latin1(self):
self.assertEqual(migra.encode_text("+45°12'"), b"+45\xb012'")
self.assertEqual(migra.encode_text(""), b"?") # nicht darstellbar
class TestAntwort(unittest.TestCase):
def test_ok(self):
frame = bytes([0x02, 0x80, 0x81, 0x80, ord("0"), 0x03])
self.assertEqual(migra.parse_response(frame), "0")
def test_fehlercode_wirft(self):
frame = bytes([0x02, 0x80, 0x81, 0x80, ord("3"), 0x03])
with self.assertRaises(migra.MigraError) as ctx:
migra.parse_response(frame)
self.assertIn("ESC-Sequenz", str(ctx.exception))
def test_timeout_wirft(self):
with self.assertRaises(migra.MigraError):
migra.parse_response(b"")
class TestKoordinaten(unittest.TestCase):
def test_ra_formate_werden_erkannt(self):
for text in ("12h34m56s", "12:34:56", "12 34 56"):
self.assertEqual(coords.parse_ra(text), (12, 34, 56))
def test_ra_dezimal(self):
self.assertEqual(coords.parse_ra("12.5"), (12, 30, 0))
def test_dec_mit_vorzeichen(self):
self.assertEqual(coords.parse_dec("+45d12m30s"), ("+", 45, 12, 30))
self.assertEqual(coords.parse_dec("-07:15:00"), ("-", 7, 15, 0))
def test_dec_ohne_vorzeichen_ist_positiv(self):
self.assertEqual(coords.parse_dec("45:12:30"), ("+", 45, 12, 30))
def test_bereichsgrenzen(self):
with self.assertRaises(ValueError):
coords.parse_ra("24:00:01")
with self.assertRaises(ValueError):
coords.parse_dec("+91:00:00")
def test_formatierung_hochpraezision(self):
hp = dict(high_precision=True)
self.assertEqual(coords.format_ra("12h34m56s", width=9, **hp), "12h34m56s")
self.assertEqual(coords.format_ra("12h34m56s", width=8, **hp), "12:34:56")
self.assertEqual(coords.format_dec("+45d12m30s", width=10, **hp), "+45°12'30\"")
self.assertEqual(coords.format_dec("+45d12m30s", width=9, **hp), "+45°12'30")
self.assertEqual(coords.format_dec("+45d12m30s", width=7, **hp), "+45°12'")
def test_formatierung_niedrigpraezision(self):
"""RA in Zehntelminuten -- so, wie die Montierung sie liefert.
Bis 2026-07-27 wurde hier in Sekunden umgerechnet (11h36m54s). Die
sprangen aber in 6-Sekunden-Schritten, weil die Rohdaten sie nicht
hergeben. Die Zehntelform ist ehrlicher und laesst mit sieben Zeichen
Platz fuer die Beschriftung "RA:".
"""
lp = dict(high_precision=False)
# Breiteste passende Form: mit Minutenzeichen am Ende, sonst ohne.
self.assertEqual(coords.format_ra("11:36.9", width=9, **lp), "11h36.9m")
self.assertEqual(coords.format_ra("11:36.9", width=8, **lp), "11h36.9m")
self.assertEqual(coords.format_ra("11:36.9", width=7, **lp), "11h36.9")
self.assertEqual(coords.format_ra("11:36.9", width=6, **lp), "11h36m")
# DEC: ohne Sekunden
self.assertEqual(coords.format_dec("-00°55", width=9, **lp), "-00°55'")
self.assertEqual(coords.format_dec("-00°55", width=6, **lp), "-00:55")
def test_zehntelminuten_mit_uebertrag(self):
"""18:36:57 sind 36,95 Minuten -- das muss auf 18:37.0 aufgehen."""
lp = dict(high_precision=False, width=7)
self.assertEqual(coords.format_ra("18:36:57", **lp), "18h37.0")
self.assertEqual(coords.format_ra("18:36:00", **lp), "18h36.0")
self.assertEqual(coords.format_ra("18:36:54", **lp), "18h36.9")
# Auch ueber die Stunde und ueber Mitternacht hinweg.
self.assertEqual(coords.format_ra("18:59:58", **lp), "19h00.0")
self.assertEqual(coords.format_ra("23:59:58", **lp), "00h00.0")
def test_hochpraezision_behaelt_die_sekundenform(self):
hp = dict(high_precision=True, width=10)
self.assertEqual(coords.format_ra("18:36:56", **hp), "18h36m56s")
self.assertEqual(coords.format_dec("+38*47:01", **hp), "+38°47'01\"")
class TestDisplay(unittest.TestCase):
def test_je_zeile_ein_telegramm(self):
"""Eine Zeile je Telegramm, jeweils sauber gerahmt.
Frueher war beides ein Telegramm. Seit jedes Zeichen einzeln
positioniert wird (10 statt 9 Zeichen), waeren beide Zeilen zusammen zu
dicht an migra.MAX_DATA_UNIT -- siehe display.show_lines.
"""
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12:34:56", "+45:12:30", clear=True)
# clear=True ist ein eigenes, sehr kurzes Telegramm, danach je Zeile eins.
self.assertEqual(len(t.frames), 3)
for frame in t.frames:
self.assertEqual(frame[:4], bytes([0x02, 0x81, 0x80, 0x81]))
self.assertEqual(frame[-1], 0x03)
self.assertLessEqual(len(frame), migra.MAX_DATA_UNIT)
loeschen, unten, oben = t.frames # untere Zeile zuerst
self.assertIn(b"\x1bF0", loeschen)
self.assertNotIn(b"\x1bF0", unten)
self.assertNotIn(b"\x1bF0", oben)
# Jedes Zeichen sitzt auf seiner eigenen Zelle: x = LINE_X + i*CELL_WIDTH.
def zelle(i, y):
return b"\x1bC%03d%03d" % (config.LINE_X + i * config.CELL_WIDTH, y)
# Linksbuendig (config.CENTER_LINES): beide Zeilen beginnen bei Zelle 0.
self.assertIn(zelle(0, 0) + b"\x1f1", oben)
self.assertIn(zelle(1, 0) + b"\x1f2", oben)
self.assertIn(zelle(0, 9) + b"\x1f+", unten)
def test_ohne_clear_kein_fill_aber_aufgefuellt(self):
"""Standard (clear=False): kein Vollbild-Loeschen, Zeilen auf volle Breite."""
from test_display import zeile, aufbereitet
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12:34:56", clear=False)
self.assertNotIn(b"\x1bF0", t.frames[0])
# auf CHARS_PER_LINE mit Leerzeichen aufgefuellt (mittig, siehe CENTER_LINES)
self.assertEqual(zeile(t.frames, 0), aufbereitet("12:34:56"))
def test_gradzeichen_via_zeichensatz(self):
"""Gradzeichen wird durch kurzen Wechsel auf Zeichensatz 1 erzeugt."""
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"")
data = t.frames[0][4:-1]
# Umschalten auf Zeichensatz 1, "/", zurueck auf Zeichensatz 0
self.assertIn(b"\x1bz01\x1f/\x1bz00", data)
def test_oberste_zeile_wird_zuletzt_gezeichnet(self):
"""Wegen der Home-Zellen-Stoerung muss y=0 nach y=9 gesendet werden.
Seit es ein Telegramm je Zeile gibt, entscheidet die Reihenfolge der
Telegramme statt der Reihenfolge innerhalb eines Telegramms.
"""
t = FakeTransport(echo=False)
Display(t, response=True).show_lines("12h34m56s", "+45°12'30\"")
from test_display import zellen
self.assertEqual(len(t.frames), 2)
# Auf die y-Koordinaten schauen, nicht auf feste Cursor-Bytes.
y_zuerst = set(y for _, y in zellen(t.frames[:1]))
y_zuletzt = set(y for _, y in zellen(t.frames[1:]))
self.assertEqual(y_zuerst, {config.LINE_Y[1]}) # untere Zeile zuerst
self.assertEqual(y_zuletzt, {config.LINE_Y[0]}) # oberste Zeile zuletzt
def test_gradzeichen_fallback_ohne_zeichensatz(self):
"""Ist DEGREE_CHARSET None, wird der Fallbacktext statt der Umschaltung genutzt."""
import config
t = FakeTransport(echo=False)
alt = config.DEGREE_CHARSET
try:
config.DEGREE_CHARSET = None
Display(t, response=True).show_lines("+45°12'30\"")
finally:
config.DEGREE_CHARSET = alt
data = t.frames[0][4:-1]
self.assertNotIn(b"\x1bz01", data)
self.assertIn(config.DEGREE_FALLBACK.encode(), data)
def test_fehlercode_der_anzeige_wirft(self):
t = FakeTransport(echo=False)
t._response = bytes([0x02, 0x80, 0x81, 0x80, ord("4"), 0x03])
with self.assertRaises(migra.MigraError):
Display(t, response=True).clear()
def test_show_line_nur_eine_zeile(self):
"""show_line schreibt nur die angegebene Zeile, ohne Vollbild-Loeschen."""
from test_display import zellen
t = FakeTransport(echo=False)
Display(t, response=True).show_line(0, "18h36m56s")
self.assertNotIn(b"\x1bF0", t.frames[0]) # kein Loeschen
y_werte = set(y for _, y in zellen(t.frames))
self.assertEqual(y_werte, {config.LINE_Y[0]}) # nur Zeile 1, Zeile 2 unberuehrt
def test_show_line_ungueltiger_index(self):
t = FakeTransport(echo=False)
with self.assertRaises(ValueError):
Display(t, response=True).show_line(5, "x")
def test_ohne_antwort_fc_bit0_ist_null(self):
"""Standardbetrieb ohne Antwort: FC = 0x80, kein Fehlercode wird gelesen."""
import config
alt = config.QUIET_TIME
try:
config.QUIET_TIME = 0.0
t = FakeTransport(echo=False)
# selbst wenn die (ignorierte) Antwort einen Fehler meldet, darf nichts fliegen
t._response = bytes([0x02, 0x80, 0x81, 0x80, ord("4"), 0x03])
Display(t, response=False).clear()
finally:
config.QUIET_TIME = alt
self.assertEqual(t.frames[0][3], 0x80) # FC ohne Antwort-Bit
if __name__ == "__main__":
unittest.main(verbosity=2)