- 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.
171 lines
6.3 KiB
JavaScript
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')
|
|
})
|
|
})
|