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, }); }); });