import { describe, it, expect } from "vitest"; import { detectStops, DEFAULT_STOP_RADIUS_METRES, DEFAULT_MIN_STOP_MINUTES, type Breadcrumb, } from "./visit-stops"; /** Sibanye Driefontein-ish, matching the rest of the geo fixtures. */ const BASE_LAT = -26.4; const BASE_LON = 27.4; /** Metres → degrees of latitude (1° ≈ 111,195 m on a 6371 km sphere). */ function metresNorth(metres: number): number { return BASE_LAT + metres / 111195; } function crumb( minutesFromStart: number, over: Partial = {} ): Breadcrumb { return { fixedAt: new Date( Date.UTC(2026, 8, 4, 7, 0, 0) + minutesFromStart * 60_000 ).toISOString(), lat: BASE_LAT, lon: BASE_LON, ignition: false, speedKmh: 0, ...over, }; } describe("detectStops", () => { it("returns nothing for an empty trail", () => { expect(detectStops([])).toEqual([]); }); it("returns nothing for a single fix — a point has no duration", () => { expect(detectStops([crumb(0)])).toEqual([]); }); it("collapses a parked vehicle into one stop", () => { const stops = detectStops([crumb(0), crumb(5), crumb(10), crumb(15)]); expect(stops).toHaveLength(1); expect(stops[0]).toMatchObject({ durationMinutes: 15, fixCount: 4, startedAt: "2026-09-04T07:00:00.000Z", endedAt: "2026-09-04T07:15:00.000Z", }); }); it("absorbs GPS jitter rather than splitting a stop into several", () => { // A stationary tracker wanders tens of metres between fixes. Without // a radius this would read as four separate visits. const stops = detectStops([ crumb(0, { lat: metresNorth(0) }), crumb(5, { lat: metresNorth(40) }), crumb(10, { lat: metresNorth(-30) }), crumb(15, { lat: metresNorth(25) }), ]); expect(stops).toHaveLength(1); expect(stops[0].fixCount).toBe(4); }); it("does not invent a stop for a vehicle driving straight through", () => { const stops = detectStops([ crumb(0, { lat: metresNorth(0), speedKmh: 60, ignition: true }), crumb(5, { lat: metresNorth(4000), speedKmh: 80, ignition: true }), crumb(10, { lat: metresNorth(9000), speedKmh: 75, ignition: true }), ]); expect(stops).toEqual([]); }); it("splits two visits separated by travel", () => { const stops = detectStops([ crumb(0), crumb(20), // 5 km away — a different site. crumb(60, { lat: metresNorth(5000) }), crumb(90, { lat: metresNorth(5000) }), ]); expect(stops).toHaveLength(2); expect(stops[0].durationMinutes).toBe(20); expect(stops[1].durationMinutes).toBe(30); }); it("drops stops shorter than the minimum — a gate queue is not a visit", () => { const stops = detectStops( [crumb(0), crumb(3)], { minDurationMinutes: 10 } ); expect(stops).toEqual([]); }); it("sorts an out-of-order trail before analysing it", () => { // PocketBase paging does not guarantee order, and a poller retry can // insert an older fix after a newer one. const stops = detectStops([crumb(15), crumb(0), crumb(10), crumb(5)]); expect(stops).toHaveLength(1); expect(stops[0].startedAt).toBe("2026-09-04T07:00:00.000Z"); expect(stops[0].endedAt).toBe("2026-09-04T07:15:00.000Z"); }); it("reports the centroid of the fixes as the stop position", () => { const stops = detectStops([ crumb(0, { lat: metresNorth(0) }), crumb(10, { lat: metresNorth(100) }), ]); expect(stops[0].lat).toBeCloseTo(metresNorth(50), 6); expect(stops[0].lon).toBeCloseTo(BASE_LON, 6); }); it("measures against the stop's anchor so a slow crawl cannot chain into one stop", () => { // Each fix is 100 m from the previous one — inside the radius — but // the vehicle covers 500 m overall. A running-centroid comparison // would drift along with it and report one long stationary stop. const stops = detectStops([ crumb(0, { lat: metresNorth(0) }), crumb(10, { lat: metresNorth(100) }), crumb(20, { lat: metresNorth(200) }), crumb(30, { lat: metresNorth(300) }), crumb(40, { lat: metresNorth(400) }), crumb(50, { lat: metresNorth(500) }), ]); expect(stops.length).toBeGreaterThan(1); }); it("flags the longest reporting gap inside a stop", () => { // Underground or in a signal shadow the tracker goes quiet. Two fixes // two hours apart at the same place *probably* mean the vehicle sat // there — but it could also have left and come back. The report has // to be able to say so rather than assert a confident 2-hour visit. const stops = detectStops([crumb(0), crumb(120), crumb(125)]); expect(stops).toHaveLength(1); expect(stops[0].longestGapMinutes).toBe(120); }); it("reports a zero gap for evenly-polled stops", () => { const stops = detectStops([crumb(0), crumb(5), crumb(10)]); expect(stops[0].longestGapMinutes).toBe(5); }); it("marks a stop as ignition-off only when every fix agrees", () => { const parked = detectStops([ crumb(0, { ignition: false }), crumb(15, { ignition: false }), ]); expect(parked[0].ignitionOff).toBe(true); const idling = detectStops([ crumb(0, { ignition: false }), crumb(15, { ignition: true }), ]); expect(idling[0].ignitionOff).toBe(false); }); it("reports ignitionOff as false when the device has no ignition wire", () => { // `--` from FleetAI parses to null. Unknown must not read as "parked", // which would overstate confidence in the visit. const stops = detectStops([ crumb(0, { ignition: null }), crumb(15, { ignition: null }), ]); expect(stops[0].ignitionOff).toBe(false); }); it("ignores fixes with an unparseable timestamp", () => { const stops = detectStops([ crumb(0), { ...crumb(5), fixedAt: "not a date" }, crumb(15), ]); expect(stops).toHaveLength(1); expect(stops[0].fixCount).toBe(2); }); it("de-duplicates repeated fixes at the same instant", () => { // The poller keys on fix time, but a retry or a clock wobble can // still land the same fix twice. const stops = detectStops([crumb(0), crumb(0), crumb(15)]); expect(stops[0].fixCount).toBe(2); }); it("exposes usable defaults", () => { expect(DEFAULT_STOP_RADIUS_METRES).toBe(150); expect(DEFAULT_MIN_STOP_MINUTES).toBe(5); }); });