weather_app/tests/lib/api/lightning.test.js
hermes-explorigin 86acc56143 feat(radar): only show lightning strikes within 25 miles
Add haversineKm helper and filterStrikesWithinKm filter in
src/lib/api/lightning.js. PrecipitationRadar now filters strikes by
distance from the current location before projecting them, so only
strikes within 25 miles render. No data-source change; relay-down
behavior is unchanged.

Fixes an incorrect haversine expectation in the test (0.3567 deg of
latitude ~= 39.66 km, not 25.7 km).
2026-09-20 19:47:37 +00:00

131 lines
5.0 KiB
JavaScript

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