← back to Charge And Explore
backend/test/parked-periods.test.ts
120 lines
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { detectParkedPeriods, summarizeParked, type ParkedPoint } from "../src/core/parked-periods.ts";
const MIN = 60_000, HOUR = 3_600_000;
const T0 = 1_754_000_000_000; // fixed base time — deterministic tests
const pt = (over: Partial<ParkedPoint>): ParkedPoint => ({
t: T0, soc: 80, range: 240, charging: false, o: 12_000, ...over,
});
describe("detectParkedPeriods — grouping", () => {
it("groups a still-odometer overnight run into one period, bridging the asleep gap", () => {
const pts = [
pt({ t: T0, soc: 80 }),
pt({ t: T0 + HOUR, soc: 79, range: 237 }),
pt({ t: T0 + 9 * HOUR, soc: 77, range: 231 }), // 8h silent (car asleep) — same period
];
const p = detectParkedPeriods(pts);
assert.equal(p.length, 1);
assert.equal(p[0]!.socFrom, 80);
assert.equal(p[0]!.socTo, 77);
assert.equal(p[0]!.socDrop, 3);
assert.equal(p[0]!.hours, 9);
assert.equal(p[0]!.rangeLostMiles, 9);
assert.equal(p[0]!.drainPctPerDay, 8); // 3% over 9h = 8%/day
});
it("an odometer change splits into separate periods (the drive is excluded)", () => {
const pts = [
pt({ t: T0, soc: 80 }),
pt({ t: T0 + 2 * HOUR, soc: 79 }),
pt({ t: T0 + 3 * HOUR, soc: 70, o: 12_050 }), // drove 50 mi — SOC drop is NOT drain
pt({ t: T0 + 6 * HOUR, soc: 69, o: 12_050 }),
];
const p = detectParkedPeriods(pts);
assert.equal(p.length, 2);
// newest first
assert.equal(p[0]!.odometerMiles, 12_050);
assert.equal(p[0]!.socDrop, 1);
assert.equal(p[1]!.odometerMiles, 12_000);
assert.equal(p[1]!.socDrop, 1);
});
it("charging points break a parked run and never join one", () => {
const pts = [
pt({ t: T0, soc: 60 }),
pt({ t: T0 + HOUR, soc: 59 }),
pt({ t: T0 + 2 * HOUR, soc: 70, charging: true }),
pt({ t: T0 + 3 * HOUR, soc: 80, charging: true }),
pt({ t: T0 + 4 * HOUR, soc: 80 }),
pt({ t: T0 + 6 * HOUR, soc: 79 }),
];
const p = detectParkedPeriods(pts);
assert.equal(p.length, 2);
assert.equal(p[0]!.socFrom, 80); // post-charge period
assert.equal(p[1]!.socFrom, 60); // pre-charge period
});
it("points without an odometer cannot prove stillness — no periods from legacy data", () => {
const pts = [
pt({ t: T0, o: undefined }),
pt({ t: T0 + HOUR, soc: 79, o: undefined }),
pt({ t: T0 + 2 * HOUR, soc: 78, o: null }),
];
assert.equal(detectParkedPeriods(pts).length, 0);
});
it("sub-minimum-duration runs are noise and are skipped", () => {
const pts = [pt({ t: T0 }), pt({ t: T0 + 10 * MIN, soc: 80 })];
assert.equal(detectParkedPeriods(pts).length, 0);
});
it("SOC regained while parked (sunny day) clamps drop to 0, never negative", () => {
const pts = [pt({ t: T0, soc: 70 }), pt({ t: T0 + 3 * HOUR, soc: 71 })];
const p = detectParkedPeriods(pts);
assert.equal(p.length, 1);
assert.equal(p[0]!.socDrop, 0);
});
it("short periods report no per-day rate (integer-SOC extrapolation guard)", () => {
const pts = [pt({ t: T0, soc: 70 }), pt({ t: T0 + HOUR, soc: 69 })];
const p = detectParkedPeriods(pts);
assert.equal(p.length, 1);
assert.equal(p[0]!.drainPctPerDay, null);
});
it("flags sentry when any sample in the period had it on", () => {
const pts = [
pt({ t: T0, soc: 70, s: false }),
pt({ t: T0 + HOUR, soc: 69, s: true }),
pt({ t: T0 + 2 * HOUR, soc: 68, s: false }),
];
assert.equal(detectParkedPeriods(pts)[0]!.sentryOn, true);
});
});
describe("summarizeParked", () => {
it("time-weights the average drain rate across periods", () => {
const periods = detectParkedPeriods([
pt({ t: T0, soc: 80 }),
pt({ t: T0 + 12 * HOUR, soc: 74 }), // 6% over 12h
pt({ t: T0 + 13 * HOUR, soc: 70, o: 12_010 }), // drive
pt({ t: T0 + 25 * HOUR, soc: 68, o: 12_010 }), // 2% over 12h
]);
const s = summarizeParked(periods);
assert.equal(s.periods, 2);
assert.equal(s.totalHours, 24);
assert.equal(s.totalSocDrop, 8);
assert.equal(s.avgDrainPctPerDay, 8); // 8% over exactly one day of parked time
});
it("returns nulls, not NaN, on empty input", () => {
const s = summarizeParked([]);
assert.equal(s.periods, 0);
assert.equal(s.avgDrainPctPerDay, null);
assert.equal(s.totalRangeLostMiles, null);
});
});