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.
This commit is contained in:
Paul Nothaft
2026-08-11 11:21:56 +02:00
parent 571d12310f
commit a68817065b
4 changed files with 451 additions and 60 deletions
+55 -60
View File
@@ -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
+94
View File
@@ -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()
})
})
+199
View File
@@ -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<Fahrt> & { 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<Tankvorgang> & { 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"])
})
})
+103
View File
@@ -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",
)
})
})