← back to Charge And Explore

backend/test/charging-sessions.test.ts

159 lines

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { detectChargingSessions, type SessionPoint } from "../src/core/charging-sessions.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<SessionPoint>): SessionPoint => ({
  t: T0, soc: 50, charging: false, ...over,
});

describe("detectChargingSessions — grouping", () => {
  it("returns no sessions when nothing charged", () => {
    const pts = [pt({ t: T0 }), pt({ t: T0 + HOUR, soc: 48 })];
    assert.equal(detectChargingSessions(pts).length, 0);
  });

  it("groups a contiguous charging run into one session", () => {
    const pts = [
      pt({ t: T0, soc: 40 }),
      pt({ t: T0 + 10 * MIN, soc: 42, charging: true }),
      pt({ t: T0 + 20 * MIN, soc: 50, charging: true }),
      pt({ t: T0 + 30 * MIN, soc: 58, charging: true }),
      pt({ t: T0 + 40 * MIN, soc: 58 }),
    ];
    const s = detectChargingSessions(pts);
    assert.equal(s.length, 1);
    assert.equal(s[0]!.socFrom, 42);
    assert.equal(s[0]!.socTo, 58);
    assert.equal(s[0]!.durationMin, 20);
  });

  it("a non-charging point between runs splits them", () => {
    const pts = [
      pt({ t: T0, soc: 40, charging: true }),
      pt({ t: T0 + 10 * MIN, soc: 45, charging: true }),
      pt({ t: T0 + 30 * MIN, soc: 45 }),
      pt({ t: T0 + 60 * MIN, soc: 44, charging: true }),
      pt({ t: T0 + 70 * MIN, soc: 55, charging: true }),
    ];
    assert.equal(detectChargingSessions(pts).length, 2);
  });

  it("a >3h gap between charging points splits sessions even with no point between", () => {
    const pts = [
      pt({ t: T0, soc: 40, charging: true }),
      pt({ t: T0 + 10 * MIN, soc: 50, charging: true }),
      pt({ t: T0 + 5 * HOUR, soc: 20, charging: true }),
      pt({ t: T0 + 5 * HOUR + 10 * MIN, soc: 30, charging: true }),
    ];
    assert.equal(detectChargingSessions(pts).length, 2);
  });

  it("orders sessions newest first and tolerates unsorted input", () => {
    const pts = [
      pt({ t: T0 + 5 * HOUR, soc: 20, charging: true }),
      pt({ t: T0, soc: 40, charging: true }),
      pt({ t: T0 + 10 * MIN, soc: 50, charging: true }),
      pt({ t: T0 + 5 * HOUR + 10 * MIN, soc: 30, charging: true }),
    ];
    const s = detectChargingSessions(pts);
    assert.equal(s.length, 2);
    assert.ok(s[0]!.startT > s[1]!.startT);
  });
});

describe("detectChargingSessions — energy", () => {
  it("uses reported charge_energy_added (max of a rising counter)", () => {
    const pts = [
      pt({ t: T0, soc: 40, charging: true, ea: 0.5 }),
      pt({ t: T0 + 30 * MIN, soc: 55, charging: true, ea: 8.2 }),
      pt({ t: T0 + 60 * MIN, soc: 70, charging: true, ea: 15.9 }),
    ];
    const s = detectChargingSessions(pts)[0]!;
    assert.equal(s.energyKWh, 15.9);
    assert.equal(s.energySource, "reported");
  });

  it("survives a mid-run counter reset (two physical sessions merged) by summing positive deltas", () => {
    const pts = [
      pt({ t: T0, soc: 40, charging: true, ea: 10 }),
      pt({ t: T0 + 30 * MIN, soc: 50, charging: true, ea: 0.4 }), // reset — new physical session
      pt({ t: T0 + 60 * MIN, soc: 60, charging: true, ea: 7.4 }),
    ];
    const s = detectChargingSessions(pts)[0]!;
    assert.equal(s.energyKWh, 17); // 10 seed + (7.4 − 0.4); the drop adds nothing
  });

  it("falls back to ΔSOC × batteryKWh when ea was never sampled", () => {
    const pts = [
      pt({ t: T0, soc: 40, charging: true }),
      pt({ t: T0 + 60 * MIN, soc: 60, charging: true }),
    ];
    const s = detectChargingSessions(pts, { batteryKWh: 75 })[0]!;
    assert.equal(s.energyKWh, 15); // 20% of 75
    assert.equal(s.energySource, "soc-estimate");
  });

  it("reports null energy with no ea and no battery size", () => {
    const pts = [
      pt({ t: T0, soc: 40, charging: true }),
      pt({ t: T0 + 60 * MIN, soc: 60, charging: true }),
    ];
    const s = detectChargingSessions(pts)[0]!;
    assert.equal(s.energyKWh, null);
    assert.equal(s.energySource, null);
  });
});

describe("detectChargingSessions — kW curve", () => {
  it("prefers recorded charger power and reports its peak", () => {
    const pts = [
      pt({ t: T0, soc: 20, charging: true, kw: 45 }),
      pt({ t: T0 + 10 * MIN, soc: 35, charging: true, kw: 150 }),
      pt({ t: T0 + 20 * MIN, soc: 50, charging: true, kw: 118 }),
    ];
    const s = detectChargingSessions(pts)[0]!;
    assert.equal(s.peakKw, 150);
    assert.equal(s.curve.length, 3);
    assert.deepEqual(s.curve.map((c) => c.kw), [45, 150, 118]);
  });

  it("derives segment power from ea deltas when kw is missing", () => {
    const pts = [
      pt({ t: T0, soc: 40, charging: true, ea: 0 }),
      pt({ t: T0 + 30 * MIN, soc: 50, charging: true, ea: 5 }), // 5 kWh in 0.5h = 10 kW
    ];
    const s = detectChargingSessions(pts)[0]!;
    assert.equal(s.curve.length, 1);
    assert.equal(s.curve[0]!.kw, 10);
  });

  it("derives segment power from ΔSOC × battery size as the last resort", () => {
    const pts = [
      pt({ t: T0, soc: 40, charging: true }),
      pt({ t: T0 + 60 * MIN, soc: 60, charging: true }), // 20% of 50 kWh in 1h = 10 kW
    ];
    const s = detectChargingSessions(pts, { batteryKWh: 50 })[0]!;
    assert.equal(s.curve[0]!.kw, 10);
  });

  it("computes average power from energy over wall time", () => {
    const pts = [
      pt({ t: T0, soc: 40, charging: true, ea: 0 }),
      pt({ t: T0 + 60 * MIN, soc: 60, charging: true, ea: 12 }),
    ];
    const s = detectChargingSessions(pts)[0]!;
    assert.equal(s.avgKw, 12);
  });

  it("a single-point session yields nulls, not NaN/crash", () => {
    const s = detectChargingSessions([pt({ t: T0, soc: 40, charging: true })])[0]!;
    assert.equal(s.durationMin, 0);
    assert.equal(s.avgKw, null);
    assert.equal(s.peakKw, null);
    assert.equal(s.curve.length, 0);
  });
});