import { describe, it, expect, vi, afterEach } from 'vitest' import { fetchLightningStrikes, projectStrikeToLayer, haversineKm, filterStrikesWithinKm } from '../../../src/lib/api/lightning.js' afterEach(() => { vi.restoreAllMocks() }) describe('fetchLightningStrikes', () => { it('builds the relay URL with lat/lon/radius/limit', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ source: 'blitzortung', strikes: [] }), }) globalThis.fetch = fetchMock await fetchLightningStrikes({ lat: 36.35, lon: -95.81, radiusKm: 100, limit: 50 }) const [url] = fetchMock.mock.calls[0] expect(url).toContain('https://cors.thecookiejar.me/lightning?') expect(url).toContain('lat=36.35') expect(url).toContain('lon=-95.81') expect(url).toContain('radiusKm=100') expect(url).toContain('limit=50') }) it('returns the strike list on a valid response', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ source: 'blitzortung', strikes: [ { lat: 36.3, lon: -95.8, time: 1724671234, strength: -12 }, { lat: 36.5, lon: -95.9, time: 1724671299 }, ], }), }) const strikes = await fetchLightningStrikes({ lat: 36.35, lon: -95.81 }) expect(strikes).toHaveLength(2) expect(strikes[0]).toMatchObject({ lat: 36.3, lon: -95.8, strength: -12 }) }) it('throws on a non-OK relay response', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 502, statusText: 'Bad Gateway' }) await expect(fetchLightningStrikes({ lat: 0, lon: 0 })).rejects.toThrow(/Lightning relay error: 502/) }) it('throws on a malformed response (no strikes array)', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ foo: 1 }) }) await expect(fetchLightningStrikes({ lat: 0, lon: 0 })).rejects.toThrow(/invalid response/) }) it('throws when the relay is unreachable (network failure)', async () => { globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) await expect(fetchLightningStrikes({ lat: 0, lon: 0 })).rejects.toThrow() }) }) describe('projectStrikeToLayer', () => { // Center tile is (0.5, 0.5) at zoom 0 (lon=0,lat=0). A strike exactly at the // location should land at the center of the 5x5 layer: (2.5, 2.5) tiles. it('maps a strike at the center location to the layer center', () => { const { x, y } = projectStrikeToLayer(0, 0, { zoom: 0, cx: 0.5, cy: 0.5, tileSize: 256 }) expect(x).toBeCloseTo(640) expect(y).toBeCloseTo(640) }) it('scales with tile size', () => { const { x } = projectStrikeToLayer(0, 0, { zoom: 0, cx: 0.5, cy: 0.5, tileSize: 128 }) expect(x).toBeCloseTo(320) }) it('moves the strike right as lon increases', () => { const a = projectStrikeToLayer(0, 1, { zoom: 0, cx: 0.5, cy: 0.5, tileSize: 256 }) const b = projectStrikeToLayer(0, 5, { zoom: 0, cx: 0.5, cy: 0.5, tileSize: 256 }) expect(a.x).toBeGreaterThan(640) expect(b.x).toBeGreaterThan(a.x) }) }) describe('haversineKm', () => { it('returns ~0 for identical coords', () => { expect(haversineKm(36.35, -95.81, 36.35, -95.81)).toBeCloseTo(0, 5) }) it('~39.66km for 0.3567 deg of latitude', () => { expect(haversineKm(36.0, -95.81, 36.3567, -95.81)).toBeCloseTo(39.66, 1) }) it('~111.19km per 1 deg of latitude', () => { expect(haversineKm(36.0, -95.81, 37.0, -95.81)).toBeCloseTo(111.19, 1) }) }) describe('filterStrikesWithinKm', () => { // Distances computed with the real haversine helper (not hardcoded), so the // boundary tests are exact regardless of floating-point rounding. const R = haversineKm(36.35, -95.81, 37.0, -95.81) // A lon at 37.0 N whose distance from the location is just past R. let ln = -95.81 while (haversineKm(36.35, -95.81, 37.0, ln) <= R) ln += 0.001 const pastLon = ln const strikes = [ { lat: 36.35, lon: -95.81, time: 1, strength: -10 }, // at the location (0 km) -> keep { lat: 37.0, lon: -95.81, time: 2, strength: -12 }, // exactly R km north -> keep { lat: 37.0, lon: pastLon, time: 3, strength: -9 }, // just past R -> drop ] it('keeps strikes strictly inside the radius', () => { const kept = filterStrikesWithinKm(strikes, 36.35, -95.81, R - 1) expect(kept).toHaveLength(1) expect(kept[0].strength).toBe(-10) }) it('keeps strikes exactly at the radius boundary (inclusive)', () => { const kept = filterStrikesWithinKm(strikes, 36.35, -95.81, R) expect(kept).toHaveLength(2) }) it('drops strikes just past the radius', () => { const kept = filterStrikesWithinKm(strikes, 36.35, -95.81, R - 0.0001) expect(kept).toHaveLength(1) expect(kept[0].strength).toBe(-10) }) it('returns an empty array when everything is beyond the radius', () => { // Reference point far from any strike, with a tiny radius. expect(filterStrikesWithinKm(strikes, 40.0, -95.81, 0.0001)).toHaveLength(0) }) })