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