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:
+55
-60
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user