feat(api): V2-3 (Teil 1) — Ereignis-Bus und SSE-Endpunkt /api/v2/events
Ampel / ampel (push) Failing after 41s
Ampel / ampel (push) Failing after 41s
WAS: rippy/bus mit Ereignis-Schema, In-Process-Treiber und Ringpuffer.
Dazu der SSE-Strom in der API: erst ein Snapshot, danach nur Deltas,
mit lueckenloser Wiederaufnahme ueber Last-Event-ID.
WARUM: Ein offener Tab plus ein Worker verursachen heute rund 133
Anfragen pro Minute (Dashboard 75 + Log-Kasten 24 + Laufwerke 12 +
Worker-Liste 4 + Log-Seite 6 + Tray 12). Das Rate-Limit stand einmal
UNTER dieser Zahl — daher die sich leerende Job-Liste im Sekundentakt.
Mit einer offenen Verbindung sind es null.
DREI EIGENSCHAFTEN, ALLE AUS v1-FEHLERN:
1. Der Bus traegt NUR Nachrichten ueber Aenderungen, nie den Zustand.
Wer den Zustand will, fragt den Store. Damit kann ein verpasstes
Ereignis auch keinen Zustand loeschen — anders als beim fuenffachen
`catch(() => [])` im alten UI, wo jeder fehlgeschlagene Abruf
"es gibt keine Jobs" bedeutete.
2. Eine zu grosse Luecke wird ANGESAGT, nicht verschluckt. nachliefern()
gibt None ("hol dir ein ganzes Bild") statt [] ("nichts verpasst") —
dieselbe Unterscheidung wie timeout-Rueckgabe 124 bei den Netzpfaden.
Stillschweigend weiterzumachen waere schlimmer: Das UI hielte sich
fuer aktuell und waere es nicht.
3. Der Snapshot meldet unlesbare Laufwerke als None, nicht als leere
Liste. "Konnte nicht nachsehen" ist etwas anderes als "gibt es nicht".
Ein unbekannter Ereignistyp fliegt beim Senden HOCH statt durchzugehen.
Ein Tippfehler waere sonst der stillste aller Fehlschlaege: Nachricht
raus, kein Empfaenger, nirgends ein Hinweis.
Ein langsamer Zuhoerer (Tab im Hintergrund, lahmes Handy) bremst den
Sender nicht — er wird markiert und bekommt beim naechsten Mal einen
Snapshot. Ein Rip darf nicht auf einen Browser warten.
GEMESSEN: ruff sauber, 359 Tests gruen + 3 uebersprungen (vorher 346).
13 neue Bus-Tests laufen auf jeder Plattform; die vier SSE-Tests haengen
an main.py und laufen damit in der Ampel.
NOCH OFFEN in V2-3: das UI auf den Strom umstellen (neun setInterval)
und die Ereignisse an den Zustandsaenderungen ausloesen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d2645f87e8
commit
55db9eb13f
@@ -246,3 +246,67 @@ def test_wache_meldet_wenn_es_wieder_geht(monkeypatch):
|
||||
zustand["da"] = True
|
||||
main._mounts_nachsehen()
|
||||
assert any("antwortet wieder" in m for m in gelogged)
|
||||
|
||||
|
||||
# --- Live-Ereignisse (SSE), Etappe V2-3 --------------------------------------
|
||||
|
||||
|
||||
def test_events_route_ist_verdrahtet():
|
||||
"""Ohne diese Route fällt das UI stumm auf seinen letzten Stand zurück —
|
||||
und weil ein Abriss KEINE Aussage ist, sähe der Nutzer einfach nichts
|
||||
Neues, ohne Fehlermeldung. Deshalb hier festgenagelt."""
|
||||
from main import app
|
||||
|
||||
assert "/api/v2/events" in {route.path for route in app.routes}
|
||||
|
||||
|
||||
def test_sse_rahmen_hat_das_format_das_der_browser_erwartet():
|
||||
"""`id:` ist nicht Kosmetik — der Browser schickt genau diesen Wert beim
|
||||
Wiederverbinden als Last-Event-ID zurück. Fehlt er, gibt es keine
|
||||
lückenlose Wiederaufnahme, und jeder WLAN-Wechsel reisst ein Loch."""
|
||||
from main import _sse_rahmen
|
||||
|
||||
rahmen = _sse_rahmen({"seq": 42, "typ": "job.progress", "daten": {"prozent": 7}})
|
||||
zeilen = rahmen.split("\n")
|
||||
assert zeilen[0] == "id: 42"
|
||||
assert zeilen[1] == "event: job.progress"
|
||||
assert zeilen[2].startswith("data: {")
|
||||
# Zwei Leerzeilen am Ende: eine schliesst das Ereignis, die zweite ist der
|
||||
# Trenner. Ohne den doppelten Umbruch haelt der Browser das Ereignis fuer
|
||||
# unvollstaendig und liefert es NIE aus.
|
||||
assert rahmen.endswith("\n\n")
|
||||
|
||||
|
||||
def test_sse_rahmen_uebersteht_umlaute():
|
||||
"""Job-Titel und Log-Zeilen sind deutsch. Mit ensure_ascii=True kaeme
|
||||
"Gr\u00f6\u00dfe" beim Nutzer an."""
|
||||
from main import _sse_rahmen
|
||||
|
||||
rahmen = _sse_rahmen({"seq": 1, "typ": "log.line", "daten": {"text": "Größe"}})
|
||||
assert "Größe" in rahmen
|
||||
|
||||
|
||||
def test_snapshot_meldet_unlesbare_laufwerke_als_none():
|
||||
"""„konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
|
||||
|
||||
Genau diese Vermischung hat in v1 die Job-Liste im Sekundentakt geleert
|
||||
(fuenfmal `catch(() => [])` im UI). Ein Snapshot mit devices=[] wuerde dem
|
||||
UI sagen „du hast kein Laufwerk"; None sagt „ich weiss es gerade nicht",
|
||||
und das UI behaelt seinen Stand.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import main
|
||||
|
||||
def kaputt():
|
||||
raise OSError("Laufwerk haengt")
|
||||
|
||||
original = main.device_discovery.list_optical_devices
|
||||
main.device_discovery.list_optical_devices = kaputt
|
||||
try:
|
||||
zustand = asyncio.run(main._snapshot())
|
||||
finally:
|
||||
main.device_discovery.list_optical_devices = original
|
||||
|
||||
assert zustand["devices"] is None
|
||||
assert isinstance(zustand["jobs"], list)
|
||||
|
||||
Reference in New Issue
Block a user