← back to Charge And Explore

backend/test/recommend.test.ts

99 lines

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { recommendStops, type StopCandidate } from "../src/recommend.ts";
import { defaultChargeCurve } from "../src/core/charge-estimate.ts";

const future = new Date("2026-08-01T12:00:00Z");

const candidate = (over: Partial<StopCandidate>): StopCandidate => ({
  stationId: "s1",
  name: "Stop 1",
  detourMinutes: 3,
  charger: { power: 90, stalls: 80, reliability: 80, dynamicAvailability: 70 },
  amenity: 80,
  nighttime: 70,
  walkability: 80,
  basicNeeds: 90,
  dataCompleteness: 0.9,
  charge: {
    // A realistic ~35-min road-trip stop (slower charger / bigger fill) so a
    // café run legitimately fits the conservative window.
    arrivalSocPercent: 20,
    targetSocPercent: 80,
    usableBatteryKWh: 80,
    vehicleMaxDcKw: 250,
    stationMaxKw: 120,
    chargingCurve: defaultChargeCurve,
    environmentFactor: 1,
    conversionEfficiency: 0.92,
    connectionOverheadMinutes: 2,
  },
  activities: [
    {
      placeId: "cafe",
      distanceMeters: 320,
      walkOutMinutes: 4,
      walkBackMinutes: 4,
      suggestedVisitMinutes: 15,
      openThrough: null,
      preferenceScore: 80,
      qualityScore: 80,
      accessibilityScore: 80,
      weatherFitScore: 80,
      dataFreshnessScore: 80,
    },
  ],
  expectedReturnAt: future,
  ...over,
});

describe("recommendStops", () => {
  it("ranks a low-detour, high-charger stop above a bad one", () => {
    const ranked = recommendStops([
      candidate({ stationId: "good", detourMinutes: 2 }),
      candidate({
        stationId: "bad",
        detourMinutes: 40,
        charger: { power: 20, stalls: 20, reliability: 20, dynamicAvailability: 0 },
        basicNeeds: 0,
      }),
    ]);
    assert.equal(ranked[0]?.stationId, "good");
    assert.ok((ranked[0]?.stop.score ?? 0) > (ranked[1]?.stop.score ?? 0));
  });

  it("surfaces a charge range and a fitting activity with distance", () => {
    const [r] = recommendStops([candidate({})]);
    assert.ok(r && r.chargeMinutes.low <= r.chargeMinutes.high);
    assert.equal(r?.noActivityFits, false);
    assert.equal(r?.activities[0]?.placeId, "cafe");
    assert.equal(r?.activities[0]?.fits, true);
    assert.equal(r?.activities[0]?.distanceMeters, 320);
  });

  it("flags when no activity fits the window", () => {
    const [r] = recommendStops([
      candidate({ activities: [
        {
          placeId: "toolong",
          distanceMeters: 900,
          walkOutMinutes: 10,
          walkBackMinutes: 10,
          suggestedVisitMinutes: 120,
          openThrough: null,
          preferenceScore: 80,
          qualityScore: 80,
          accessibilityScore: 80,
          weatherFitScore: 80,
          dataFreshnessScore: 80,
        },
      ] }),
    ]);
    assert.equal(r?.noActivityFits, true);
    // the activity is still LISTED (with distance), just flagged as not fitting
    assert.equal(r?.activities.length, 1);
    assert.equal(r?.activities[0]?.fits, false);
    assert.equal(r?.activities[0]?.distanceMeters, 900);
  });
});