180 lines
7.4 KiB
JavaScript
180 lines
7.4 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 accept zero coordinates, which are a real position', async () => {
|
|
global.fetch.mockResolvedValue(osrmOk(1000, 120));
|
|
await expect(
|
|
calculateDrivingDistance({ latitude: 0, longitude: 0 }, { latitude: 0, longitude: 1 })
|
|
).resolves.toEqual(expect.any(Number));
|
|
});
|
|
});
|
|
|
|
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');
|
|
expect(url).toContain('overview=false');
|
|
});
|
|
|
|
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.
|
|
global.fetch.mockResolvedValue({ ok: false, json: async () => ({}) });
|
|
const distance = await calculateDrivingDistance(
|
|
{ latitude: 0, longitude: 0 },
|
|
{ latitude: 1, longitude: 0 }
|
|
);
|
|
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);
|
|
});
|
|
});
|