weather_app/tests/lib/stores/storm.test.js
hermes-explorigin 4616c297a9
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 24s
Test, Build & Deploy / deploy (push) Successful in 18s
feat(storms): NWS alerts, SPC outlook and IEM storm reports
Adds a Storms tab driven by three keyless, CORS-enabled feeds:
- api.weather.gov/alerts/active?point=… for watches/warnings/advisories,
  with a banner above the content for tornado/severe/Extreme alerts
- SPC Day 1-3 categorical outlooks plus Day 1 tornado/hail/wind
  probabilities, resolved by point-in-polygon (nolyr GeoJSON)
- NWS Local Storm Reports via IEM (bounding box + radius cut), nearest first

New: src/lib/api/storm.js (pure helpers + cached fetchers),
src/lib/stores/storm.svelte.js (5-min polling, stale-response guard),
src/components/StormWatch.svelte. Router/nav gain a 'storms' view.
Tests: geometry/parsing (16), store (7), component render (3).
Docs: AGENTS.md storm section + API notes.
2026-10-17 18:10:46 +00:00

195 lines
6.4 KiB
JavaScript

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { StormStore, STORM_REFRESH_MS } from '../../../src/lib/stores/storm.svelte.js'
import { app } from '../../../src/lib/stores/app.svelte.js'
import { clearStormCache } from '../../../src/lib/api/storm.js'
// ---- fixtures ------------------------------------------------------------
const box = (w, s, e, n) => [[[w, s], [e, s], [e, n], [w, n], [w, s]]]
const catFc = (label) => ({
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { LABEL: label, ISSUE_ISO: '2026-10-17T16:14:00+00:00' },
geometry: { type: 'Polygon', coordinates: box(-100, 30, -90, 40) },
}],
})
const probFc = (label) => ({
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { LABEL: label },
geometry: { type: 'Polygon', coordinates: box(-100, 30, -90, 40) },
}],
})
const alertsFc = (features) => ({ type: 'FeatureCollection', features })
const alertFeature = (props) => ({
type: 'Feature',
properties: { status: 'Actual', messageType: 'Alert', ...props },
geometry: { type: 'Polygon', coordinates: box(-97, 35, -96, 36) },
})
const lsrFc = (features) => ({ type: 'FeatureCollection', features })
const lsr = (city, lon, lat) => ({
type: 'Feature',
properties: {
typetext: 'HAIL', city, county: 'X', state: 'OK', source: 'Trained Spotter',
valid: '2026-10-17T11:00:00Z', magnitude: '1.75', remark: 'quarter size',
},
geometry: { type: 'Point', coordinates: [lon, lat] },
})
// ---- fetch stub ----------------------------------------------------------
let calls
let overrides
function installFetch() {
calls = []
overrides = {}
globalThis.fetch = vi.fn(async (url) => {
const u = String(url)
calls.push(u)
for (const [match, handler] of Object.entries(overrides)) {
if (u.includes(match)) return handler(u)
}
const json = (body) => ({ ok: true, status: 200, json: async () => body })
if (u.includes('api.weather.gov/alerts/active')) return json(alertsFc([]))
if (u.includes('day1otlk_cat')) return json(catFc('SLGT'))
if (u.includes('day2otlk_cat')) return json(catFc('MRGL'))
if (u.includes('day3otlk_cat')) return json(catFc('TSTM'))
if (u.includes('day1otlk_torn')) return json(probFc('5%'))
if (u.includes('day1otlk_wind')) return json(probFc('15%'))
if (u.includes('day1otlk_hail')) return json(probFc('30%'))
if (u.includes('mesonet.agron.iastate.edu')) return json(lsrFc([lsr('Near', -95.82, 36.36)]))
return { ok: false, status: 404, json: async () => ({}) }
})
}
function setLocation(lat, lon, name = 'Test') {
app.locations = [{ id: 'loc-1', name, lat, lon, isCurrent: true }]
app.selectedLocationId = 'loc-1'
}
// ---- tests ---------------------------------------------------------------
describe('StormStore', () => {
let store
beforeEach(() => {
clearStormCache()
installFetch()
store = new StormStore()
setLocation(36.3567, -95.8146, 'Home')
})
afterEach(() => {
store.stop()
vi.restoreAllMocks()
})
it('loads alerts, outlook and reports for the selected location', async () => {
overrides['api.weather.gov/alerts/active'] = async () => ({
ok: true, status: 200,
json: async () => alertsFc([
alertFeature({ id: 't1', event: 'Tornado Warning', severity: 'Extreme', ends: '2026-10-17T19:00:00Z' }),
]),
})
await store.watch({ force: true })
expect(store.alerts).toHaveLength(1)
expect(store.alerts[0].event).toBe('Tornado Warning')
expect(store.banner?.id).toBe('t1')
expect(store.outlook.day1.key).toBe('SLGT')
expect(store.outlook.day2.key).toBe('MRGL')
expect(store.outlook.probabilities).toEqual({ tornado: 5, wind: 15, hail: 30 })
expect(store.reports).toHaveLength(1)
expect(store.reports[0].city).toBe('Near')
expect(store.loading).toBe(false)
expect(store.updatedAt).toBeGreaterThan(0)
expect(store.alertCount).toBe(1)
})
it('serves fresh data from the store/HTTP cache until it expires', async () => {
await store.watch({ force: true })
const firstCount = calls.length
expect(firstCount).toBeGreaterThan(0)
await store.watch() // store-level: data for this location is fresh
expect(calls.length).toBe(firstCount)
await store.watch({ force: true }) // HTTP cache still warm
expect(calls.length).toBe(firstCount)
clearStormCache() // simulate the TTLs expiring
await store.watch({ force: true })
expect(calls.length).toBeGreaterThan(firstCount)
})
it('refetches when the selected location changes', async () => {
await store.watch({ force: true })
const firstCount = calls.length
setLocation(27.2, -82.3, 'Elsewhere')
await store.watch()
const fresh = calls.slice(firstCount)
expect(fresh.length).toBeGreaterThan(0)
expect(fresh.some((u) => u.includes('36.3567'))).toBe(false) // old point not refetched
expect(fresh.some((u) => u.includes('27.2'))).toBe(true) // new point queried
})
it('keeps the other feeds when one request fails', async () => {
overrides['mesonet.agron.iastate.edu'] = async () => ({
ok: false, status: 503, json: async () => ({}),
})
await store.watch({ force: true })
expect(store.reports).toEqual([])
expect(store.outlook.day1.key).toBe('SLGT')
expect(store.error).toMatch(/reports/)
})
it('skips SPC/LSR requests outside the US and clears the outlook', async () => {
setLocation(51.5, -0.12, 'London')
await store.watch({ force: true })
expect(store.hasUsCoverage).toBe(false)
expect(store.outlook).toBeNull()
expect(store.reports).toEqual([])
expect(calls.some((u) => u.includes('spc.noaa.gov'))).toBe(false)
expect(calls.some((u) => u.includes('api.weather.gov'))).toBe(true)
})
it('reset() drops data and the location key', async () => {
await store.watch({ force: true })
store.reset()
expect(store.alerts).toEqual([])
expect(store.outlook).toBeNull()
expect(store.reports).toEqual([])
expect(store.updatedAt).toBe(0)
})
it('start() schedules a refresh at the documented interval', () => {
vi.useFakeTimers()
try {
const spy = vi.spyOn(store, 'watch').mockResolvedValue()
store.start()
store.start() // idempotent
vi.advanceTimersByTime(STORM_REFRESH_MS + 10)
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith({ force: true })
store.stop()
} finally {
vi.useRealTimers()
}
})
})