weather_app/tests/lib/api/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

238 lines
9.9 KiB
JavaScript

import { describe, it, expect } from 'vitest'
import {
pointInRing,
pointInPolygon,
pointInGeometry,
haversineKm,
riskKey,
spcRiskAtPoint,
spcProbabilityAtPoint,
normalizeOutlook,
spcOutlookUrl,
isUsPoint,
reportIcon,
reportLabel,
normalizeStormReports,
normalizeActiveAlerts,
alertType,
isUrgentWarning,
} from '../../../src/lib/api/storm.js'
// --- fixtures -------------------------------------------------------------
const box = (w, s, e, n) => [[[w, s], [e, s], [e, n], [w, n], [w, s]]]
const fc = (features) => ({ type: 'FeatureCollection', features })
const catFeature = (label, geom) => ({
type: 'Feature',
properties: { LABEL: label, ISSUE_ISO: '2026-10-17T16:14:00+00:00' },
geometry: geom,
})
const lsrFeature = (props, lon, lat) => ({
type: 'Feature',
properties: { type: 'Feature', ...props },
geometry: { type: 'Point', coordinates: [lon, lat] },
})
// --- tests ----------------------------------------------------------------
describe('storm geometry helpers', () => {
const square = box(-97, 35, -96, 36)
it('pointInRing handles inside, outside and open rings', () => {
expect(pointInRing(-96.5, 35.5, square[0])).toBe(true)
expect(pointInRing(-97.5, 35.5, square[0])).toBe(false)
expect(pointInRing(-96.5, 36.5, square[0])).toBe(false)
expect(pointInRing(-96.5, 35.5, [])).toBe(false)
})
it('pointInPolygon excludes holes', () => {
const withHole = [box(-97, 35, -96, 36)[0], box(-96.8, 35.2, -96.6, 35.4)[0]]
expect(pointInPolygon(-96.9, 35.6, withHole)).toBe(true) // in the ring, outside the hole
expect(pointInPolygon(-96.7, 35.3, withHole)).toBe(false) // inside the hole
})
it('pointInGeometry supports Polygon and MultiPolygon', () => {
const poly = { type: 'Polygon', coordinates: square }
const multi = {
type: 'MultiPolygon',
coordinates: [box(-100, 30, -99, 31), box(-97, 35, -96, 36)],
}
expect(pointInGeometry(-96.5, 35.5, poly)).toBe(true)
expect(pointInGeometry(-99.5, 30.5, multi)).toBe(true)
expect(pointInGeometry(-98, 33, multi)).toBe(false)
expect(pointInGeometry(-96.5, 35.5, null)).toBe(false)
})
it('haversineKm is within a few km of the real distance', () => {
// Tulsa (36.154, -95.993) → Oklahoma City (35.468, -97.516) ≈ 157 km
const km = haversineKm(36.154, -95.993, 35.468, -97.516)
expect(km).toBeGreaterThan(150)
expect(km).toBeLessThan(165)
// Zero distance for the same point.
expect(haversineKm(36, -95, 36, -95)).toBeCloseTo(0, 5)
})
it('isUsPoint recognises CONUS, Alaska, Hawaii and rejects elsewhere', () => {
expect(isUsPoint(36.36, -95.81)).toBe(true) // Oklahoma
expect(isUsPoint(61.2, -149.9)).toBe(true) // Anchorage
expect(isUsPoint(21.3, -157.8)).toBe(true) // Honolulu
expect(isUsPoint(51.5, -0.12)).toBe(false) // London
// The CONUS band is deliberately wide, so southern Canada passes; the SPC
// polygons (not this check) decide whether a point actually has an outlook.
expect(isUsPoint(43.6, -79.4)).toBe(true) // Toronto
expect(isUsPoint(19.4, -99.1)).toBe(false) // Mexico City
})
})
describe('SPC outlook parsing', () => {
it('riskKey maps SPC labels and ignores unknown ones', () => {
expect(riskKey('TSTM')).toBe('TSTM')
expect(riskKey(' slgt ')).toBe('SLGT')
expect(riskKey('HIGH')).toBe('HIGH')
expect(riskKey('Less Than 2% All Areas')).toBe('NONE')
expect(riskKey(undefined)).toBe('NONE')
})
it('spcRiskAtPoint returns the highest containing category', () => {
const data = fc([
catFeature('TSTM', { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }),
catFeature('ENH', { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }),
catFeature('SLGT', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) }),
])
const inside = spcRiskAtPoint(-96.5, 35.5, data)
expect(inside.key).toBe('ENH')
expect(inside.short).toBe('ENH')
expect(inside.label).toBe('Enhanced risk')
// Inside SLGT + TSTM but outside ENH.
expect(spcRiskAtPoint(-97.5, 36.5, data).key).toBe('SLGT')
// Inside the TSTM box only.
expect(spcRiskAtPoint(-99, 39, data).key).toBe('TSTM')
// Outside everything.
const none = spcRiskAtPoint(-80, 25, data)
expect(none.key).toBe('NONE')
expect(none.label).toBe('No outlook')
})
it('spcRiskAtPoint handles MultiPolygon and empty collections', () => {
const multi = catFeature('MDT', {
type: 'MultiPolygon',
coordinates: [box(-100, 30, -99, 31), box(-97, 35, -96, 36)],
})
expect(spcRiskAtPoint(-96.5, 35.5, fc([multi])).key).toBe('MDT')
expect(spcRiskAtPoint(-96.5, 35.5, fc([])).key).toBe('NONE')
expect(spcRiskAtPoint(-96.5, 35.5, null).key).toBe('NONE')
})
it('spcProbabilityAtPoint takes the max and ignores the "less than" background', () => {
const data = fc([
catFeature('Less Than 2% All Areas', { type: 'Polygon', coordinates: box(-130, 20, -60, 55) }),
catFeature('2%', { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }),
catFeature('15%', { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }),
])
expect(spcProbabilityAtPoint(-96.5, 35.5, data)).toBe(15)
expect(spcProbabilityAtPoint(-99, 39, data)).toBe(2)
expect(spcProbabilityAtPoint(-80, 25, data)).toBe(0)
expect(spcProbabilityAtPoint(-96.5, 35.5, null)).toBe(0)
})
it('normalizeOutlook resolves all three days plus day-1 probabilities', () => {
const src = {
cat1: fc([catFeature('SLGT', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
cat2: fc([catFeature('TSTM', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
cat3: null,
torn: fc([catFeature('5%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
wind: fc([catFeature('15%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
hail: fc([catFeature('30%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
}
const out = normalizeOutlook(src, -96.5, 35.5)
expect(out.day1.key).toBe('SLGT')
expect(out.day2.key).toBe('TSTM')
expect(out.day3.key).toBe('NONE')
expect(out.probabilities).toEqual({ tornado: 5, wind: 15, hail: 30 })
expect(out.issued).toBe('2026-10-17T16:14:00+00:00')
})
it('spcOutlookUrl builds the light no-label GeoJSON URLs', () => {
expect(spcOutlookUrl(1, 'cat')).toBe(
'https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson'
)
expect(spcOutlookUrl(3, 'hail')).toBe(
'https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson'
)
})
})
describe('local storm reports', () => {
it('reportLabel and reportIcon map NWS type codes', () => {
expect(reportLabel('TORNADO')).toBe('Tornado')
expect(reportLabel('TSTM WND GST')).toBe('Thunderstorm wind gust')
expect(reportLabel('FLASH FLOOD')).toBe('Flash flood')
expect(reportLabel('HAIL')).toBe('Hail')
expect(reportIcon('TORNADO')).toBe('🌪️')
expect(reportIcon('TSTM WND DMG')).toBe('💨')
expect(reportIcon('HAIL')).toBe('🧊')
expect(reportIcon('FLASH FLOOD')).toBe('🌊')
})
it('normalizeStormReports filters by radius and sorts nearest first', () => {
const data = fc([
lsrFeature({ typetext: 'HAIL', city: 'Far', county: 'X', state: 'OK', valid: '2026-10-17T10:00:00Z', magnitude: '1.75' }, -94.0, 36.3),
lsrFeature({ typetext: 'TORNADO', city: 'Near', county: 'Y', state: 'OK', valid: '2026-10-17T11:00:00Z', magnitude: '' }, -95.82, 36.36),
lsrFeature({ typetext: 'TSTM WND GST', city: 'Middle', county: 'Z', state: 'OK', valid: '2026-10-17T09:00:00Z', magnitude: '60' }, -96.5, 36.36),
lsrFeature({ typetext: 'HAIL', city: 'TooFar', county: 'W', state: 'KS', valid: '2026-10-17T09:00:00Z', magnitude: '2.00' }, -90.0, 40.0),
])
const out = normalizeStormReports(data, 36.36, -95.82, 100)
expect(out.map((r) => r.city)).toEqual(['Near', 'Middle'])
expect(out[0].label).toBe('Tornado')
expect(out[0].distanceKm).toBeLessThan(1)
expect(out[0].magnitude).toBeNull()
expect(out[1].magnitude).toBe('60')
expect(out[1].distanceKm).toBeGreaterThan(50)
})
})
describe('NWS alert normalization', () => {
const alert = (props) => ({
type: 'Feature',
properties: { status: 'Actual', messageType: 'Alert', ...props },
geometry: { type: 'Polygon', coordinates: box(-96, 35, -95, 36) },
})
it('alertType buckets events like the notification store does', () => {
expect(alertType('Tornado Warning')).toBe('storm')
expect(alertType('Severe Thunderstorm Watch')).toBe('storm')
expect(alertType('Flood Warning')).toBe('flood')
expect(alertType('Excessive Heat Warning')).toBe('heat')
expect(alertType('Red Flag Warning')).toBe('advisory')
})
it('isUrgentWarning flags tornado/severe warnings and Extreme alerts', () => {
expect(isUrgentWarning('Tornado Warning', 'Extreme')).toBe(true)
expect(isUrgentWarning('Severe Thunderstorm Warning', 'Severe')).toBe(true)
expect(isUrgentWarning('Flood Warning', 'Severe')).toBe(false)
expect(isUrgentWarning('Heat Advisory', 'Moderate')).toBe(false)
expect(isUrgentWarning('Special Weather Statement', 'Extreme')).toBe(true)
})
it('normalizeActiveAlerts sorts by severity, drops cancels, keeps fields', () => {
const data = fc([
alert({ id: 'b', event: 'Flood Advisory', severity: 'Minor', ends: '2026-10-17T20:00:00Z' }),
alert({ id: 'a', event: 'Tornado Warning', severity: 'Extreme', ends: '2026-10-17T19:00:00Z', headline: 'Take cover now' }),
alert({ id: 'c', event: 'Heat Advisory', severity: 'Moderate', ends: '2026-10-18T00:00:00Z' }),
alert({ id: 'd', event: 'Old Warning', severity: 'Extreme', messageType: 'Cancel' }),
])
const out = normalizeActiveAlerts(data)
expect(out.map((a) => a.id)).toEqual(['a', 'c', 'b'])
expect(out[0].event).toBe('Tornado Warning')
expect(out[0].headline).toBe('Take cover now')
expect(out[0].type).toBe('storm')
expect(out[0].geometry.type).toBe('Polygon')
})
})