fix(ui): Leerer Bildschirm nach „Rippen starten" — ein halber Job riss alles mit

Commander: „wenn man auf rippen starten klickt passiert irgendwas, was das
Programm nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es
gibt auch keine fehlermeldung."

Nachgestellt: Dienst lokal gestartet, Disc-Karte geoeffnet, „Rippen starten"
geklickt. Browser-Konsole:

    TypeError: Cannot read properties of undefined (reading 'toUpperCase')

## Er startet sehr wohl — man sieht es nur nie

Waehrend die Seite leer war, lief der Job (per API gemessen: status
`processing`, progress 12). Das „er startet nix" ist also der zweite Teil
desselben Fehlers: Die Oberflaeche war weg, bevor sie ihn zeigen konnte.

## Die Ursache

`_job_kurz` in `bus/waechter.py` trug nur `status`, `progress`, `title` und
`error` — **keinen `type`**. Das UI fuegt aus `job.created` einen halben Job
in seine Liste ein, `LiveLogSection` liest `job.type.toUpperCase()`, und React
baut bei einem Fehler im Zeichnen den GESAMTEN Baum ab. Es gab in diesem
Projekt keine einzige Fehlergrenze — also blieb ein leerer Bildschirm ohne
jede Meldung.

## Drei Reparaturen, weil es drei Fehler waren

1. **Das Ereignis traegt den Job.** `type`, `device` und `startTime` fahren
   mit. Sie aendern sich ueber die Lebenszeit eines Jobs nie, kosten also kein
   zusaetzliches Ereignis — und ohne `startTime` stand in der Jobliste
   sekundenlang „Invalid Date".
2. **Das UI vertraegt sein Fehlen.** `LiveLogSection` und `TypeBadge` nahmen
   einen vollstaendigen Job an. Zeile 108 derselben Datei hatte das
   Fragezeichen laengst, Zeile 48 nicht.
3. **Eine Fehlergrenze.** Ein Fehler in einer Karte darf nicht den ganzen
   Bildschirm mitnehmen — und schon gar nicht schweigend. Jetzt steht da, was
   los ist, die Navigation bleibt bedienbar, und der Hinweis sagt das
   Wichtigste: laufende Rips gehen weiter.

## Warum es niemand gefunden hat

`unterschiede()` bekommt die Kurzform schon fertig, und alle Tests reichen
ihre eigenen Woerterbuecher herein. **`_job_kurz` selbst war nie geprueft.**
Jetzt bewacht `UI_PFLICHTFELDER` den Vertrag mechanisch — es gibt kein
gemeinsames Typsystem zwischen Python und dem UI.

Gegengeprueft im Browser: derselbe Klick, keine Konsolenfehler, Job erscheint
als „Alle (1)".

833 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-29 14:10:12 +02:00
co-authored by Claude Opus 5
parent d9780a4951
commit 27c9d9a4bb
6 changed files with 239 additions and 13 deletions
+68 -1
View File
@@ -6,7 +6,7 @@ genau die Eigenschaft, die dem ersten Anlauf der SSE-Tests gefehlt hat
(Ampel-Lauf 170 lief rot, weil ein Test eine Datenbank brauchte).
"""
from rippy.bus.waechter import Waechter, laufwerks_unterschiede, unterschiede
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz, Waechter, laufwerks_unterschiede, unterschiede
class FakeBus:
@@ -277,3 +277,70 @@ def test_laufwerke_werden_seltener_abgefragt_als_jobs():
w.einmal()
w.einmal()
assert len(aufrufe) == 1, "Laufwerke wurden mehrfach im selben Takt gelesen"
# ── Der Vertrag mit der Oberflaeche (Befund 29.08.2026) ─────────────────
#
# Commander: „wenn man auf rippen starten klickt passiert irgendwas, was das
# Programm nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es
# gibt auch keine fehlermeldung."
#
# Nachgestellt und in der Browser-Konsole gemessen:
#
# TypeError: Cannot read properties of undefined (reading 'toUpperCase')
#
# Der Job lief in Wahrheit (gemessen: processing, 12 %). Aber `job.created`
# trug keinen `type`; das UI fuegte einen halben Job ein, las
# `job.type.toUpperCase()` — und weil es keine Fehlergrenze gab, riss der
# Fehler die GANZE Oberflaeche mit.
#
# ⚠️ Warum es niemand gefunden hat: `unterschiede()` bekommt die Kurzform
# schon fertig, die Tests oben reichen ihre eigenen Woerterbuecher herein.
# **`_job_kurz` selbst war nie geprueft.** Diese Tests schliessen die Luecke.
def _db_zeile():
"""Eine Job-Zeile, wie die API sie liefert (Felder aus /jobs)."""
return {"id": "j1", "type": "bluray", "device": r"\\.\G:",
"startTime": "2026-08-29T12:07:02", "endTime": None,
"status": "processing", "progress": 12, "title": "Evangelion 2.22",
"error": None, "meta": {"confidence": 0.3}}
def test_ein_job_ereignis_traegt_alles_was_die_oberflaeche_braucht():
"""DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander
leer — ohne Fehlermeldung."""
kurz = _job_kurz(_db_zeile())
for feld in UI_PFLICHTFELDER:
assert feld in kurz, "%s fehlt — das UI muesste raten" % feld
assert kurz[feld] is not None, "%s ist leer" % feld
def test_der_typ_kommt_wirklich_durch():
"""Der konkrete Fehler: `type` fehlte, das UI rief .toUpperCase() darauf."""
assert _job_kurz(_db_zeile())["type"] == "bluray"
def test_fehlende_felder_werden_zu_None_statt_zu_werfen():
"""Eine unvollstaendige Zeile darf den Waechter nicht umbringen — er
laeuft in der Ereignisschleife des Servers."""
kurz = _job_kurz({"id": "j1"})
assert kurz["type"] is None and kurz["status"] is None
def test_die_kurzform_traegt_NUR_was_gebraucht_wird():
"""Kein Nachziehen der ganzen Zeile: `meta` und `endTime` gehoeren nicht
in ein Aenderungs-Ereignis, sonst feuert es bei jeder Kleinigkeit."""
kurz = _job_kurz(_db_zeile())
assert "meta" not in kurz
assert "endTime" not in kurz
def test_unveraenderliche_felder_erzeugen_keine_zusatz_ereignisse():
"""`type`, `device` und `startTime` aendern sich ueber die Lebenszeit
eines Jobs nie — sie kosten also nichts, obwohl sie mitfahren."""
eins = _job_kurz(_db_zeile())
zwei = _job_kurz(dict(_db_zeile(), progress=13))
assert unterschiede({"j1": eins}, {"j1": zwei}) == [
("job.progress", "j1", zwei)]
+42 -1
View File
@@ -79,8 +79,38 @@ ENDE = ("completed", "failed", "canceled")
def _job_kurz(zeile: dict) -> dict:
"""Nur die Felder, deren Änderung ein Ereignis wert ist."""
"""Nur die Felder, deren Änderung ein Ereignis wert ist.
## Warum `type` dazugehört (Befund des Commanders, 29.08.2026)
> „wenn man auf rippen starten klickt passiert irgendwas, was das Programm
> nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
> auch keine fehlermeldung."
Nachgestellt und in der Browser-Konsole gemessen:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Der Ablauf: Der Job startet in Wahrheit sehr wohl (gemessen: `processing`,
12 %). Aber `job.created` trug nur `status`, `progress`, `title` und
`error` — **keinen `type`**. Das UI fügt so einen halben Job in seine
Liste ein, das Live-Log liest `job.type.toUpperCase()`, und weil es keine
Fehlergrenze gab, riss der Fehler die GESAMTE Oberfläche mit. Leerer
Bildschirm, keine Meldung.
Der Fehler ist damit an drei Stellen zugleich repariert: Das Ereignis
trägt den Typ (hier), das UI verträgt sein Fehlen, und eine Fehlergrenze
fängt den nächsten Fall dieser Art ab.
**Ein Ereignis „Job angelegt", das den halben Job weglässt, zwingt jeden
Empfänger zum Raten.** `type` und `device` ändern sich über die Lebenszeit
eines Jobs nie — sie kosten hier nichts und ersparen dem Empfänger die
Frage, ob er gerade einen ganzen oder einen halben Job vor sich hat.
"""
return {
"type": zeile.get("type"),
"device": zeile.get("device"),
"startTime": zeile.get("startTime"),
"status": zeile.get("status"),
"progress": zeile.get("progress"),
"title": zeile.get("title"),
@@ -88,6 +118,17 @@ def _job_kurz(zeile: dict) -> dict:
}
#: Was ein Job-Ereignis mitbringen MUSS, damit die Oberflaeche ihn zeichnen
#: kann, ohne zu raten. Gegengeprueft von test_waechter.py — es gibt kein
#: gemeinsames Typsystem zwischen Python und dem UI, also steht der Vertrag
#: hier und wird mechanisch bewacht.
#:
#: `startTime` steht dabei, weil ohne ihn in der Jobliste „Invalid Date"
#: stand (am 29.08.2026 im Browser gesehen), bis Sekunden spaeter der
#: naechste Schnappschuss kam.
UI_PFLICHTFELDER = ("type", "startTime", "status", "progress")
def unterschiede(vorher: dict, jetzt: dict) -> list:
"""Was hat sich geändert? (pure Funktion — deshalb testbar ohne DB)