weather_app/tests/lib/api/lightning.test.js
hermes-explorigin ff73e58d31 Add toggleable lightning strike layer to the radar map
- Lightning layer on PrecipitationRadar: toggleable  button (persisted via
  showLightning setting). When on, fetches recent strikes from the planned CORS
  relay and renders them as markers that pan/zoom with the slippy map.
- New src/lib/api/lightning.js: fetchLightningStrikes() reads
  https://cors.thecookiejar.me/lightning?lat&lon&radiusKm&limit (relay NOT
  deployed yet) with an 8s abort timeout and graceful failure - never blocks or
  crashes the map, shows an unavailable banner instead. Exports pure
  projectStrikeToLayer() for Mercator tile-layer marker projection.
- showLightning setting added to DEFAULT_SETTINGS + app store; toggle persists.
- Tests: fetch URL/parsing/error handling + layer projection. 71 total pass.
2026-08-27 00:30:25 +00:00

80 lines
3.0 KiB
JavaScript

import { describe, it, expect, vi, afterEach } from 'vitest'
import { fetchLightningStrikes, projectStrikeToLayer } 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)
})
})