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
+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",
)
})
})