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.
195 lines
6.4 KiB
JavaScript
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()
|
|
}
|
|
})
|
|
})
|