WFDASH-WEBAPP/src/lib/tracker-parsing.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

197 lines
6.8 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
fleetAiValue,
fleetAiNumber,
fleetAiBoolean,
parseFleetAiTimestamp,
readFleetAiError,
fixAgeSeconds,
SAST_OFFSET_MINUTES,
} from "./tracker-parsing";
describe("fleetAiValue", () => {
it("maps the double-dash sentinel to null", () => {
// §1.1: "Fields returning -- in responses mean the value is
// unavailable or not configured. This is not null — handle it
// explicitly." Without this, "--" leaks into the UI as a literal.
expect(fleetAiValue("--")).toBeNull();
});
it("maps blank and whitespace-only values to null", () => {
expect(fleetAiValue("")).toBeNull();
expect(fleetAiValue(" ")).toBeNull();
});
it("maps missing values to null", () => {
expect(fleetAiValue(undefined)).toBeNull();
expect(fleetAiValue(null)).toBeNull();
});
it("trims and returns real values", () => {
expect(fleetAiValue(" Carletonville, GP ")).toBe("Carletonville, GP");
});
it("does not swallow a legitimate value that merely contains a dash", () => {
expect(fleetAiValue("Sibanye - Driefontein")).toBe("Sibanye - Driefontein");
expect(fleetAiValue("-")).toBe("-");
});
});
describe("fleetAiNumber", () => {
it("parses numeric strings", () => {
expect(fleetAiNumber("-26.348597")).toBe(-26.348597);
expect(fleetAiNumber(27.375136)).toBe(27.375136);
});
it("returns null for the double-dash sentinel", () => {
expect(fleetAiNumber("--")).toBeNull();
});
it("strips a unit suffix — gps.speed comes back as '62km/h'", () => {
expect(fleetAiNumber("62km/h")).toBe(62);
});
it("returns null rather than NaN for unparseable input", () => {
expect(fleetAiNumber("unknown")).toBeNull();
expect(fleetAiNumber(undefined)).toBeNull();
});
it("keeps a real zero — a stationary vehicle has speed 0", () => {
expect(fleetAiNumber("0")).toBe(0);
});
});
describe("fleetAiBoolean", () => {
it("reads the ON/OFF flags the platform uses for IGN", () => {
expect(fleetAiBoolean("ON")).toBe(true);
expect(fleetAiBoolean("OFF")).toBe(false);
expect(fleetAiBoolean("on")).toBe(true);
});
it("returns null for an unwired sensor rather than false", () => {
// "--" means the device has no ignition wire. Reading that as
// "ignition off" would make the stop detector treat every moving
// vehicle as parked.
expect(fleetAiBoolean("--")).toBeNull();
expect(fleetAiBoolean(undefined)).toBeNull();
expect(fleetAiBoolean("MAYBE")).toBeNull();
});
it("accepts the boolean-ish variants", () => {
expect(fleetAiBoolean("1")).toBe(true);
expect(fleetAiBoolean("0")).toBe(false);
expect(fleetAiBoolean("true")).toBe(true);
});
});
describe("parseFleetAiTimestamp", () => {
it("reads dd-MM-yyyy HH:mm:ss as platform-local and returns UTC", () => {
// §11: responses carry the platform's configured timezone, which is
// UTC+2 for South Africa. Parsing naively would make every fresh fix
// look two hours stale.
expect(parseFleetAiTimestamp("04-09-2026 14:30:00", SAST_OFFSET_MINUTES)).toBe(
"2026-09-04T12:30:00.000Z"
);
});
it("reads yyyy-MM-dd HH:mm:ss too", () => {
expect(parseFleetAiTimestamp("2026-09-04 14:30:00", SAST_OFFSET_MINUTES)).toBe(
"2026-09-04T12:30:00.000Z"
);
});
it("disambiguates dd-MM-yyyy from MM-dd-yyyy per the documented format", () => {
// 04-09-2026 is 4 September, not 9 April.
expect(parseFleetAiTimestamp("04-09-2026 00:00:00", 0)).toBe(
"2026-09-04T00:00:00.000Z"
);
});
it("accepts a missing seconds component", () => {
expect(parseFleetAiTimestamp("04-09-2026 14:30", SAST_OFFSET_MINUTES)).toBe(
"2026-09-04T12:30:00.000Z"
);
});
it("passes through an ISO-8601 string with an explicit zone", () => {
expect(parseFleetAiTimestamp("2026-09-04T12:30:00Z", SAST_OFFSET_MINUTES)).toBe(
"2026-09-04T12:30:00.000Z"
);
});
it("returns null for the double-dash sentinel and for junk", () => {
expect(parseFleetAiTimestamp("--", SAST_OFFSET_MINUTES)).toBeNull();
expect(parseFleetAiTimestamp("not a date", SAST_OFFSET_MINUTES)).toBeNull();
expect(parseFleetAiTimestamp(undefined, SAST_OFFSET_MINUTES)).toBeNull();
});
it("rejects an out-of-range date rather than rolling it over", () => {
// JS Date would silently turn month 13 into January of next year.
expect(parseFleetAiTimestamp("04-13-2026 00:00:00", 0)).toBeNull();
expect(parseFleetAiTimestamp("32-09-2026 00:00:00", 0)).toBeNull();
});
});
describe("readFleetAiError", () => {
it("detects a failure envelope even though the HTTP status was 200", () => {
// §12: "The API returns HTTP 200 for most responses including
// errors. Always inspect the response body."
expect(
readFleetAiError({ RESULT: "FAIL", MSG: "Invalid request or validation failed" })
).toBe("Invalid request or validation failed");
});
it("detects the lowercase result/message variant", () => {
expect(readFleetAiError({ result: "FAIL", message: "Invalid parameter" })).toBe(
"Invalid parameter"
);
});
it("detects a root.error field", () => {
expect(readFleetAiError({ root: { error: "No Vehicle Found For Given User" } })).toBe(
"No Vehicle Found For Given User"
);
});
it("detects a bare error message with no result field", () => {
expect(readFleetAiError({ message: "Refresh Token Expired" })).toBe(
"Refresh Token Expired"
);
expect(readFleetAiError({ message: "The call exceeded the limit of one minute one call" })).toBe(
"The call exceeded the limit of one minute one call"
);
});
it("returns null for a successful response", () => {
expect(readFleetAiError({ result: "sample", message: "sample", data: [] })).toBeNull();
expect(readFleetAiError({ root: { VehicleData: [] } })).toBeNull();
expect(readFleetAiError({ data: [{ plate_no: "ABC123GP" }] })).toBeNull();
});
it("does not mistake a success message for an error", () => {
expect(readFleetAiError({ result: 1, message: "User is Active" })).toBeNull();
});
it("returns null for a non-object body", () => {
expect(readFleetAiError(null)).toBeNull();
expect(readFleetAiError("plain text")).toBeNull();
});
});
describe("fixAgeSeconds", () => {
const now = new Date("2026-09-04T12:30:00.000Z");
it("measures how stale a fix is", () => {
expect(fixAgeSeconds("2026-09-04T12:25:00.000Z", now)).toBe(300);
});
it("returns 0 for a fix in the future rather than a negative age", () => {
// Clock skew between the platform and this server is normal; a
// negative age would render as "-3 minutes ago" in the UI.
expect(fixAgeSeconds("2026-09-04T12:33:00.000Z", now)).toBe(0);
});
it("returns null when the fix time is unknown", () => {
expect(fixAgeSeconds(null, now)).toBeNull();
});
});