Phase 6: Datenanbindung, Ersteinrichtung und Offline-Anzeige
Die App spricht jetzt echt mit Home Assistant. Ein Datenkontext buendelt die vier Datentoepfe, haengt sich an den WebSocket-Ereignisstrom und reicht Verbindungszustand und Warteschlange nach aussen; die Screens kennen weder REST noch WebSocket. Ersteinrichtung prueft die Zugangsdaten, bevor sie sie speichert, und unterscheidet in der Fehlermeldung zwischen abgelehntem Token und nicht erreichbarem Server - zwei Faelle, die voellig verschiedene Reaktionen verlangen. Zwei echte Fehler, die erst der Test gegen die laufende Instanz zutage brachte: 1. Die Typen der Datenschicht deklarierten tank_prozent, sicher_abgestellt und sicherheit. Das Backend schreibt tankprozent, gesichert und sicherheitscheck - die Oberflaeche haette still ueberall undefined gelesen, ohne dass irgendetwas fehlgeschlagen waere. 2. Der Profil-Adapter reichte lebende Verweise ins Rohprofil durch. Ein Formular haette damit den Rohstand mitveraendert und die Zusage gebrochen, den vom Backend fortgeschriebenen Reifen-Kilometerstand nie zu ueberschreiben. Abschnitte werden jetzt kopiert. Ausserdem zwei Parameter-Eigenschaften in der Datenschicht aufgeloest, weil Node sie im Strip-Modus nicht uebersetzt und genau darueber die Rauchtests laufen. Belegt durch neun Pruefungen gegen die laufende Instanz (Lesen, Dienstaufruf, vollstaendiger Warteschlangenumlauf, WebSocket-Anmeldung) und 21 Unit-Tests.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/* Rauchtest mit echtem Zugangstoken gegen die laufende HA-Testinstanz.
|
||||
Ergänzt scripts/smoke.ts um alles, was ohne Anmeldung nicht prüfbar ist:
|
||||
echte Lesevorgänge, ein echter Dienstaufruf und ein vollständiger Umlauf
|
||||
durch die Warteschlange.
|
||||
|
||||
Zugangsdaten kommen aus companion-app/.env.local (schreibt
|
||||
testumgebung/aufsetzen.sh). Ohne sie bricht der Test mit einem Hinweis ab,
|
||||
ohne zu scheitern - damit er in einer Umgebung ohne Testinstanz nicht
|
||||
fälschlich rot wird.
|
||||
|
||||
Aufruf: npm run smoke:auth */
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { dirname, resolve } from "node:path"
|
||||
|
||||
import { ENTITAETEN } from "../src/api/types.ts"
|
||||
import { HassRest } from "../src/api/rest.ts"
|
||||
import { Warteschlange } from "../src/api/warteschlange.ts"
|
||||
import { ablageSetzen, websocketUrl, type Ablage } from "../src/api/umgebung.ts"
|
||||
|
||||
/* ------------------------------------------------------ Zugangsdaten */
|
||||
|
||||
const hier = dirname(fileURLToPath(import.meta.url))
|
||||
const umgebungsdatei = resolve(hier, "../.env.local")
|
||||
|
||||
function umgebungLesen(): Record<string, string> {
|
||||
try {
|
||||
const inhalt = readFileSync(umgebungsdatei, "utf8")
|
||||
const werte: Record<string, string> = {}
|
||||
for (const zeile of inhalt.split("\n")) {
|
||||
const treffer = /^([A-Z_]+)=(.*)$/.exec(zeile.trim())
|
||||
if (treffer?.[1] && treffer[2] !== undefined) werte[treffer[1]] = treffer[2]
|
||||
}
|
||||
return werte
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const umgebung = umgebungLesen()
|
||||
const BASIS = umgebung["VITE_TEST_URL"] ?? "http://localhost:18123"
|
||||
const TOKEN = umgebung["VITE_TEST_TOKEN"] ?? process.env["VITE_TEST_TOKEN"] ?? ""
|
||||
|
||||
if (!TOKEN) {
|
||||
console.log("übersprungen: kein Token in companion-app/.env.local")
|
||||
console.log(" testumgebung/aufsetzen.sh ausführen, dann erneut versuchen.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Rahmen */
|
||||
|
||||
let fehler = 0
|
||||
function pruefe(bezeichnung: string, bedingung: boolean, zusatz = ""): void {
|
||||
console.log(`${bedingung ? "PASS" : "FAIL"} ${bezeichnung}${zusatz ? ` (${zusatz})` : ""}`)
|
||||
if (!bedingung) fehler += 1
|
||||
}
|
||||
|
||||
/** Ablage im Arbeitsspeicher — Node hat kein localStorage. */
|
||||
class SpeicherAblage implements Ablage {
|
||||
#werte = new Map<string, string>()
|
||||
async lesen(schluessel: string) {
|
||||
return this.#werte.get(schluessel) ?? null
|
||||
}
|
||||
async schreiben(schluessel: string, wert: string) {
|
||||
this.#werte.set(schluessel, wert)
|
||||
}
|
||||
async loeschen(schluessel: string) {
|
||||
this.#werte.delete(schluessel)
|
||||
}
|
||||
}
|
||||
ablageSetzen(new SpeicherAblage())
|
||||
|
||||
const zugang = { basisUrl: BASIS, token: TOKEN }
|
||||
const rest = new HassRest(zugang)
|
||||
|
||||
/* ------------------------------------------------------------- Lesen */
|
||||
|
||||
try {
|
||||
const antwort = await rest.verbindungPruefen()
|
||||
pruefe("verbindungPruefen mit Token", typeof antwort.message === "string", antwort.message)
|
||||
} catch (e) {
|
||||
pruefe("verbindungPruefen mit Token", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
try {
|
||||
const profil = await rest.datenLesen<Record<string, unknown>>(ENTITAETEN.profil)
|
||||
pruefe(
|
||||
"Profil liefert die erwarteten Abschnitte",
|
||||
Boolean(profil["fahrzeug"] && profil["einstellungen"] && profil["reifen"]),
|
||||
Object.keys(profil).join(", "),
|
||||
)
|
||||
} catch (e) {
|
||||
pruefe("Profil lesen", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await rest.datenLesen<Record<string, unknown>>(ENTITAETEN.fahrzeugstatus)
|
||||
/* Die Feldnamen sind der Grund für diesen Test: ein früherer Entwurf der
|
||||
Typen hatte tank_prozent/sicher_abgestellt/sicherheit angenommen, das
|
||||
Backend schreibt aber tankprozent/gesichert/sicherheitscheck. */
|
||||
pruefe(
|
||||
"Fahrzeugstatus benutzt die erwarteten Feldnamen",
|
||||
"tankprozent" in status && "gesichert" in status && "sicherheitscheck" in status,
|
||||
Object.keys(status).slice(0, 6).join(", "),
|
||||
)
|
||||
pruefe(
|
||||
"Sicherheitscheck liefert alle 16 Einzelprüfungen",
|
||||
Array.isArray(status["sicherheitscheck"]) && (status["sicherheitscheck"] as unknown[]).length === 16,
|
||||
`${(status["sicherheitscheck"] as unknown[] | undefined)?.length ?? 0} Punkte`,
|
||||
)
|
||||
} catch (e) {
|
||||
pruefe("Fahrzeugstatus lesen", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
for (const [name, id] of [
|
||||
["Fahrten", ENTITAETEN.fahrten],
|
||||
["Tankvorgänge", ENTITAETEN.tankvorgaenge],
|
||||
] as const) {
|
||||
try {
|
||||
const liste = await rest.datenLesen<unknown[]>(id)
|
||||
pruefe(`${name} sind eine Liste`, Array.isArray(liste), `${liste.length} Einträge`)
|
||||
} catch (e) {
|
||||
pruefe(`${name} lesen`, false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Dienstaufruf */
|
||||
|
||||
try {
|
||||
await rest.dienstAufrufen("pyscript", "audi_dashboard_jetzt_aktualisieren")
|
||||
pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", true)
|
||||
} catch (e) {
|
||||
pruefe("Dienstaufruf audi_dashboard_jetzt_aktualisieren", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ Warteschlange */
|
||||
|
||||
try {
|
||||
const warteschlange = new Warteschlange(rest)
|
||||
await warteschlange.einreihen("pyscript", "audi_dashboard_jetzt_aktualisieren", {}, "Rauchtest")
|
||||
|
||||
/* einreihen() stößt die Abarbeitung selbst an, ohne sie abzuwarten - ein
|
||||
sofortiger eigener abarbeiten()-Aufruf liefe deshalb ins Leere (der Lauf
|
||||
ist schon aktiv). Also warten, bis die Schlange tatsächlich leer ist. */
|
||||
const frist = Date.now() + 10_000
|
||||
while (warteschlange.anzahl > 0 && Date.now() < frist) {
|
||||
await new Promise((weiter) => setTimeout(weiter, 100))
|
||||
}
|
||||
pruefe(
|
||||
"Warteschlange arbeitet einen Auftrag vollständig ab",
|
||||
warteschlange.anzahl === 0,
|
||||
`${warteschlange.anzahl} verblieben`,
|
||||
)
|
||||
} catch (e) {
|
||||
pruefe("Warteschlangen-Umlauf", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
/* ----------------------------------------- WebSocket mit Anmeldung */
|
||||
|
||||
try {
|
||||
const ergebnis = await new Promise<string>((aufloesen, ablehnen) => {
|
||||
const ws = new WebSocket(websocketUrl(BASIS))
|
||||
const frist = setTimeout(() => {
|
||||
ablehnen(new Error("Zeitüberschreitung"))
|
||||
ws.close()
|
||||
}, 10_000)
|
||||
ws.onmessage = (nachricht) => {
|
||||
const daten = JSON.parse(String(nachricht.data)) as { type: string }
|
||||
if (daten.type === "auth_required") {
|
||||
ws.send(JSON.stringify({ type: "auth", access_token: TOKEN }))
|
||||
} else if (daten.type === "auth_ok" || daten.type === "auth_invalid") {
|
||||
clearTimeout(frist)
|
||||
aufloesen(daten.type)
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
clearTimeout(frist)
|
||||
ablehnen(new Error("WebSocket-Fehler"))
|
||||
}
|
||||
})
|
||||
pruefe("WebSocket meldet sich mit dem Token an", ergebnis === "auth_ok", ergebnis)
|
||||
} catch (e) {
|
||||
pruefe("WebSocket-Anmeldung", false, e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
console.log(fehler === 0 ? "\nAlle Prüfungen bestanden." : `\n${fehler} Prüfung(en) fehlgeschlagen.`)
|
||||
process.exitCode = fehler === 0 ? 0 : 1
|
||||
Reference in New Issue
Block a user