import { describe, expect, it } from 'bun:test'; import { computeTrackDistance, haversine, type GeoPoint } from './geo'; // One degree of latitude is ~111_195 m, so these small deltas give // predictable segment lengths: 0.0001° ≈ 11.12 m, 0.00001° ≈ 1.11 m. const DEG = 111_195; function pt( lat: number, lon: number, seconds: number, accuracy?: number | null ): GeoPoint { return { lat, lon, accuracy, recordedAt: new Date(seconds * 1000) }; } describe('haversine', () => { it('returns 0 for identical points', () => { expect(haversine(pt(0, 0, 0), pt(0, 0, 0))).toBe(0); }); it('measures one degree of latitude along a meridian', () => { const d = haversine({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }); expect(Math.abs(d - DEG)).toBeLessThan(1); }); it('measures one degree of longitude along the equator', () => { const d = haversine({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }); expect(Math.abs(d - DEG)).toBeLessThan(1); }); it('is symmetric', () => { const a = { lat: 52.5, lon: 13.4 }; const b = { lat: 52.6, lon: 13.5 }; expect(haversine(a, b)).toBeCloseTo(haversine(b, a), 6); }); }); describe('computeTrackDistance', () => { it('returns 0 for empty input', () => { expect(computeTrackDistance([])).toBe(0); }); it('returns 0 for a single point', () => { expect(computeTrackDistance([pt(0, 0, 0)])).toBe(0); }); it('sums a normal multi-point walk', () => { const points = [ pt(0, 0, 0), pt(0.0001, 0, 10), pt(0.0002, 0, 20), pt(0.0003, 0, 30) ]; // three ~11.12 m segments const d = computeTrackDistance(points); expect(d).toBeGreaterThan(32); expect(d).toBeLessThan(35); }); it('excludes fixes with accuracy worse than 50 m', () => { const points = [ pt(0, 0, 0, 5), // far off path, bad accuracy → dropped pt(0, 0.001, 10, 100), pt(0.0001, 0, 20, 5) ]; const d = computeTrackDistance(points); // only the ~11 m A→C segment counts, not the ~222 m detour via B expect(d).toBeGreaterThan(10); expect(d).toBeLessThan(50); }); it('keeps accuracy of exactly 50 m', () => { const points = [pt(0, 0, 0, 50), pt(0.0001, 0, 10, 50)]; const d = computeTrackDistance(points); expect(d).toBeGreaterThan(10); expect(d).toBeLessThan(13); }); it('rejects teleport candidates and preserves the anchor', () => { const points = [ pt(0, 0, 0), // ~1112 m in 1 s → far above 7 m/s → rejected, anchor stays at A pt(0.01, 0, 1), // ~11 m from A over 2 s → 5.6 m/s → accepted pt(0.0001, 0, 2) ]; const d = computeTrackDistance(points); expect(d).toBeGreaterThan(10); expect(d).toBeLessThan(13); }); it('ignores stationary jitter below 5 m', () => { const jitter = [ pt(0, 0, 0), pt(0.00001, 0, 10), // ~1.1 m pt(0.00002, 0, 20) // ~2.2 m from anchor ]; expect(computeTrackDistance(jitter)).toBe(0); // a real move after the jitter is still measured from the original anchor const withMove = [...jitter, pt(0.0002, 0, 30)]; const d = computeTrackDistance(withMove); expect(d).toBeGreaterThan(21); expect(d).toBeLessThan(24); }); it('dedupes and sorts out-of-order points', () => { const a = pt(0, 0, 0); const b = pt(0.0001, 0, 10); const c = pt(0.0002, 0, 20); const shuffled = [c, a, b, a, c, b]; const d = computeTrackDistance(shuffled); // two ~11.12 m segments after dedupe + sort expect(d).toBeGreaterThan(21); expect(d).toBeLessThan(24); }); it('accepts string recordedAt values', () => { const points: GeoPoint[] = [ { lat: 0, lon: 0, recordedAt: new Date(0).toISOString() }, { lat: 0.0001, lon: 0, recordedAt: new Date(10_000).toISOString() } ]; const d = computeTrackDistance(points); expect(d).toBeGreaterThan(10); expect(d).toBeLessThan(13); }); it('returns an integer number of meters', () => { const points = [pt(0, 0, 0), pt(0.0001, 0, 10), pt(0.0002, 0, 20)]; expect(Number.isInteger(computeTrackDistance(points))).toBeTrue(); }); });