← 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);
  });
});