← back to Charge And Explore

backend/test/activity-match.test.ts

85 lines

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
  returnBuffer,
  scoreActivity,
  rankActivities,
  type ActivityCandidate,
} from "../src/core/activity-match.ts";

const candidate = (over: Partial<ActivityCandidate>): ActivityCandidate => ({
  placeId: "p1",
  distanceMeters: 400,
  walkOutMinutes: 5,
  walkBackMinutes: 5,
  suggestedVisitMinutes: 15,
  openThrough: null,
  preferenceScore: 80,
  qualityScore: 80,
  accessibilityScore: 80,
  weatherFitScore: 80,
  dataFreshnessScore: 80,
  ...over,
});

describe("returnBuffer", () => {
  it("is at least 5 minutes", () => {
    assert.equal(returnBuffer(10), 5);
  });
  it("is 20% of a large window", () => {
    assert.equal(returnBuffer(60), 12);
  });
});

describe("scoreActivity", () => {
  const future = new Date("2026-08-01T12:00:00Z");

  it("returns null when the activity cannot fit the window", () => {
    // 5+5+40 visit + buffer >> 30-min window
    const r = scoreActivity(candidate({ suggestedVisitMinutes: 40 }), 30, future);
    assert.equal(r, null);
  });

  it("returns a score when it fits", () => {
    const r = scoreActivity(candidate({}), 40, future);
    assert.ok(typeof r === "number" && r > 0);
  });

  it("returns null when the place closes before expected return", () => {
    const closesSoon = new Date("2026-08-01T11:00:00Z");
    const r = scoreActivity(candidate({ openThrough: closesSoon }), 40, future);
    assert.equal(r, null);
  });

  it("uses the conservative buffer (leaves margin before car is done)", () => {
    // window 30, walk 5+5, visit 12, buffer 6 => required 28 <= 30 fits
    const fits = scoreActivity(candidate({ suggestedVisitMinutes: 12 }), 30, future);
    // visit 15 => required 31 > 30 does NOT fit
    const doesnt = scoreActivity(candidate({ suggestedVisitMinutes: 15 }), 30, future);
    assert.ok(fits !== null && doesnt === null);
  });
});

describe("rankActivities", () => {
  const future = new Date("2026-08-01T12:00:00Z");

  it("drops non-fitting candidates and sorts best-first", () => {
    const ranked = rankActivities(
      [
        candidate({ placeId: "good", suggestedVisitMinutes: 20, preferenceScore: 100 }),
        candidate({ placeId: "toolong", suggestedVisitMinutes: 90 }),
        candidate({ placeId: "ok", suggestedVisitMinutes: 20, preferenceScore: 40 }),
      ],
      45,
      future,
    );
    assert.equal(ranked.length, 2);
    assert.equal(ranked[0]?.placeId, "good");
  });

  it("returns empty when nothing fits (caller should suggest a plain break)", () => {
    const ranked = rankActivities([candidate({ suggestedVisitMinutes: 120 })], 20, future);
    assert.deepEqual(ranked, []);
  });
});