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); }); });