277 lines
12 KiB
JavaScript
277 lines
12 KiB
JavaScript
import {
|
|
calculateDrivingDistance,
|
|
calculateDrivingRoute,
|
|
calculateTotalCharge,
|
|
formatRouteDistance,
|
|
formatRouteDuration,
|
|
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('calculateDrivingRoute — sub-kilometre legs', () => {
|
|
beforeEach(() => {
|
|
global.fetch = jest.fn();
|
|
});
|
|
|
|
it('should keep the unrounded road length in metres alongside whole-km distance', async () => {
|
|
// A same-address order: OSRM snaps both pins to the road and returns ~120 m.
|
|
// `distance` (billed) rounds to 0 km; `meters` is what the operator sees.
|
|
global.fetch.mockResolvedValue(osrmOk(123.4, 20));
|
|
const res = await calculateDrivingRoute(BLR, { latitude: 12.9717, longitude: 77.5947 });
|
|
expect(res.distance).toBe(0);
|
|
expect(res.meters).toBe(123);
|
|
expect(res.minutes).toBe(0);
|
|
});
|
|
|
|
it('should report metres on the Haversine fallback too', async () => {
|
|
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
|
|
const res = await calculateDrivingRoute(BLR, { ...BLR });
|
|
expect(res.distance).toBe(0);
|
|
expect(res.meters).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('formatRouteDistance', () => {
|
|
it.each([
|
|
[0, '0 m'],
|
|
[123, '120 m'],
|
|
[996, '1 km'],
|
|
[1000, '1 km'],
|
|
[1449, '1.4 km'],
|
|
[8200, '8.2 km']
|
|
])('should format %d m as %s', (meters, expected) => {
|
|
expect(formatRouteDistance(meters)).toBe(expected);
|
|
});
|
|
|
|
it.each([[null], [undefined], [NaN], [-5]])('should show a dash for %p', (value) => {
|
|
expect(formatRouteDistance(value)).toBe('—');
|
|
});
|
|
});
|
|
|
|
describe('formatRouteDuration', () => {
|
|
it('should show "< 1 min" for a sub-minute drive rather than a default', () => {
|
|
expect(formatRouteDuration(0)).toBe('< 1 min');
|
|
});
|
|
|
|
it('should show whole minutes', () => {
|
|
expect(formatRouteDuration(15)).toBe('15 mins');
|
|
});
|
|
|
|
it.each([[null], [undefined]])('should show a dash when there is no ETA (%p)', (value) => {
|
|
expect(formatRouteDuration(value)).toBe('—');
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|