202 lines
6.3 KiB
TypeScript
202 lines
6.3 KiB
TypeScript
|
|
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> = {}
|
||
|
|
): 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);
|
||
|
|
});
|
||
|
|
});
|