← back to Charge And Explore

backend/test/history.test.ts

113 lines

// Vehicle-keyed history (multi-car, TK-10227). Proves two cars' samples never
// interleave, the legacy single-vin map stays readable as the default vehicle,
// and the dedupe/retention rules hold per vehicle.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
  appendHistoryPoint,
  HISTORY_MAX_POINTS,
  HISTORY_RETENTION_MS,
  type HistoryPoint,
  type HistorySample,
} from "../src/core/history.ts";

const MIN = 60 * 1000;
const T0 = 1_700_000_000_000;

function sample(vin: string, soc: number, over: Partial<HistorySample> = {}): HistorySample {
  return { vin, soc, rangeMiles: soc * 3, chargingState: "Disconnected", ...over };
}

describe("appendHistoryPoint — vehicle keying", () => {
  it("keys points under their own VIN — two cars never interleave", () => {
    const h = new Map<string, HistoryPoint[]>();
    assert.equal(appendHistoryPoint(h, sample("VINAAA", 80), T0), true);
    assert.equal(appendHistoryPoint(h, sample("VINBBB", 40), T0 + 1 * MIN), true);
    assert.equal(appendHistoryPoint(h, sample("VINAAA", 79), T0 + 10 * MIN), true);
    assert.equal(appendHistoryPoint(h, sample("VINBBB", 39), T0 + 11 * MIN), true);
    assert.deepEqual([...h.keys()].sort(), ["VINAAA", "VINBBB"]);
    assert.deepEqual(h.get("VINAAA")!.map((p) => p.soc), [80, 79]);
    assert.deepEqual(h.get("VINBBB")!.map((p) => p.soc), [40, 39]);
  });

  it("car B arriving within car A's dedupe window still records (independent spacing)", () => {
    const h = new Map<string, HistoryPoint[]>();
    appendHistoryPoint(h, sample("VINAAA", 80), T0);
    // 1 min later, same SOC on car A → deduped; brand-new car B → recorded.
    assert.equal(appendHistoryPoint(h, sample("VINAAA", 80), T0 + 1 * MIN), false);
    assert.equal(appendHistoryPoint(h, sample("VINBBB", 80), T0 + 1 * MIN), true);
    assert.equal(h.get("VINAAA")!.length, 1);
    assert.equal(h.get("VINBBB")!.length, 1);
  });

  it("a pre-multi-car map (one vin) keeps its points untouched when a second car appears", () => {
    const legacy: HistoryPoint[] = [
      { t: T0 - 2 * 24 * 60 * MIN, soc: 55, range: 165, charging: false },
      { t: T0 - 1 * 24 * 60 * MIN, soc: 52, range: 156, charging: false },
    ];
    const h = new Map<string, HistoryPoint[]>([["VINAAA", [...legacy]]]);
    appendHistoryPoint(h, sample("VINBBB", 90), T0);
    assert.deepEqual(h.get("VINAAA"), legacy); // untouched — non-destructive
    assert.equal(h.get("VINBBB")!.length, 1);
  });

  it("retention prunes per vehicle — one car's old points never trim another's", () => {
    const h = new Map<string, HistoryPoint[]>();
    appendHistoryPoint(h, sample("VINAAA", 70), T0 - HISTORY_RETENTION_MS - 24 * 60 * MIN);
    appendHistoryPoint(h, sample("VINBBB", 60), T0 - 60 * MIN);
    // fresh point on A prunes ONLY A's expired point; B untouched
    appendHistoryPoint(h, sample("VINAAA", 68), T0);
    assert.deepEqual(h.get("VINAAA")!.map((p) => p.soc), [68]);
    assert.deepEqual(h.get("VINBBB")!.map((p) => p.soc), [60]);
  });

  it("caps at HISTORY_MAX_POINTS per vehicle", () => {
    const h = new Map<string, HistoryPoint[]>();
    const pts: HistoryPoint[] = Array.from({ length: HISTORY_MAX_POINTS }, (_, i) => ({
      t: T0 - (HISTORY_MAX_POINTS - i) * MIN, soc: 50, range: 150, charging: false,
    }));
    h.set("VINAAA", pts);
    appendHistoryPoint(h, sample("VINAAA", 51), T0 + 10 * MIN);
    assert.equal(h.get("VINAAA")!.length, HISTORY_MAX_POINTS);
    assert.equal(h.get("VINAAA")![HISTORY_MAX_POINTS - 1]!.soc, 51);
  });
});

describe("appendHistoryPoint — recording rules (unchanged from single-car)", () => {
  it("skips when soc is missing or vin is empty", () => {
    const h = new Map<string, HistoryPoint[]>();
    assert.equal(appendHistoryPoint(h, sample("VINAAA", null as unknown as number), T0), false);
    assert.equal(appendHistoryPoint(h, sample("", 50), T0), false);
    assert.equal(h.size, 0);
  });

  it("parked: dedupes within 5 min at the same SOC, records on SOC change", () => {
    const h = new Map<string, HistoryPoint[]>();
    appendHistoryPoint(h, sample("VINAAA", 80), T0);
    assert.equal(appendHistoryPoint(h, sample("VINAAA", 80), T0 + 4 * MIN), false);
    assert.equal(appendHistoryPoint(h, sample("VINAAA", 79), T0 + 4 * MIN), true);
  });

  it("charging: 2-min spacing even when SOC changed, kw/ea ride along", () => {
    const h = new Map<string, HistoryPoint[]>();
    const chg = (soc: number): HistorySample =>
      sample("VINAAA", soc, { chargingState: "Charging", stats: { chargerPowerKw: 48, energyAddedKWh: 7.5 } });
    appendHistoryPoint(h, chg(50), T0);
    assert.equal(appendHistoryPoint(h, chg(51), T0 + 1 * MIN), false); // <2 min
    assert.equal(appendHistoryPoint(h, chg(52), T0 + 3 * MIN), true);
    const last = h.get("VINAAA")!.at(-1)!;
    assert.equal(last.charging, true);
    assert.equal(last.kw, 48);
    assert.equal(last.ea, 7.5);
  });

  it("odometer + sentry ride along on every point when present", () => {
    const h = new Map<string, HistoryPoint[]>();
    appendHistoryPoint(h, sample("VINAAA", 66, { stats: { odometerMiles: 12345, sentry: true } }), T0);
    const p = h.get("VINAAA")![0]!;
    assert.equal(p.o, 12345);
    assert.equal(p.s, true);
    assert.equal(p.kw, undefined); // not charging → no kw/ea
  });
});