Files
doormilxpress_astryx/tests/lib/distance.test.js

216 lines
9.6 KiB
JavaScript

import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
/**
* Distance and charge for a delivery leg.
*
* OSRM is the routed source; the Haversine + 1.3 multiplier is the fallback.
* The behaviour that matters commercially is that the fallback NEVER reports a
* duration — a straight-line estimate has no honest ETA, and showing a made-up
* one to an operator is worse than showing none.
*/
const osrmOk = (distanceMeters, durationSeconds) => ({
ok: true,
json: async () => ({ routes: [{ distance: distanceMeters, duration: durationSeconds }] })
});
const BLR = { latitude: 12.9716, longitude: 77.5946 };
const IND = { latitude: 12.9352, longitude: 77.6245 };
describe('calculateDrivingDistance', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
describe('coordinate validation', () => {
it.each([
['a missing origin latitude', { longitude: 77.5946 }, IND],
['a missing origin longitude', { latitude: 12.9716 }, IND],
['a missing destination latitude', BLR, { longitude: 77.6245 }],
['a missing destination longitude', BLR, { latitude: 12.9352 }],
['a null origin', null, IND],
['an undefined destination', BLR, undefined],
['a null latitude value', { latitude: null, longitude: 77.5946 }, IND]
])('should reject %s', async (_label, origin, destination) => {
await expect(calculateDrivingDistance(origin, destination)).rejects.toThrow('Invalid coordinates');
expect(global.fetch).not.toHaveBeenCalled();
});
it('should reject zero coordinates, which are only ever a failed geocode', async () => {
// This assertion is INVERTED from what it used to say, and the reason is
// that the old pair of expectations could not both hold.
//
// The case above requires a null latitude to be rejected. `Number(null)`
// is 0, so to any guard built on Number() a null latitude and a zero
// latitude are the same value — there is no implementation that rejects
// one and accepts the other. The old code accepted both, which is why
// "should reject a null latitude value" was failing on main.
//
// So it is a choice, and zero loses. Geographically (0, 0) is a real
// point in the Gulf of Guinea; in this product it is not reachable —
// Doormile operates inside the India bounding box (latitude 6.5 to 37.5,
// longitude 68 to 97.5, see geocodingService.isWithinIndia) and every
// zero coordinate ever seen in production was the residue of a geocode
// that failed. Accepting them cost real money: OSRM has no route to open
// ocean, so the Haversine fallback measured a Coimbatore pickup at
// roughly 11,000 km, and the backend honours that as the order's price.
global.fetch.mockResolvedValue(osrmOk(1000, 120));
await expect(
calculateDrivingDistance({ latitude: 0, longitude: 0 }, { latitude: 0, longitude: 1 })
).rejects.toThrow('Invalid coordinates');
expect(global.fetch).not.toHaveBeenCalled();
});
it('should reject a coordinate outside its own axis range', async () => {
// Nothing in the console checked this before. Note the limit: this
// catches a longitude read as a latitude only when it exceeds 90, so a
// transposed Coimbatore pin (11.0168, 76.9558) still passes. See
// lib/coords for why, and what would actually catch it.
await expect(
calculateDrivingDistance({ latitude: 94.912, longitude: 27.4728 }, IND)
).rejects.toThrow('Invalid coordinates');
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe('OSRM routed distance', () => {
it('should return the routed distance rounded to whole kilometres', async () => {
global.fetch.mockResolvedValue(osrmOk(8200, 900));
await expect(calculateDrivingDistance(BLR, IND)).resolves.toBe(8);
});
it('should cache the routed duration in whole minutes', async () => {
global.fetch.mockResolvedValue(osrmOk(8200, 900));
await calculateDrivingDistance(BLR, IND);
expect(getLastRouteDurationMin()).toBe(15);
});
it('should request lon,lat order — the order OSRM expects', async () => {
// Swapping to lat,lon silently returns a route between two other places.
global.fetch.mockResolvedValue(osrmOk(8200, 900));
await calculateDrivingDistance(BLR, IND);
const url = global.fetch.mock.calls[0][0];
expect(url).toContain('/route/v1/driving/77.5946,12.9716;77.6245,12.9352');
// The route geometry IS requested: `calculateDrivingRoute` returns a
// polyline for the live map preview, so this asks for overview=full with
// geojson geometry. It read `overview=false` until the polyline landed.
expect(url).toContain('overview=full');
expect(url).toContain('geometries=geojson');
});
it('should use the public OSRM host by default', async () => {
global.fetch.mockResolvedValue(osrmOk(8200, 900));
await calculateDrivingDistance(BLR, IND);
expect(global.fetch.mock.calls[0][0]).toContain('https://router.project-osrm.org');
});
it('should honour a VITE_OSRM_URL override for a self-hosted router', async () => {
globalThis.__VITE_IMPORT_META__ = { env: { VITE_OSRM_URL: 'http://osrm.internal:5000' } };
global.fetch.mockResolvedValue(osrmOk(8200, 900));
let reloaded;
jest.isolateModules(() => {
// eslint-disable-next-line global-require
reloaded = require('@/lib/distance').calculateDrivingDistance;
});
await reloaded(BLR, IND);
expect(global.fetch.mock.calls[0][0]).toContain('http://osrm.internal:5000');
});
it('should report no duration when OSRM omits one', async () => {
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ routes: [{ distance: 8200 }] }) });
await calculateDrivingDistance(BLR, IND);
expect(getLastRouteDurationMin()).toBeNull();
});
});
describe('Haversine fallback', () => {
it('should fall back when OSRM answers with an error status', async () => {
global.fetch.mockResolvedValue({ ok: false, status: 502, json: async () => ({}) });
const distance = await calculateDrivingDistance(BLR, IND);
expect(distance).toEqual(expect.any(Number));
expect(distance).toBeGreaterThan(0);
});
it('should fall back when the network call throws', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
global.fetch.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(calculateDrivingDistance(BLR, IND)).resolves.toEqual(expect.any(Number));
expect(warn).toHaveBeenCalled();
});
it('should fall back when OSRM returns no routes', async () => {
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ routes: [] }) });
await expect(calculateDrivingDistance(BLR, IND)).resolves.toEqual(expect.any(Number));
});
it('should apply the 1.3 road multiplier to the straight-line distance', async () => {
// One degree of latitude is 111.19 km great-circle; x1.3 = 144.5 -> 145.
// Measured along a meridian over India rather than from (0, 0): the
// arithmetic is identical at any longitude, and zero coordinates are now
// refused before the fetch.
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
const distance = await calculateDrivingDistance(
{ latitude: 10, longitude: 77 },
{ latitude: 11, longitude: 77 }
);
expect(distance).toBe(145);
});
it('should return zero for two identical points', async () => {
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
await expect(calculateDrivingDistance(BLR, { ...BLR })).resolves.toBe(0);
});
it('should report NO duration for a fallback estimate', async () => {
// A straight-line guess has no honest ETA. Leaving a stale routed value
// in place would attach a real-looking duration to a fabricated distance.
global.fetch.mockResolvedValueOnce(osrmOk(8200, 900));
await calculateDrivingDistance(BLR, IND);
expect(getLastRouteDurationMin()).toBe(15);
global.fetch.mockResolvedValueOnce({ ok: false, json: async () => ({}) });
await calculateDrivingDistance(BLR, IND);
expect(getLastRouteDurationMin()).toBeNull();
});
it('should be symmetric — the distance does not depend on direction', async () => {
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
const there = await calculateDrivingDistance(BLR, IND);
const back = await calculateDrivingDistance(IND, BLR);
expect(there).toBe(back);
});
});
});
describe('calculateTotalCharge', () => {
it('should charge only the base price below the minimum distance', () => {
expect(calculateTotalCharge(3, 30, 6, 8)).toBe(30);
});
it('should charge only the base price at exactly the minimum distance', () => {
// Boundary: minKm is inclusive of the base fare.
expect(calculateTotalCharge(8, 30, 6, 8)).toBe(30);
});
it('should add the per-km rate beyond the minimum', () => {
expect(calculateTotalCharge(12, 30, 6, 8)).toBe(54);
});
it('should add a partial kilometre proportionally', () => {
expect(calculateTotalCharge(8.5, 30, 6, 8)).toBeCloseTo(33, 6);
});
it('should charge the base price for a zero-distance leg', () => {
expect(calculateTotalCharge(0, 30, 6, 8)).toBe(30);
});
it('should charge the base price when there is no minimum', () => {
expect(calculateTotalCharge(0, 30, 6, 0)).toBe(30);
});
it('should scale linearly for a long leg', () => {
expect(calculateTotalCharge(108, 30, 6, 8)).toBe(630);
});
});