- 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.
323 lines
10 KiB
JavaScript
323 lines
10 KiB
JavaScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { NotificationStore } from '../../../src/lib/stores/notifications.svelte.js'
|
|
import { app } from '../../../src/lib/stores/app.svelte.js'
|
|
|
|
// We need to mock app.forecastData and app.settings for alert analysis
|
|
describe('NotificationStore', () => {
|
|
let notifications
|
|
|
|
beforeEach(() => {
|
|
notifications = new NotificationStore()
|
|
notifications.alerts = []
|
|
notifications.dismissedIds.clear()
|
|
})
|
|
|
|
function setForecast(overrides = {}) {
|
|
app.forecastData = {
|
|
current: {
|
|
weather_code: 0,
|
|
...overrides.current,
|
|
},
|
|
daily: {
|
|
time: ['2026-07-22', '2026-07-23', '2026-07-24'],
|
|
precipitation_probability_max: [10, 5, 0],
|
|
weather_code: [0, 1, 2],
|
|
temperature_2m_max: [25, 27, 28],
|
|
temperature_2m_min: [15, 16, 17],
|
|
uv_index_max: [3, 4, 5],
|
|
wind_speed_10m_max: [15, 12, 10],
|
|
...overrides.daily,
|
|
},
|
|
}
|
|
|
|
app.settings = {
|
|
alertsEnabled: true,
|
|
windUnit: 'kmh',
|
|
alertThresholds: {
|
|
precip: 70,
|
|
windGust: 40,
|
|
uvIndex: 6,
|
|
tempHigh: 35,
|
|
tempLow: 0,
|
|
thunderstorm: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
it('does not generate alerts when disabled', () => {
|
|
setForecast()
|
|
app.settings.alertsEnabled = false
|
|
|
|
notifications.analyze()
|
|
expect(notifications.alerts).toHaveLength(0)
|
|
})
|
|
|
|
it('generates no alerts for calm weather', () => {
|
|
setForecast()
|
|
notifications.analyze()
|
|
expect(notifications.alerts).toHaveLength(0)
|
|
})
|
|
|
|
it('generates precipitation alert when threshold exceeded', () => {
|
|
setForecast({
|
|
daily: { precipitation_probability_max: [85, 30, 10] },
|
|
})
|
|
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.type === 'precip')).toBe(true)
|
|
})
|
|
|
|
it('generates thunderstorm alert for severe codes in forecast', () => {
|
|
setForecast({
|
|
daily: { weather_code: [95, 0, 0] },
|
|
})
|
|
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.type === 'storm')).toBe(true)
|
|
})
|
|
|
|
it('generates thunderstorm alert for current conditions', () => {
|
|
setForecast({
|
|
current: { weather_code: 95 },
|
|
})
|
|
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.id === 'storm-now')).toBe(true)
|
|
})
|
|
|
|
it('generates heat alert', () => {
|
|
setForecast({
|
|
daily: { temperature_2m_max: [38, 35, 32] },
|
|
})
|
|
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.type === 'heat')).toBe(true)
|
|
})
|
|
|
|
it('generates cold alert', () => {
|
|
setForecast({
|
|
daily: { temperature_2m_min: [-5, 0, 5] },
|
|
})
|
|
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.type === 'cold')).toBe(true)
|
|
})
|
|
|
|
it('generates UV alert', () => {
|
|
setForecast({
|
|
daily: { uv_index_max: [8, 5, 3] },
|
|
})
|
|
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.type === 'uv')).toBe(true)
|
|
})
|
|
|
|
it('generates wind alert', () => {
|
|
setForecast({
|
|
daily: { wind_speed_10m_max: [50, 20, 15] },
|
|
})
|
|
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.type === 'wind')).toBe(true)
|
|
})
|
|
|
|
it('dismisses an alert', () => {
|
|
setForecast({
|
|
daily: { precipitation_probability_max: [85, 30, 10] },
|
|
})
|
|
|
|
notifications.analyze()
|
|
const precipAlert = notifications.alerts.find((a) => a.type === 'precip')
|
|
expect(precipAlert).toBeTruthy()
|
|
|
|
notifications.dismiss(precipAlert.id)
|
|
expect(notifications.alerts.find((a) => a.id === precipAlert.id)).toBeUndefined()
|
|
})
|
|
|
|
it('resets dismissed alerts', () => {
|
|
setForecast({
|
|
daily: { precipitation_probability_max: [85, 30, 10] },
|
|
})
|
|
|
|
notifications.analyze()
|
|
const alertCount = notifications.alerts.length
|
|
notifications.dismiss(notifications.alerts[0].id)
|
|
|
|
notifications.resetDismissed()
|
|
notifications.analyze()
|
|
expect(notifications.alerts.length).toBe(alertCount)
|
|
})
|
|
|
|
it('filters alerts for only first 3 days', () => {
|
|
setForecast({
|
|
daily: {
|
|
time: ['2026-07-22', '2026-07-23', '2026-07-24', '2026-07-25', '2026-07-26', '2026-07-27', '2026-07-28'],
|
|
precipitation_probability_max: [80, 80, 80, 80, 80, 80, 80],
|
|
weather_code: [0, 0, 0, 0, 0, 0, 0],
|
|
temperature_2m_max: [25, 25, 25, 25, 25, 25, 25],
|
|
temperature_2m_min: [15, 15, 15, 15, 15, 15, 15],
|
|
uv_index_max: [3, 3, 3, 3, 3, 3, 3],
|
|
wind_speed_10m_max: [15, 15, 15, 15, 15, 15, 15],
|
|
},
|
|
})
|
|
|
|
notifications.analyze()
|
|
const precipAlerts = notifications.alerts.filter((a) => a.type === 'precip')
|
|
// Should only alert for the first 3 days (i < 3)
|
|
expect(precipAlerts.length).toBeLessThanOrEqual(3)
|
|
})
|
|
})
|
|
|
|
describe('NotificationStore per-type toggles', () => {
|
|
let notifications
|
|
|
|
beforeEach(() => {
|
|
notifications = new NotificationStore()
|
|
notifications.alerts = []
|
|
notifications.dismissedIds.clear()
|
|
notifications._notifiedIds.clear()
|
|
})
|
|
|
|
function setForecastWith(overrides = {}) {
|
|
app.forecastData = {
|
|
current: { weather_code: 0, ...(overrides.current || {}) },
|
|
daily: {
|
|
time: ['2026-07-22', '2026-07-23', '2026-07-24'],
|
|
precipitation_probability_max: [85, 30, 10],
|
|
weather_code: [95, 0, 0],
|
|
temperature_2m_max: [38, 35, 32],
|
|
temperature_2m_min: [-5, 0, 5],
|
|
uv_index_max: [8, 5, 3],
|
|
wind_speed_10m_max: [50, 20, 15],
|
|
...(overrides.daily || {}),
|
|
},
|
|
}
|
|
app.settings = {
|
|
alertsEnabled: true,
|
|
units: 'metric',
|
|
alertThresholds: { precip: 70, windGust: 40, uvIndex: 6, tempHigh: 35, tempLow: 0 },
|
|
notificationTypes: {
|
|
storm: true, precip: true, heat: true, cold: true, uv: true, wind: true,
|
|
...(overrides.notificationTypes || {}),
|
|
},
|
|
}
|
|
}
|
|
|
|
it('fires all alert types when every toggle is on', () => {
|
|
setForecastWith()
|
|
notifications.analyze()
|
|
const types = new Set(notifications.alerts.map((a) => a.type))
|
|
expect(types).toEqual(new Set(['storm', 'precip', 'heat', 'cold', 'uv', 'wind']))
|
|
})
|
|
|
|
it('suppresses heat alert when heat toggle is off', () => {
|
|
setForecastWith({ notificationTypes: { heat: false } })
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.type === 'heat')).toBe(false)
|
|
// Other types unaffected
|
|
expect(notifications.alerts.some((a) => a.type === 'precip')).toBe(true)
|
|
})
|
|
|
|
it('suppresses storm alert when storm toggle is off', () => {
|
|
setForecastWith({ notificationTypes: { storm: false } })
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.type === 'storm')).toBe(false)
|
|
})
|
|
|
|
it('suppresses every type independently', () => {
|
|
const offs = ['storm', 'precip', 'heat', 'cold', 'uv', 'wind']
|
|
for (const off of offs) {
|
|
setForecastWith({ notificationTypes: { [off]: false } })
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.type === off)).toBe(
|
|
false,
|
|
`expected ${off} alert to be suppressed`
|
|
)
|
|
}
|
|
})
|
|
|
|
it('suppresses all alerts when master alertsEnabled is off', () => {
|
|
setForecastWith()
|
|
app.settings.alertsEnabled = false
|
|
notifications.analyze()
|
|
expect(notifications.alerts).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('NotificationStore with NWS advisories', () => {
|
|
let notifications
|
|
|
|
beforeEach(() => {
|
|
notifications = new NotificationStore()
|
|
notifications.alerts = []
|
|
notifications.dismissedIds.clear()
|
|
notifications._notifiedIds.clear()
|
|
notifications._nwsAdvisories = []
|
|
})
|
|
|
|
function seedAdvisoryForecast(overrides = {}) {
|
|
app.forecastData = {
|
|
current: { weather_code: 95, time: '2026-07-22T12:00:00' },
|
|
daily: {
|
|
time: ['2026-07-22', '2026-07-23'],
|
|
precipitation_probability_max: [85, 10],
|
|
weather_code: [95, 95],
|
|
temperature_2m_max: [38, 25],
|
|
temperature_2m_min: [20, 15],
|
|
uv_index_max: [3, 3],
|
|
wind_speed_10m_max: [10, 10],
|
|
...(overrides.daily || {}),
|
|
},
|
|
}
|
|
app.settings = {
|
|
alertsEnabled: true,
|
|
units: 'metric',
|
|
alertThresholds: { precip: 70, windGust: 40, uvIndex: 6, tempHigh: 35, tempLow: 0 },
|
|
notificationTypes: { storm: true, precip: true, heat: true, cold: true, uv: true, wind: true, advisory: true },
|
|
}
|
|
}
|
|
|
|
it('appends an advisory and does not duplicate a storm that overlaps', () => {
|
|
seedAdvisoryForecast()
|
|
notifications._nwsAdvisories = [
|
|
{ id: 'nws-storm', type: 'storm', source: 'nws', severity: 'danger', icon: '⛈️',
|
|
message: 'Severe Thunderstorm Warning issued', event: 'Severe Thunderstorm Warning',
|
|
effectiveDate: '2026-07-22', expiresDate: '2026-07-22' },
|
|
]
|
|
notifications.analyze()
|
|
notifications.analyze() // idempotent
|
|
|
|
const stormAlerts = notifications.alerts.filter((a) => a.type === 'storm')
|
|
// The derived storm-0 (7/22) is suppressed by the advisory; storm-1 stays.
|
|
expect(stormAlerts.some((a) => a.id === 'nws-storm')).toBe(true)
|
|
expect(stormAlerts.some((a) => a.id === 'storm-0')).toBe(false)
|
|
expect(stormAlerts.some((a) => a.id === 'storm-1')).toBe(true)
|
|
})
|
|
|
|
it('shows both a derived alert and an advisory when dates do not overlap', () => {
|
|
seedAdvisoryForecast()
|
|
// Heat advisory covers 7/23 only; derived heat is 7/22 -> both stay.
|
|
notifications._nwsAdvisories = [
|
|
{ id: 'nws-heat', type: 'heat', source: 'nws', severity: 'warning', icon: '🔥',
|
|
message: 'Heat Advisory issued', event: 'Heat Advisory',
|
|
effectiveDate: '2026-07-23', expiresDate: '2026-07-23' },
|
|
]
|
|
notifications.analyze()
|
|
const heatAlerts = notifications.alerts.filter((a) => a.type === 'heat')
|
|
expect(heatAlerts.some((a) => a.id === 'heat-0')).toBe(true)
|
|
expect(heatAlerts.some((a) => a.id === 'nws-heat')).toBe(true)
|
|
})
|
|
|
|
it('respects the advisory type toggle', () => {
|
|
seedAdvisoryForecast()
|
|
app.settings.notificationTypes.advisory = false
|
|
notifications._nwsAdvisories = [
|
|
{ id: 'nws-adv', type: 'advisory', source: 'nws', severity: 'info', icon: '⚠️',
|
|
message: 'Special Weather Statement issued', event: 'Special Weather Statement',
|
|
effectiveDate: '2026-07-22', expiresDate: '2026-07-23' },
|
|
]
|
|
notifications.analyze()
|
|
expect(notifications.alerts.some((a) => a.id === 'nws-adv')).toBe(false)
|
|
})
|
|
})
|