From a68817065b344551598456f5828fe294edbcef47 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:21:56 +0200 Subject: [PATCH] Phase 9: Tests fuer die portierte Rechenlogik 41 weitere Tests fuer Statistik, Service-Prognose und Formatierung. Die Faelle sind entlang der Regeln gewaehlt, die beim Portieren wichtig waren: Wochenbeginn Montag, Nachtspanne ueber Mitternacht, mengengewichteter Durchschnittspreis statt Mittel der Einzelpreise, Plausibilitaetsfenster beim Langzeitverbrauch, Neustart der Oelprognose ab dem letzten Wechsel samt Zeitlimit-Deckelung, BOM und Semikolon in der CSV-Ausgabe. Ein Test war zunaechst falsch: er verglich das UTC-Datum gegen eine Ortszeit-Erwartung. Der Code rechnet bewusst lokal - sonst begaenne der Monat in unserer Zeitzone einen Tag zu frueh. companion-app/README.md auf den erreichten Stand gebracht. Gesamt: 90 Unit- und Rendertests plus 9 Pruefungen gegen die laufende Home-Assistant-Instanz. --- companion-app/README.md | 115 ++++++------- companion-app/src/daten/service.test.ts | 94 ++++++++++ companion-app/src/daten/statistik.test.ts | 199 ++++++++++++++++++++++ companion-app/src/format.test.ts | 103 +++++++++++ 4 files changed, 451 insertions(+), 60 deletions(-) create mode 100644 companion-app/src/daten/service.test.ts create mode 100644 companion-app/src/daten/statistik.test.ts create mode 100644 companion-app/src/format.test.ts diff --git a/companion-app/README.md b/companion-app/README.md index 0cb91d5..ae33d4f 100644 --- a/companion-app/README.md +++ b/companion-app/README.md @@ -1,81 +1,76 @@ -# DataMetric360 — companion app +# companion-app — DataMetric360 -Private vehicle app for one car. Ships three ways from one codebase: native iOS and Android via -Capacitor, and embedded as a plain iframe in the Home Assistant dashboard. Architecture and the -decisions behind it: [`../COMPANION_APP_ARCHITECTURE.md`](../COMPANION_APP_ARCHITECTURE.md). +Private Fahrzeug-App für einen Audi RS 4 Avant competition. Läuft als Web-App +im Browser, als PWA auf dem Homescreen, später über Capacitor nativ auf +iOS/Android und eingebettet als Iframe im Home-Assistant-Dashboard. -## Status +**Kein eigenes Backend.** Die App spricht direkt mit der REST- und +WebSocket-Schnittstelle von Home Assistant und ruft dieselben +`pyscript.audi_dashboard_*`-Dienste auf wie das bestehende Panel. Alle Daten +liegen auf dem eigenen Server; das Gerät hält nur den Zugangstoken und einen +Zwischenspeicher für die Offline-Anzeige. -**Data layer only.** No UI yet — the screens come from the Claude Design draft -([`../DESIGN_BRIEF_DATAMETRIC360.md`](../DESIGN_BRIEF_DATAMETRIC360.md)), and get implemented on top -of `design-system/`'s React components once that draft settles. This package was written first on -purpose: how the app talks to Home Assistant doesn't depend on what the screens look like, so it -survives every design iteration untouched. +Architektur: `../COMPANION_APP_ARCHITECTURE.md` · Umsetzungsschritte: +`../UMSETZUNGSPLAN.md` · Projektstand: `../AGENTS.md` -Not yet added (deliberately, they'd be guesses today): React/Vite, Capacitor, the native secure-storage -adapter, and the Audi brand assets (fonts/rings/badges — those come from `homeassistant/www/` at -implementation time, never into `design-system/`, see the licence note in the architecture doc). +## Loslegen -## Layout - -``` -src/api/ -├── types.ts HA state shapes + domain types (Fahrt, Tankvorgang, …) + the entity-ID table -├── umgebung.ts runtime detection (capacitor/iframe/browser), credential storage, URL helpers -├── rest.ts REST client — replaces hass.states / hass.callService -├── live.ts WebSocket client — push updates, auto-reconnect with backoff -├── warteschlange.ts offline queue for writes made without a connection -└── index.ts DataMetricApi — ties the three together, exposes the domain operations -scripts/smoke.ts verification against a running HA instance +```bash +npm install # im Repo-Wurzelverzeichnis, nicht hier (npm-Workspace) +npm run build:ds # Design-System bauen, die App importiert aus dessen dist/ +npm run dev --workspace datametric360 ``` -Comments are in German, matching the rest of the project. +Beim ersten Start fragt die App nach Server-Adresse und Zugangstoken. Für die +Entwicklung setzt `../testumgebung/aufsetzen.sh` eine vollständige +Home-Assistant-Instanz mit dem echten Backend auf. -## What replaces what +## Aufbau -The old panel got a `hass` object injected by `panel_custom`. That object only exists inside the HA -frontend, which is exactly why the old panel can't run as a standalone app. The mapping: - -| Old panel | Here | +| Ordner | Inhalt | |---|---| -| `hass.states[id].attributes.daten` | `rest.datenLesen(id)` / `DataMetricApi.profilLesen()` etc. | -| `hass.callService(...)` | `warteschlange.einreihen(...)` via the `DataMetricApi` methods | -| automatic re-render on state push | `live.aufZustand(...)` | +| `src/api/` | Datenschicht: REST, WebSocket mit Wiederverbindung, Offline-Warteschlange, Zugangsdaten | +| `src/daten/` | Fachlogik: Profil-Umrechnung, Statistik, Service-Prognose, Datenkontext | +| `src/screens/` | Die 21 Seiten der App | +| `src/stile/` | Layout, Bildschirmbausteine, Audi-Schrift | +| `src/assets/audi/` | Schrift, Vier-Ringe, Typenschilder — **lizenzpflichtig, siehe unten** | +| `src/tests/` | Testaufbau und Beispieldaten | -Same entities, same pyscript services, same backend files written — only the transport changes. +Gestaltung kommt vollständig aus `@audi-dash/ui` (`../design-system/`). Neue +Komponenten entstehen dort, nicht hier. -Every **write** goes through the queue rather than straight to REST. That's what makes offline edits -behave the same as online ones, just delayed: a receipt photographed in a dead zone is persisted and -sent when the connection returns, surviving an app restart in between. - -## Commands - -Node is installed at `C:\Program Files\nodejs` but is **not on PATH** — prefix it: +## Prüfen ```bash -$env:Path = "C:\Program Files\nodejs;" + $env:Path +npm run typecheck # TypeScript, sehr streng (exactOptionalPropertyTypes u. a.) +npm run test # 90 Tests: Rechenlogik, Layoutwechsel, alle Seiten gerendert +npm run smoke # Adressbildung und Endpunkte, ohne Token +npm run smoke:auth # gegen die laufende Testinstanz, mit Token +npm run build # Produktionsbündel ``` -```bash -npm run typecheck -``` +`smoke:auth` liest Adresse und Token aus `.env.local` (schreibt +`../testumgebung/aufsetzen.sh`, gitignored). Ohne die Datei überspringt der +Test sich selbst, statt fehlzuschlagen. -```bash -npm run smoke -``` +Die Rendertests decken alle 21 Seiten in beiden Layouts ab — schmal mit +Tab-Leiste, breit mit Seitenleiste. -`smoke` targets `http://localhost:18123` (the `audi_ha_test` Docker container) by default; pass a -different base URL as the first argument. +## Rechtlicher Hinweis -## Verification status (2026-08-10) +`src/assets/audi/` enthält die Hausschrift Audi Type, das Vier-Ringe-Zeichen +und die Modell-Typenschilder. Diese sind lizenz- beziehungsweise +markenrechtlich geschützt und ausschließlich für diese eine private, +unveröffentlichte Installation freigegeben (`../bauauftrag.md` §8/§12). -`tsc --noEmit` clean under `strict` plus `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`. -All 7 smoke checks pass against the running test container, covering URL normalisation, the -http→ws/https→wss mapping, that `GET /api/` exists and rejects an unauthenticated request with 401, -and that the WebSocket opens with `auth_required` — which is the message `live.ts`'s whole auth flow -is built around. +Daraus folgt: **Die App wird nur seitlich installiert, nie in einem App Store +veröffentlicht**, und dieses Repository bleibt privat. `../design-system/` +kommt bewusst ohne diese Dateien aus, weil es nach außen hochgeladen wird — +dort niemals Markendateien ablegen. -**Not yet verified — needs a token:** authenticated reads, service calls, and the queue's real -round-trip. Those need a Long-Lived Access Token, which is a deliberate manual step (see the LLAT -provisioning decision in the architecture doc). To do it: create a token in the HA profile page, then -extend `scripts/smoke.ts` with authenticated cases. +## Noch offen + +- Capacitor-Hülle und sichere Ablage des Tokens (Keychain/Keystore) +- QR-Einrichtung als Alternative zum Einfügen des Tokens +- Live-Ansicht der laufenden Fahrt: gebaut, aber über + `src/funktionen.ts` abgeschaltet, bis der FMM003 echte Werte liefert diff --git a/companion-app/src/daten/service.test.ts b/companion-app/src/daten/service.test.ts new file mode 100644 index 0000000..736b940 --- /dev/null +++ b/companion-app/src/daten/service.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" + +import { letzterOelwechsel, oelwechselPrognose } from "./service" +import type { Fahrzeug } from "./profilAdapter" + +function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug { + return { + fin: "", + details: "", + erstzulassung: "", + hu: "", + odo, + odoBekannt: true, + tankPct: 0, + reichweite: null, + batteriespannung: null, + gesichert: null, + sicherheitscheck: [], + oelwechselFaelligTs: null, + oelwechselFaelligKm: null, + inspektionFaelligTs: null, + inspektionFaelligKm: null, + smartdeal: { aktiv: false, laeuftAb: null }, + oel: { modus, km: 15000, monate: 12, herstellerKm: 30000, herstellerMonate: 24 }, + versicherung: {}, + steuer: { faellig: "" }, + reifen: { aktiv: "Sommer", anzugsmoment: null, wechsel: {}, sommer: {}, winter: {} }, + service: {}, + technik: {}, + ausstattung: [], + } +} + +const BUCH = [ + { datum: "2026-02-10", km: 40000, art: "Ölwechsel" }, + { datum: "2025-03-05", km: 25000, art: "Inspektion" }, +] + +describe("letzterOelwechsel", () => { + it("findet den jüngsten Ölwechsel und ignoriert andere Arten", () => { + expect(letzterOelwechsel(BUCH)?.datum).toBe("2026-02-10") + }) + + it("erkennt auch die Schreibweise ohne Umlaut", () => { + expect(letzterOelwechsel([{ datum: "2026-01-01", km: 1, art: "Oelwechsel" }])).not.toBeNull() + }) + + it("liefert null, wenn nie einer eingetragen wurde", () => { + expect(letzterOelwechsel([{ datum: "2026-01-01", km: 1, art: "Inspektion" }])).toBeNull() + }) +}) + +describe("oelwechselPrognose", () => { + const jetzt = new Date("2026-08-11T12:00:00") + + it("rechnet ab dem letzten Ölwechsel, nicht ab dem ersten Buchungseintrag", () => { + // Seit 10.02. sind 182 Tage vergangen, 6000 km gefahren -> rund 33 km/Tag. + // Ziel ist 40000 + 30000 = 70000, offen sind also 24000 km. + const p = oelwechselPrognose(fahrzeug(46000), BUCH, jetzt) + expect(p).not.toBeNull() + expect(p!.restKm).toBe(24000) + expect(p!.kmProTag).toBeGreaterThan(30) + expect(p!.kmProTag).toBeLessThan(36) + }) + + it("deckelt auf das Zeitlimit, wenn wenig gefahren wurde", () => { + // Nur 100 km seit dem Wechsel: die Kilometerprognose läge Jahre entfernt, + // das Zeitlimit von 24 Monaten greift vorher. + const p = oelwechselPrognose(fahrzeug(40100), BUCH, jetzt) + expect(p!.durchZeitlimit).toBe(true) + expect(p!.datum).toBe("2028-02-10") + }) + + it("meldet Fälligkeit sofort, wenn das Intervall überschritten ist", () => { + const p = oelwechselPrognose(fahrzeug(75000), BUCH, jetzt) + expect(p!.restKm).toBe(0) + expect(p!.datum).toBe("2026-08-11") + }) + + it("nutzt bei eigenem Intervall den kleineren der beiden Werte", () => { + // modus "eigen": min(15000, 30000) = 15000 -> Ziel 55000, offen 9000. + const p = oelwechselPrognose(fahrzeug(46000, "eigen"), BUCH, jetzt) + expect(p!.restKm).toBe(9000) + }) + + it("liefert nichts ohne bekannten Kilometerstand", () => { + const ohne = { ...fahrzeug(0), odoBekannt: false } + expect(oelwechselPrognose(ohne, BUCH, jetzt)).toBeNull() + }) + + it("liefert nichts, wenn nie ein Ölwechsel eingetragen wurde", () => { + expect(oelwechselPrognose(fahrzeug(46000), [], jetzt)).toBeNull() + }) +}) diff --git a/companion-app/src/daten/statistik.test.ts b/companion-app/src/daten/statistik.test.ts new file mode 100644 index 0000000..fc8a0ec --- /dev/null +++ b/companion-app/src/daten/statistik.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest" + +import type { Fahrt, Tankvorgang } from "../api" +import { + fahrtenSeit, + istNachtZeit, + jahresauswertung, + langzeitverbrauch, + nachJahrUndMonat, + tankSeit, + zeitraumStart, +} from "./statistik" + +const JETZT = new Date("2026-08-11T12:00:00") + +function fahrt(teil: Partial & { trip_id: string; ts_start: string }): Fahrt { + return { + ts_end: teil.ts_start, + duration_s: 1800, + distance_km: 10, + km_quelle: "odometer", + odo_start: null, + odo_end: null, + art: "privat", + source: "ha", + status: "vollständig", + ...teil, + } +} + +function tankung(teil: Partial & { tank_id: string; ts: string }): Tankvorgang { + return { + liters: 40, + fuel_total_eur: 70, + price_per_l: 1.75, + source: "manual", + status: "vollständig", + ...teil, + } +} + +/** Ortszeit-Datum. toISOString() waere UTC und kippt in Zeitzonen oestlich + von Greenwich auf den Vortag - der Code rechnet aber bewusst lokal. */ +function alsTag(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` +} + +describe("zeitraumStart", () => { + it("beginnt das Jahr am 1. Januar", () => { + expect(alsTag(zeitraumStart("jahr", JETZT))).toBe("2026-01-01") + }) + + it("beginnt den Monat am Ersten", () => { + expect(alsTag(zeitraumStart("monat", JETZT))).toBe("2026-08-01") + }) + + it("beginnt die Woche am Montag, nicht am Sonntag", () => { + // Der 11.08.2026 ist ein Dienstag, die Woche beginnt am 10.08. + const start = zeitraumStart("woche", JETZT) + expect(start.getDay()).toBe(1) + expect(start.getDate()).toBe(10) + }) + + it("beginnt den Tag um Mitternacht", () => { + const start = zeitraumStart("tag", JETZT) + expect(start.getHours()).toBe(0) + expect(start.getDate()).toBe(11) + }) +}) + +describe("istNachtZeit", () => { + it("erkennt eine Spanne über Mitternacht", () => { + expect(istNachtZeit("2026-08-11T23:30:00", "22:00", "06:00")).toBe(true) + expect(istNachtZeit("2026-08-11T03:00:00", "22:00", "06:00")).toBe(true) + expect(istNachtZeit("2026-08-11T12:00:00", "22:00", "06:00")).toBe(false) + }) + + it("erkennt eine Spanne innerhalb eines Tages", () => { + expect(istNachtZeit("2026-08-11T13:00:00", "12:00", "14:00")).toBe(true) + expect(istNachtZeit("2026-08-11T15:00:00", "12:00", "14:00")).toBe(false) + }) + + it("schließt den Beginn ein und das Ende aus", () => { + expect(istNachtZeit("2026-08-11T22:00:00", "22:00", "06:00")).toBe(true) + expect(istNachtZeit("2026-08-11T06:00:00", "22:00", "06:00")).toBe(false) + }) +}) + +describe("fahrtenSeit und tankSeit", () => { + const fahrten = [ + fahrt({ trip_id: "heute", ts_start: "2026-08-11T09:00:00" }), + fahrt({ trip_id: "diesewoche", ts_start: "2026-08-10T09:00:00" }), + fahrt({ trip_id: "diesenmonat", ts_start: "2026-08-02T09:00:00" }), + fahrt({ trip_id: "letztesjahr", ts_start: "2025-12-30T09:00:00" }), + fahrt({ trip_id: "ohnestrecke", ts_start: "2026-08-11T10:00:00", distance_km: null }), + ] + + it("filtert je Zeitraum", () => { + expect(fahrtenSeit(fahrten, "tag", JETZT).map((f) => f.trip_id)).toEqual(["heute"]) + expect(fahrtenSeit(fahrten, "woche", JETZT).map((f) => f.trip_id)).toEqual([ + "heute", + "diesewoche", + ]) + expect(fahrtenSeit(fahrten, "jahr", JETZT)).toHaveLength(3) + }) + + it("lässt Fahrten ohne Strecke außen vor — sie würden jede Summe verfälschen", () => { + expect(fahrtenSeit(fahrten, "tag", JETZT).some((f) => f.trip_id === "ohnestrecke")).toBe(false) + }) + + it("lässt Tankungen ohne Menge außen vor", () => { + const tanks = [ + tankung({ tank_id: "voll", ts: "2026-08-11T09:00:00" }), + tankung({ tank_id: "offen", ts: "2026-08-11T10:00:00", liters: null }), + ] + expect(tankSeit(tanks, "tag", JETZT).map((t) => t.tank_id)).toEqual(["voll"]) + }) +}) + +describe("langzeitverbrauch", () => { + it("rechnet bevorzugt mit dem Kilometerstand an den Tankvorgängen", () => { + const tanks = [ + tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }), + tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40600 }), + ] + const ergebnis = langzeitverbrauch([], tanks) + expect(ergebnis.km).toBe(600) + expect(ergebnis.liter).toBe(100) + // 100 l auf 600 km sind 16,7 l/100 km — plausibel. + expect(ergebnis.wert).toBeCloseTo(16.67, 1) + expect(ergebnis.basis).toBe("Kilometerstand an den Tankvorgängen") + }) + + it("weicht auf die erfassten Fahrten aus, wenn Kilometerstände fehlen", () => { + const tanks = [tankung({ tank_id: "a", ts: "2026-07-01T10:00:00", liters: 50 })] + const fahrten = [fahrt({ trip_id: "f", ts_start: "2026-06-01T10:00:00", distance_km: 500 })] + const ergebnis = langzeitverbrauch(fahrten, tanks) + expect(ergebnis.km).toBe(500) + expect(ergebnis.basis).toBe("erfasste Fahrten") + }) + + it("meldet unplausible Werte als unbekannt statt als Tatsache", () => { + // 100 l auf 50 km wären 200 l/100 km - unmöglich. + const tanks = [ + tankung({ tank_id: "a", ts: "2026-06-01T10:00:00", liters: 50, odometer_km: 40000 }), + tankung({ tank_id: "b", ts: "2026-07-01T10:00:00", liters: 50, odometer_km: 40050 }), + ] + const ergebnis = langzeitverbrauch([], tanks) + expect(ergebnis.wert).toBeNull() + expect(ergebnis.unplausibel).toBe(true) + }) + + it("kommt ohne Tankvorgänge zurecht", () => { + const ergebnis = langzeitverbrauch([], []) + expect(ergebnis.wert).toBeNull() + expect(ergebnis.unplausibel).toBe(false) + expect(ergebnis.bis).toBeNull() + }) +}) + +describe("jahresauswertung", () => { + it("teilt die Strecke in Tag und Nacht sowie in Arbeitsweg und privat", () => { + const fahrten = [ + fahrt({ trip_id: "tag", ts_start: "2026-08-05T12:00:00", distance_km: 100, art: "arbeitsweg" }), + fahrt({ trip_id: "nacht", ts_start: "2026-08-05T23:00:00", distance_km: 40 }), + ] + const a = jahresauswertung(fahrten, [], "22:00", "06:00", JETZT) + expect(a.kmJahr).toBe(140) + expect(a.tagKm).toBe(100) + expect(a.nachtKm).toBe(40) + expect(a.arbeitKm).toBe(100) + expect(a.privatKm).toBe(40) + expect(a.arbeitFahrten).toBe(1) + expect(a.privatFahrten).toBe(1) + }) +}) + +describe("nachJahrUndMonat", () => { + it("gruppiert absteigend, Neuestes zuerst", () => { + const eintraege = [ + { id: "alt", ts: "2025-03-04T10:00:00" }, + { id: "neu", ts: "2026-08-01T10:00:00" }, + { id: "mittel", ts: "2026-02-01T10:00:00" }, + ] + const gruppen = nachJahrUndMonat(eintraege, (e) => e.ts) + expect(gruppen.map((g) => g.jahr)).toEqual([2026, 2025]) + expect(gruppen[0]?.monate.map((m) => m.monat)).toEqual([7, 1]) + expect(gruppen[0]?.eintraege.map((e) => e.id)).toEqual(["neu", "mittel"]) + }) + + it("überspringt unlesbare Zeitangaben, statt daran zu scheitern", () => { + const gruppen = nachJahrUndMonat( + [{ id: "kaputt", ts: "keine-zeit" }, { id: "gut", ts: "2026-08-01T10:00:00" }], + (e) => e.ts, + ) + expect(gruppen).toHaveLength(1) + expect(gruppen[0]?.eintraege.map((e) => e.id)).toEqual(["gut"]) + }) +}) diff --git a/companion-app/src/format.test.ts b/companion-app/src/format.test.ts new file mode 100644 index 0000000..ecc0d7d --- /dev/null +++ b/companion-app/src/format.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest" + +import { alsCsv } from "./screens/csv" +import { kalenderDatei } from "./screens/kalender" +import { dauer, de, deOderStrich, eur, isoTag, schnittpreis, summe } from "./format" + +describe("Zahlen im deutschen Format", () => { + it("setzt Punkt als Tausender- und Komma als Dezimaltrennzeichen", () => { + expect(de(48250)).toBe("48.250") + expect(de(1234.5, 1)).toBe("1.234,5") + expect(eur(89.1)).toBe("89,10") + }) + + it("macht aus fehlenden Werten eine Null, nicht NaN", () => { + expect(de(null)).toBe("0") + expect(de(undefined)).toBe("0") + }) + + it("unterscheidet mit deOderStrich zwischen null und unbekannt", () => { + expect(deOderStrich(0)).toBe("0") + expect(deOderStrich(null)).toBe("—") + }) +}) + +describe("dauer", () => { + it("zeigt unter einer Stunde nur Minuten", () => { + expect(dauer(2700)).toBe("45 min") + }) + + it("zeigt darüber Stunden und Minuten zweistellig", () => { + expect(dauer(8040)).toBe("2 h 14 min") + }) + + it("zeigt einen Strich statt „0 min", () => { + expect(dauer(0)).toBe("—") + expect(dauer(null)).toBe("—") + }) +}) + +describe("schnittpreis", () => { + it("gewichtet nach Menge, nicht nach Anzahl der Tankungen", () => { + // 10 l zu 2,00 € und 90 l zu 1,00 €: der Schnitt muss nahe 1,10 liegen, + // nicht bei 1,50 (dem Mittel der Einzelpreise). + const schnitt = schnittpreis([ + { liters: 10, fuel_total_eur: 20 }, + { liters: 90, fuel_total_eur: 90 }, + ]) + expect(schnitt).toBeCloseTo(1.1, 2) + }) + + it("liefert 0 statt einer Division durch null", () => { + expect(schnittpreis([])).toBe(0) + expect(schnittpreis([{ liters: 0, fuel_total_eur: 10 }])).toBe(0) + }) +}) + +describe("summe", () => { + it("überspringt fehlende Werte", () => { + expect(summe([{ a: 1 }, { a: null }, { a: 3 }], (e) => e.a ?? 0)).toBe(4) + }) +}) + +describe("isoTag", () => { + it("gibt das lokale Datum aus, nicht das UTC-Datum", () => { + // 23:30 Ortszeit ist in UTC schon der Folgetag — der Tag darf nicht kippen. + const spaet = new Date(2026, 7, 11, 23, 30) + expect(isoTag(spaet)).toBe("2026-08-11") + }) +}) + +describe("CSV-Ausgabe", () => { + it("trennt mit Semikolon und schreibt Dezimalkomma", () => { + const csv = alsCsv(["Strecke", "Ort"], [[42.5, "München"]]) + expect(csv).toContain("Strecke;Ort") + expect(csv).toContain("42,5;München") + }) + + it("beginnt mit einem BOM, damit Excel die Umlaute richtig liest", () => { + expect(alsCsv(["a"], [])[0]).toBe("") + }) + + it("schützt Felder mit Semikolon oder Anführungszeichen", () => { + const csv = alsCsv(["Text"], [['Er sagte "hallo"; dann ging er']]) + expect(csv).toContain('"Er sagte ""hallo""; dann ging er"') + }) +}) + +describe("Kalenderdatei", () => { + it("erzeugt einen ganztägigen Termin mit Erinnerung zwei Tage vorher", () => { + const ics = kalenderDatei("Ölwechsel", "2027-03-14", "laut Prognose") + expect(ics).toContain("BEGIN:VCALENDAR") + expect(ics).toContain("DTSTART;VALUE=DATE:20270314") + expect(ics).toContain("SUMMARY:Ölwechsel") + expect(ics).toContain("TRIGGER:-P2D") + expect(ics.trimEnd().endsWith("END:VCALENDAR")).toBe(true) + }) + + it("schützt Sonderzeichen im Titel", () => { + expect(kalenderDatei("Service; Öl, Filter", "2027-01-01")).toContain( + "SUMMARY:Service\\; Öl\\, Filter", + ) + }) +})