weather_app/tests/lib/stores/notifications.test.js
hermes-explorigin 897f49ae22
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 1m1s
Test, Build & Deploy / deploy (push) Successful in 22s
Add installable PWA (manifest, service worker, icons) + per-type notification toggles
- manifest.webmanifest + apple/maskable PNG icons; relative paths so the
  app works both at dev root and under /weather
- plain-JS service worker (sw.js) with notificationclick/push handlers and
  an SW-mediated showNotification() path for native Android notifications
- src/lib/pwa.js bootstraps registration and routes alerts to the SW
- Every alert kind (storm/precip/heat/cold/uv/wind) is now a per-type
  preference toggle in Settings; removed the old alertThresholds.thunderstorm
  boolean in favor of notificationTypes.storm
- Store dedups native notifications per forecast day and fires best-effort
  native notifications for newly-appeared alerts
- inline-assets keeps manifest/sw/icons in dist; deploy.yml uploads + verifies
  all PWA files via WebDAV
2026-09-08 00:59:19 +00:00

246 lines
7.2 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)
})
})