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
|
Private Fahrzeug-App für einen Audi RS 4 Avant competition. Läuft als Web-App
|
||||||
Capacitor, and embedded as a plain iframe in the Home Assistant dashboard. Architecture and the
|
im Browser, als PWA auf dem Homescreen, später über Capacitor nativ auf
|
||||||
decisions behind it: [`../COMPANION_APP_ARCHITECTURE.md`](../COMPANION_APP_ARCHITECTURE.md).
|
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
|
Architektur: `../COMPANION_APP_ARCHITECTURE.md` · Umsetzungsschritte:
|
||||||
([`../DESIGN_BRIEF_DATAMETRIC360.md`](../DESIGN_BRIEF_DATAMETRIC360.md)), and get implemented on top
|
`../UMSETZUNGSPLAN.md` · Projektstand: `../AGENTS.md`
|
||||||
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.
|
|
||||||
|
|
||||||
Not yet added (deliberately, they'd be guesses today): React/Vite, Capacitor, the native secure-storage
|
## Loslegen
|
||||||
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).
|
|
||||||
|
|
||||||
## Layout
|
```bash
|
||||||
|
npm install # im Repo-Wurzelverzeichnis, nicht hier (npm-Workspace)
|
||||||
```
|
npm run build:ds # Design-System bauen, die App importiert aus dessen dist/
|
||||||
src/api/
|
npm run dev --workspace datametric360
|
||||||
├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
| Ordner | Inhalt |
|
||||||
frontend, which is exactly why the old panel can't run as a standalone app. The mapping:
|
|
||||||
|
|
||||||
| Old panel | Here |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `hass.states[id].attributes.daten` | `rest.datenLesen(id)` / `DataMetricApi.profilLesen()` etc. |
|
| `src/api/` | Datenschicht: REST, WebSocket mit Wiederverbindung, Offline-Warteschlange, Zugangsdaten |
|
||||||
| `hass.callService(...)` | `warteschlange.einreihen(...)` via the `DataMetricApi` methods |
|
| `src/daten/` | Fachlogik: Profil-Umrechnung, Statistik, Service-Prognose, Datenkontext |
|
||||||
| automatic re-render on state push | `live.aufZustand(...)` |
|
| `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
|
## Prüfen
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```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
|
`smoke:auth` liest Adresse und Token aus `.env.local` (schreibt
|
||||||
npm run typecheck
|
`../testumgebung/aufsetzen.sh`, gitignored). Ohne die Datei überspringt der
|
||||||
```
|
Test sich selbst, statt fehlzuschlagen.
|
||||||
|
|
||||||
```bash
|
Die Rendertests decken alle 21 Seiten in beiden Layouts ab — schmal mit
|
||||||
npm run smoke
|
Tab-Leiste, breit mit Seitenleiste.
|
||||||
```
|
|
||||||
|
|
||||||
`smoke` targets `http://localhost:18123` (the `audi_ha_test` Docker container) by default; pass a
|
## Rechtlicher Hinweis
|
||||||
different base URL as the first argument.
|
|
||||||
|
|
||||||
## 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`.
|
Daraus folgt: **Die App wird nur seitlich installiert, nie in einem App Store
|
||||||
All 7 smoke checks pass against the running test container, covering URL normalisation, the
|
veröffentlicht**, und dieses Repository bleibt privat. `../design-system/`
|
||||||
http→ws/https→wss mapping, that `GET /api/` exists and rejects an unauthenticated request with 401,
|
kommt bewusst ohne diese Dateien aus, weil es nach außen hochgeladen wird —
|
||||||
and that the WebSocket opens with `auth_required` — which is the message `live.ts`'s whole auth flow
|
dort niemals Markendateien ablegen.
|
||||||
is built around.
|
|
||||||
|
|
||||||
**Not yet verified — needs a token:** authenticated reads, service calls, and the queue's real
|
## Noch offen
|
||||||
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
|
- Capacitor-Hülle und sichere Ablage des Tokens (Keychain/Keystore)
|
||||||
extend `scripts/smoke.ts` with authenticated cases.
|
- 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