197 lines
6.1 KiB
TypeScript
197 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([]);
|
||
|
|
});
|
||
|
|
});
|