ca62fb8ef6
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 30s
CI / Lint & Typecheck (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 43s
CI / Build & Push Docker Images (pull_request) Successful in 27s
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
haversineKm,
|
|
estimateLeg,
|
|
nearestNeighborOrder,
|
|
optimizeRoute,
|
|
detectScheduleConflicts,
|
|
recomputeLegsForOrder,
|
|
MAX_STOPS_PER_ROUTE,
|
|
type RouteStopInput,
|
|
} from "../services/routeOptimization.js";
|
|
import type { FetchLike } from "../services/geocoding.js";
|
|
|
|
/** Builds a fake fetch returning a single JSON body, recording called URLs. */
|
|
function fakeFetch(
|
|
body: unknown,
|
|
init: { ok?: boolean; status?: number; statusText?: string } = {}
|
|
): { fetchImpl: FetchLike; calls: string[] } {
|
|
const calls: string[] = [];
|
|
const fetchImpl: FetchLike = async (url) => {
|
|
calls.push(url);
|
|
return {
|
|
ok: init.ok ?? true,
|
|
status: init.status ?? 200,
|
|
statusText: init.statusText ?? "OK",
|
|
json: async () => body,
|
|
};
|
|
};
|
|
return { fetchImpl, calls };
|
|
}
|
|
|
|
function stop(appointmentId: string, lat: number, lng: number): RouteStopInput {
|
|
return { appointmentId, latitude: lat, longitude: lng };
|
|
}
|
|
|
|
describe("haversineKm", () => {
|
|
it("is zero for the same point", () => {
|
|
expect(haversineKm({ latitude: 40, longitude: -74 }, { latitude: 40, longitude: -74 })).toBe(0);
|
|
});
|
|
|
|
it("approximates 1 degree of latitude as ~111km", () => {
|
|
const d = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 1, longitude: 0 });
|
|
expect(d).toBeGreaterThan(110);
|
|
expect(d).toBeLessThan(112);
|
|
});
|
|
});
|
|
|
|
describe("estimateLeg", () => {
|
|
it("applies the circuity factor and average speed", () => {
|
|
const a = { latitude: 0, longitude: 0 };
|
|
const b = { latitude: 0, longitude: 1 };
|
|
const { distanceKm, mins } = estimateLeg(a, b);
|
|
// ~111km straight * 1.3 circuity ≈ 144.6km; at 40km/h ≈ 217 min
|
|
expect(distanceKm).toBeGreaterThan(140);
|
|
expect(distanceKm).toBeLessThan(150);
|
|
expect(mins).toBeGreaterThan(200);
|
|
expect(mins).toBeLessThan(230);
|
|
expect(Number.isInteger(mins)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("nearestNeighborOrder", () => {
|
|
it("returns trivial order for 0 or 1 points", () => {
|
|
expect(nearestNeighborOrder([])).toEqual([]);
|
|
expect(nearestNeighborOrder([{ latitude: 1, longitude: 1 }])).toEqual([0]);
|
|
});
|
|
|
|
it("greedily visits the nearest unvisited point", () => {
|
|
// Points on a line; scrambled input order.
|
|
const points = [
|
|
{ latitude: 0, longitude: 0 }, // 0 (start)
|
|
{ latitude: 0, longitude: 5 }, // 1 (far)
|
|
{ latitude: 0, longitude: 1 }, // 2
|
|
{ latitude: 0, longitude: 2 }, // 3
|
|
];
|
|
expect(nearestNeighborOrder(points, 0)).toEqual([0, 2, 3, 1]);
|
|
});
|
|
});
|
|
|
|
describe("optimizeRoute — nearest-neighbor fallback (no API key)", () => {
|
|
it("returns an empty route for no stops", async () => {
|
|
const r = await optimizeRoute([]);
|
|
expect(r.stops).toHaveLength(0);
|
|
expect(r.totalTravelMins).toBe(0);
|
|
expect(r.totalDistanceKm).toBe(0);
|
|
expect(r.provider).toBe("nearest_neighbor");
|
|
expect(r.chunked).toBe(false);
|
|
});
|
|
|
|
it("handles a single stop with null travel-from-prev", async () => {
|
|
const r = await optimizeRoute([stop("a", 40, -74)]);
|
|
expect(r.stops).toHaveLength(1);
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
expect(r.stops[0]!.travelDistanceKmFromPrev).toBeNull();
|
|
expect(r.totalTravelMins).toBe(0);
|
|
});
|
|
|
|
it("orders multiple stops greedily and sums totals", async () => {
|
|
const stops = [
|
|
stop("start", 0, 0),
|
|
stop("far", 0, 5),
|
|
stop("near1", 0, 1),
|
|
stop("near2", 0, 2),
|
|
];
|
|
const r = await optimizeRoute(stops);
|
|
expect(r.provider).toBe("nearest_neighbor");
|
|
expect(r.stops.map((s) => s.appointmentId)).toEqual([
|
|
"start",
|
|
"near1",
|
|
"near2",
|
|
"far",
|
|
]);
|
|
// First stop has no inbound leg.
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
// Remaining stops have positive travel.
|
|
for (const s of r.stops.slice(1)) {
|
|
expect(s.travelMinsFromPrev!).toBeGreaterThan(0);
|
|
expect(s.travelDistanceKmFromPrev!).toBeGreaterThan(0);
|
|
}
|
|
const summed = r.stops.reduce((acc, s) => acc + (s.travelMinsFromPrev ?? 0), 0);
|
|
expect(r.totalTravelMins).toBe(summed);
|
|
});
|
|
});
|
|
|
|
describe("optimizeRoute — Google Directions path", () => {
|
|
it("uses optimized waypoint order and real leg metrics, dropping the return leg", async () => {
|
|
const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)];
|
|
// waypoints = [B, C]; optimizer reorders them to [C, B] (waypoint_order [1,0]).
|
|
// legs: A->C, C->B, B->A(return). The return leg must be dropped.
|
|
const { fetchImpl, calls } = fakeFetch({
|
|
status: "OK",
|
|
routes: [
|
|
{
|
|
waypoint_order: [1, 0],
|
|
legs: [
|
|
{ distance: { value: 2000 }, duration: { value: 600 } }, // A->C
|
|
{ distance: { value: 1000 }, duration: { value: 300 } }, // C->B
|
|
{ distance: { value: 3000 }, duration: { value: 900 } }, // B->A (return, dropped)
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl });
|
|
expect(r.provider).toBe("google");
|
|
expect(r.stops.map((s) => s.appointmentId)).toEqual(["A", "C", "B"]);
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
expect(r.stops[1]!.travelDistanceKmFromPrev).toBe(2); // 2000m -> 2km
|
|
expect(r.stops[1]!.travelMinsFromPrev).toBe(10); // 600s -> 10min
|
|
expect(r.stops[2]!.travelDistanceKmFromPrev).toBe(1); // 1000m
|
|
expect(r.stops[2]!.travelMinsFromPrev).toBe(5); // 300s
|
|
expect(r.totalDistanceKm).toBe(3);
|
|
expect(r.totalTravelMins).toBe(15);
|
|
expect(decodeURIComponent(calls[0]!)).toContain("optimize:true");
|
|
});
|
|
|
|
it("falls back to the heuristic when Google returns a non-OK status", async () => {
|
|
const stops = [stop("A", 0, 0), stop("B", 0, 1), stop("C", 0, 2)];
|
|
const { fetchImpl } = fakeFetch({ status: "REQUEST_DENIED", error_message: "bad key" });
|
|
const r = await optimizeRoute(stops, { googleApiKey: "key", fetchImpl });
|
|
// Provider label reflects the chosen strategy (google requested) but a
|
|
// warning records the degradation and stops are still ordered.
|
|
expect(r.stops).toHaveLength(3);
|
|
expect(r.warnings.some((w) => w.includes("offline heuristic"))).toBe(true);
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("optimizeRoute — >25 stop chunking", () => {
|
|
it("splits into sub-routes with a warning and continuous stop ordering", async () => {
|
|
const stops: RouteStopInput[] = [];
|
|
for (let i = 0; i < MAX_STOPS_PER_ROUTE + 5; i++) {
|
|
stops.push(stop(`s${i}`, 0, i * 0.1));
|
|
}
|
|
const r = await optimizeRoute(stops);
|
|
expect(r.chunked).toBe(true);
|
|
expect(r.subRouteCount).toBe(2);
|
|
expect(r.warnings.some((w) => w.includes("sub-routes"))).toBe(true);
|
|
expect(r.stops).toHaveLength(MAX_STOPS_PER_ROUTE + 5);
|
|
// Only the very first stop of the whole route lacks an inbound leg.
|
|
expect(r.stops[0]!.travelMinsFromPrev).toBeNull();
|
|
expect(r.stops.slice(1).every((s) => s.travelMinsFromPrev !== null)).toBe(true);
|
|
// All appointment ids preserved exactly once.
|
|
expect(new Set(r.stops.map((s) => s.appointmentId)).size).toBe(stops.length);
|
|
});
|
|
});
|
|
|
|
describe("detectScheduleConflicts", () => {
|
|
const at = (iso: string) => new Date(iso);
|
|
|
|
it("returns no conflict and null gaps for an empty or single-stop route", () => {
|
|
expect(detectScheduleConflicts([])).toEqual([]);
|
|
const one = detectScheduleConflicts([
|
|
{
|
|
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
|
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
|
travelMinsFromPrev: null,
|
|
bufferMins: 15,
|
|
},
|
|
]);
|
|
expect(one).toEqual([
|
|
{
|
|
hasConflict: false,
|
|
scheduleGapMins: null,
|
|
requiredGapMins: null,
|
|
shortfallMins: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("flags a tight schedule when gap < travel + buffer", () => {
|
|
// Stop 1 ends 10:00, stop 2 starts 10:20 → 20min gap. Travel 15 + buffer 15
|
|
// = 30 required → shortfall 10 → conflict.
|
|
const flags = detectScheduleConflicts([
|
|
{
|
|
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
|
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
|
travelMinsFromPrev: null,
|
|
bufferMins: 0,
|
|
},
|
|
{
|
|
appointmentStartTime: at("2026-06-08T10:20:00Z"),
|
|
appointmentEndTime: at("2026-06-08T11:00:00Z"),
|
|
travelMinsFromPrev: 15,
|
|
bufferMins: 15,
|
|
},
|
|
]);
|
|
expect(flags[0]!.hasConflict).toBe(false);
|
|
expect(flags[1]).toEqual({
|
|
hasConflict: true,
|
|
scheduleGapMins: 20,
|
|
requiredGapMins: 30,
|
|
shortfallMins: 10,
|
|
});
|
|
});
|
|
|
|
it("does not flag when the gap comfortably covers travel + buffer", () => {
|
|
// 90min gap, 15 travel + 15 buffer = 30 required → 60 slack → no conflict.
|
|
const flags = detectScheduleConflicts([
|
|
{
|
|
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
|
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
|
travelMinsFromPrev: null,
|
|
bufferMins: 0,
|
|
},
|
|
{
|
|
appointmentStartTime: at("2026-06-08T11:30:00Z"),
|
|
appointmentEndTime: at("2026-06-08T12:00:00Z"),
|
|
travelMinsFromPrev: 15,
|
|
bufferMins: 15,
|
|
},
|
|
]);
|
|
expect(flags[1]).toEqual({
|
|
hasConflict: false,
|
|
scheduleGapMins: 90,
|
|
requiredGapMins: 30,
|
|
shortfallMins: -60,
|
|
});
|
|
});
|
|
|
|
it("treats a null travelMinsFromPrev as zero travel", () => {
|
|
const flags = detectScheduleConflicts([
|
|
{
|
|
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
|
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
|
travelMinsFromPrev: null,
|
|
bufferMins: 0,
|
|
},
|
|
{
|
|
appointmentStartTime: at("2026-06-08T10:05:00Z"),
|
|
appointmentEndTime: at("2026-06-08T11:00:00Z"),
|
|
travelMinsFromPrev: null,
|
|
bufferMins: 15,
|
|
},
|
|
]);
|
|
// 5min gap vs 0 travel + 15 buffer = 15 required → conflict, shortfall 10.
|
|
expect(flags[1]!.hasConflict).toBe(true);
|
|
expect(flags[1]!.requiredGapMins).toBe(15);
|
|
expect(flags[1]!.shortfallMins).toBe(10);
|
|
});
|
|
|
|
it("flags overlapping appointments (negative gap) as conflicts", () => {
|
|
const flags = detectScheduleConflicts([
|
|
{
|
|
appointmentStartTime: at("2026-06-08T09:00:00Z"),
|
|
appointmentEndTime: at("2026-06-08T10:00:00Z"),
|
|
travelMinsFromPrev: null,
|
|
bufferMins: 0,
|
|
},
|
|
{
|
|
appointmentStartTime: at("2026-06-08T09:30:00Z"),
|
|
appointmentEndTime: at("2026-06-08T10:30:00Z"),
|
|
travelMinsFromPrev: 10,
|
|
bufferMins: 15,
|
|
},
|
|
]);
|
|
expect(flags[1]!.scheduleGapMins).toBe(-30);
|
|
expect(flags[1]!.hasConflict).toBe(true);
|
|
expect(flags[1]!.shortfallMins).toBe(55);
|
|
});
|
|
});
|
|
|
|
describe("recomputeLegsForOrder", () => {
|
|
it("returns null travel for an empty or single-point order", () => {
|
|
expect(recomputeLegsForOrder([])).toEqual([]);
|
|
expect(recomputeLegsForOrder([{ latitude: 40, longitude: -74 }])).toEqual([
|
|
{ travelMinsFromPrev: null, travelDistanceKmFromPrev: null },
|
|
]);
|
|
});
|
|
|
|
it("estimates each leg for the fixed given order without reordering", () => {
|
|
const pts = [
|
|
{ latitude: 0, longitude: 0 },
|
|
{ latitude: 0, longitude: 1 },
|
|
{ latitude: 0, longitude: 2 },
|
|
];
|
|
const legs = recomputeLegsForOrder(pts);
|
|
expect(legs).toHaveLength(3);
|
|
expect(legs[0]).toEqual({
|
|
travelMinsFromPrev: null,
|
|
travelDistanceKmFromPrev: null,
|
|
});
|
|
// Each leg equals estimateLeg between adjacent points (no optimization).
|
|
const e01 = estimateLeg(pts[0]!, pts[1]!);
|
|
const e12 = estimateLeg(pts[1]!, pts[2]!);
|
|
expect(legs[1]).toEqual({
|
|
travelMinsFromPrev: e01.mins,
|
|
travelDistanceKmFromPrev: e01.distanceKm,
|
|
});
|
|
expect(legs[2]).toEqual({
|
|
travelMinsFromPrev: e12.mins,
|
|
travelDistanceKmFromPrev: e12.distanceKm,
|
|
});
|
|
});
|
|
});
|