weather_app/tests/lib/api/advisories.test.js
hermes-explorigin 882fd69a18
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 25s
Test, Build & Deploy / deploy (push) Successful in 20s
Add official NWS advisories as notifications, deduping forecast alerts
- nws.js: new fetchActiveAdvisories() hits the official NWS alerts feed
  (api.weather.gov /alerts/active?point=lat,lon) and normalizes each advisory
  into the app's alert shape, tagged source:'nws'. Adds classifyAdvisoryEvent
  (maps NWS events -> app buckets: heat/wind/cold/storm/precip, else advisory),
  nwsSeverity (Extreme/Severe->danger etc.), advisoryId (stable short id from
  the URN), advisoryIcon, and local effectiveDate/expiresDate for dedup.

- notifications store: cached _nwsAdvisories + refreshAdvisories() (best
  effort, cached 5 min, non-US/no-alerts safe). analyze() now merges
  advisories via new pure mergeAdvisories(): an advisory that is officially
  in force on a date supersedes the derived forecast alert of the same type
  on that date, so the official NWS warning replaces the forecast guess. Both
  are dismissible independently (nws-* vs forecast-* ids).

- New per-type toggle "advisory" (defaults on) across app/default settings and
  SettingsDialog, so official advisories are configurable like every other
  alert and the bell shows them with their own severity/icon.

- Wired: App.svelte calls refreshAdvisories() alongside analyze() on forecast
  load and changes.

- Tests: 12 advisory tests (classifier, severity, id, normalize, dedup incl.
  disable toggle) + 3 store integration tests (overlap dedup, non-overlap kept,
  toggle-off). 107 total pass.

- Verified end-to-end in headless Chromium against the live NWS feed for
  Rogers County, OK: the current Heat Advisory appears in the bell, and the
  derived heat alerts for the days it covers are suppressed while a
  non-overlapping day's forecast heat alert remains.
2026-09-08 02:33:37 +00:00

171 lines
6.3 KiB
JavaScript

import { describe, it, expect } from 'vitest'
import {
classifyAdvisoryEvent,
nwsSeverity,
advisoryId,
normalizeAdvisories,
} from '../../../src/lib/api/nws.js'
import { mergeAdvisories } from '../../../src/lib/stores/notifications.svelte.js'
// A realistic raw feature from /alerts/active
const heat = {
properties: {
id: 'urn:oid:2.49.0.1.840.0.e6e2f1.001.2',
event: 'Excessive Heat Warning',
severity: 'Severe',
headline: 'Excessive Heat Warning issued September 7 at 7:02PM CDT until September 8 at 7:00PM CDT by NWS Tulsa OK',
areaDesc: 'Rogers; Tulsa; Creek',
effective: '2026-09-07T19:02:00-05:00',
expires: '2026-09-08T19:15:00-05:00',
},
}
const wind = {
properties: {
id: 'urn:oid:2.49.0.1.840.0.9ab.001.1',
event: 'Wind Advisory',
severity: 'Moderate',
headline: 'Wind Advisory issued',
areaDesc: 'Benton; Washington',
effective: '2026-09-07T12:00:00-05:00',
expires: '2026-09-07T20:00:00-05:00',
},
}
const flood = {
properties: {
id: 'urn:oid:2.49.0.1.840.0.7c.001.3',
event: 'Flash Flood Warning',
severity: 'Severe',
headline: 'Flash Flood Warning issued',
areaDesc: 'Rogers; Delaware',
effective: '2026-09-08T01:00:00-05:00',
expires: '2026-09-08T04:00:00-05:00',
},
}
const unknown = {
properties: {
id: 'urn:oid:2.49.0.1.840.0.xyz.001.9',
event: 'Special Weather Statement',
severity: 'Unknown',
headline: 'Special Weather Statement issued',
areaDesc: 'Rogers',
effective: '2026-09-07T10:00:00-05:00',
expires: '2026-09-07T13:00:00-05:00',
},
}
describe('classifyAdvisoryEvent', () => {
it('maps NWS events to app alert types', () => {
expect(classifyAdvisoryEvent('Excessive Heat Warning')).toBe('heat')
expect(classifyAdvisoryEvent('Heat Advisory')).toBe('heat')
expect(classifyAdvisoryEvent('Wind Advisory')).toBe('wind')
expect(classifyAdvisoryEvent('High Wind Warning')).toBe('wind')
expect(classifyAdvisoryEvent('Winter Storm Warning')).toBe('cold')
expect(classifyAdvisoryEvent('Blizzard Warning')).toBe('cold')
expect(classifyAdvisoryEvent('Severe Thunderstorm Warning')).toBe('storm')
expect(classifyAdvisoryEvent('Tornado Warning')).toBe('storm')
expect(classifyAdvisoryEvent('Flash Flood Warning')).toBe('precip')
})
it('falls back to advisory type for unrecognized events', () => {
expect(classifyAdvisoryEvent('Special Weather Statement')).toBe('advisory')
expect(classifyAdvisoryEvent('Air Quality Alert')).toBe('advisory')
})
})
describe('nwsSeverity', () => {
it('maps NWS severities', () => {
expect(nwsSeverity('Extreme')).toBe('danger')
expect(nwsSeverity('Severe')).toBe('danger')
expect(nwsSeverity('Moderate')).toBe('warning')
expect(nwsSeverity('Minor')).toBe('info')
expect(nwsSeverity('Unknown')).toBe('info')
})
})
describe('advisoryId', () => {
it('derives a stable short id from the URN', () => {
const a = advisoryId('urn:oid:2.49.0.1.840.0.e6e2f1.001.2')
const b = advisoryId('urn:oid:2.49.0.1.840.0.e6e2f1.001.2')
expect(a).toBe(b)
expect(a.length).toBeGreaterThan(0)
})
it('handles missing ids', () => {
expect(advisoryId('')).toBe('adv')
expect(advisoryId(null)).toBe('adv')
})
})
describe('normalizeAdvisories', () => {
it('produces app-shaped alert entries tagged source nws', () => {
const out = normalizeAdvisories({ features: [heat, wind, flood, unknown] }, 'America/Chicago')
expect(out).toHaveLength(4)
const h = out.find((a) => a.event === 'Excessive Heat Warning')
expect(h.type).toBe('heat')
expect(h.severity).toBe('danger')
expect(h.source).toBe('nws')
expect(h.id).toMatch(/^nws-/)
expect(h.effectiveDate).toBe('2026-09-07')
expect(h.expiresDate).toBe('2026-09-08')
expect(h.icon).toBe('🔥')
expect(h.message).toContain('Excessive Heat Warning issued')
})
it('handles empty feed', () => {
expect(normalizeAdvisories({ features: [] })).toEqual([])
expect(normalizeAdvisories(null)).toEqual([])
})
it('dedupes duplicate ids within the feed', () => {
const out = normalizeAdvisories({ features: [heat, heat] }, 'UTC')
expect(out).toHaveLength(1)
})
})
describe('mergeAdvisories (dedup)', () => {
const forecastAlerts = [
// derived heat for today and tomorrow (idx 0 and 1)
{ id: 'heat-0', type: 'heat', date: '2026-09-07', source: 'forecast' },
{ id: 'heat-1', type: 'heat', date: '2026-09-08', source: 'forecast' },
{ id: 'rain-0', type: 'precip', date: '2026-09-07', source: 'forecast' },
{ id: 'wind-0', type: 'wind', date: '2026-09-07', source: 'forecast' },
]
const advisories = [
// NWS heat warning covering 9/7 only (effective..expires that one day)
{ id: 'nws-h', type: 'heat', source: 'nws', effectiveDate: '2026-09-07', expiresDate: '2026-09-07' },
{ id: 'nws-flood', type: 'precip', source: 'nws', effectiveDate: '2026-09-08', expiresDate: '2026-09-08' },
]
it('drops the derived alert whose date sits under an advisory of the same type', () => {
const merged = mergeAdvisories(forecastAlerts, advisories)
// heat-0 (9/7) is under the 9/7 heat advisory -> gone
// heat-1 (9/8) is NOT under the heat advisory (ends 9/7) -> kept
const ids = merged.map((a) => a.id)
expect(ids).not.toContain('heat-0')
expect(ids).toContain('heat-1')
expect(ids).toContain('nws-h')
})
it('keeps derived alerts of types/flags without an overlapping advisory', () => {
const merged = mergeAdvisories(forecastAlerts, advisories)
// rain-0 is on 9/7 but flood advisory is 9/8 -> kept
// wind has no advisory -> kept
const ids = merged.map((a) => a.id)
expect(ids).toContain('rain-0')
expect(ids).toContain('wind-0')
// flood advisory itself appended
expect(ids).toContain('nws-flood')
})
it('does not drop derived alerts when no advisory overlaps', () => {
const merged = mergeAdvisories(forecastAlerts, [])
expect(merged.map((a) => a.id)).toEqual(forecastAlerts.map((a) => a.id))
})
it('respects per-type toggles (disabled advisories are not appended)', () => {
const merged = mergeAdvisories(forecastAlerts, advisories, { heat: false, precip: true, wind: true, cold: true, uv: true, storm: true, advisory: true })
const ids = merged.map((a) => a.id)
expect(ids).not.toContain('nws-h')
expect(ids).toContain('nws-flood')
})
})