WFDASH-WEBAPP/src/lib/visit-matching.test.ts
Dave 6fd11f4af0 feat(rep-visits): FleetAI tracker adapter, nearby-customer matching, stop detection
Groundwork for the rep visit check-in feature: a rep taps "I'm on site",
the vehicle's tracker position surfaces which customers are there, and
the visit is logged against them.

Pure/unit-testable layers only — no PocketBase writes and no UI yet,
both of which are blocked on schema that has to be added in the PB
admin first. Design doc: docs/superpowers/specs/2026-09-05-rep-visit-check-in-design.md

- lib/visit-matching.ts   rankNearbyCustomers: radius match around a
                          point, rep's own customers first
- lib/visit-stops.ts      detectStops: breadcrumb trail -> stops.
                          Anchor-based clustering rather than a running
                          centroid, which would let a slow crawl chain
                          into one bogus stationary stop
- lib/tracker-parsing.ts  Netstar FleetAI response decoding
- lib/server/tracker-service.ts
                          FleetAiAdapter + FakeTrackerAdapter behind a
                          TrackerAdapter seam. The fake is auto-selected
                          when no credentials are configured, so the rest
                          of the feature builds and tests without vendor
                          access.
- lib/geo.ts              isRealGeoPoint, lifting the Null-Island guard
                          that was inlined identically in customers/[id]
                          and companies/[id]

Three FleetAI conventions drive real code here and are each pinned by a
test: errors arrive as HTTP 200 with a failure envelope; "--" means
"unavailable" and is not null (and for IGN, not false); timestamps are
platform-local UTC+2 with no offset marker, so parsing them naively puts
every fresh fix two hours in the past.

FleetAI has no position-history endpoint, so the design polls
getTokenBaseLiveData (whole fleet in one call) and keeps its own trail.
scripts/fleetai-probe.mjs is a throwaway read-only probe to confirm the
three account-shaped assumptions before more is built on top.

102 tests pass. The 9 pre-existing failing test files elsewhere in the
suite were verified identical on a stashed tree and are unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:22:28 +02:00

196 lines
6.1 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { distanceKm } from "./geo";
import {
rankNearbyCustomers,
DEFAULT_RADIUS_KM,
type NearbyCandidate,
} from "./visit-matching";
/**
* Fixtures are anchored on Sibanye Driefontein (Carletonville) — the
* same part of the West Rand the real customer base sits in, so the
* latitude-dependent longitude scaling in `distanceKm` is exercised at
* a realistic value rather than at the equator.
*/
const ORIGIN = [-26.4, 27.4] as const;
/** ~1 km north of ORIGIN (1 degree of latitude ≈ 111.19 km). */
const ONE_KM_NORTH = { lat: -26.4 + 1 / 111.19, lon: 27.4 };
/** ~3 km north of ORIGIN — outside a 2 km radius. */
const THREE_KM_NORTH = { lat: -26.4 + 3 / 111.19, lon: 27.4 };
function candidate(over: Partial<NearbyCandidate> & { id: string }): NearbyCandidate {
return {
customer: `Customer ${over.id}`,
coordinates: { lat: ORIGIN[0], lon: ORIGIN[1] },
rep: "",
companyName: null,
...over,
};
}
describe("rankNearbyCustomers", () => {
it("returns customers inside the radius and drops those outside it", () => {
const result = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: 2,
customers: [
candidate({ id: "near", coordinates: ONE_KM_NORTH }),
candidate({ id: "far", coordinates: THREE_KM_NORTH }),
],
});
expect(result.map((r) => r.id)).toEqual(["near"]);
});
it("skips Null Island customers rather than placing them off West Africa", () => {
// This is the state of all 616 customers before the pin tool /
// capture-on-visit runs, so it is the common case, not an edge case.
const result = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: 2,
customers: [
candidate({ id: "unset", coordinates: { lat: 0, lon: 0 } }),
candidate({ id: "pinned" }),
],
});
expect(result.map((r) => r.id)).toEqual(["pinned"]);
});
it("skips customers whose coordinates field is missing entirely", () => {
const result = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: 2,
customers: [
candidate({ id: "null-coords", coordinates: null }),
candidate({ id: "undef-coords", coordinates: undefined }),
candidate({ id: "pinned" }),
],
});
expect(result.map((r) => r.id)).toEqual(["pinned"]);
});
it("puts the signed-in rep's own customers first, then sorts by distance", () => {
// Inside a 2 km radius everyone is on the same mine property, so
// distance is mostly noise — the rep came to see *their* accounts.
const result = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: 2,
repId: "rep-tiaan",
customers: [
candidate({ id: "other-closest", rep: "rep-chris" }),
candidate({ id: "mine-far", rep: "rep-tiaan", coordinates: ONE_KM_NORTH }),
candidate({ id: "mine-close", rep: "rep-tiaan" }),
],
});
expect(result.map((r) => r.id)).toEqual([
"mine-close",
"mine-far",
"other-closest",
]);
});
it("marks which rows belong to the signed-in rep", () => {
const result = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: 2,
repId: "rep-tiaan",
customers: [
candidate({ id: "mine", rep: "rep-tiaan" }),
candidate({ id: "theirs", rep: "rep-chris" }),
],
});
expect(result.find((r) => r.id === "mine")?.isMine).toBe(true);
expect(result.find((r) => r.id === "theirs")?.isMine).toBe(false);
});
it("treats every row as not-mine when no rep is signed in", () => {
const result = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: 2,
customers: [candidate({ id: "a", rep: "rep-tiaan" })],
});
expect(result[0].isMine).toBe(false);
});
it("reports distance in metres-precision kilometres", () => {
const [row] = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: 2,
customers: [candidate({ id: "a", coordinates: ONE_KM_NORTH })],
});
expect(row.distanceKm).toBeCloseTo(1, 2);
});
it("carries the company name through for display", () => {
const [row] = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: 2,
customers: [
candidate({ id: "a", customer: "DANIE GREYLING", companyName: "Sibanye - Driefontein" }),
],
});
expect(row.name).toBe("DANIE GREYLING");
expect(row.companyName).toBe("Sibanye - Driefontein");
});
it("includes a customer sitting exactly on the radius boundary", () => {
// A rep parked exactly `radiusKm` from a pin should still see it —
// excluding the boundary makes the radius setting feel arbitrary.
// The radius is taken from the measured distance so this asserts the
// exact `<=` boundary rather than an approximated one.
const exactDistance = distanceKm(ORIGIN, [
ONE_KM_NORTH.lat,
ONE_KM_NORTH.lon,
]);
const result = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: exactDistance,
customers: [candidate({ id: "boundary", coordinates: ONE_KM_NORTH })],
});
expect(result.map((r) => r.id)).toEqual(["boundary"]);
});
it("excludes a customer just outside the radius", () => {
const exactDistance = distanceKm(ORIGIN, [
ONE_KM_NORTH.lat,
ONE_KM_NORTH.lon,
]);
const result = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: exactDistance - 0.001,
customers: [candidate({ id: "boundary", coordinates: ONE_KM_NORTH })],
});
expect(result).toEqual([]);
});
it("falls back to the default radius when given a non-positive one", () => {
// A misconfigured settings row must not silently match every
// customer in the country (radius 0 → nothing, negative → nothing).
const result = rankNearbyCustomers({
origin: ORIGIN,
radiusKm: 0,
customers: [candidate({ id: "near", coordinates: ONE_KM_NORTH })],
});
expect(DEFAULT_RADIUS_KM).toBe(2);
expect(result.map((r) => r.id)).toEqual(["near"]);
});
it("returns an empty list rather than throwing on no candidates", () => {
expect(
rankNearbyCustomers({ origin: ORIGIN, radiusKm: 2, customers: [] })
).toEqual([]);
});
});