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.
105 lines
3.9 KiB
JavaScript
105 lines
3.9 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { mount, unmount, tick } from 'svelte'
|
|
import StormWatch from '../../src/components/StormWatch.svelte'
|
|
import { storm } from '../../src/lib/stores/storm.svelte.js'
|
|
import { app } from '../../src/lib/stores/app.svelte.js'
|
|
|
|
const risk = (key, label, short, color) => ({ key, label, short, color, props: null })
|
|
|
|
describe('StormWatch component', () => {
|
|
let target
|
|
let instance
|
|
|
|
beforeEach(() => {
|
|
app.locations = [{ id: 'x', name: 'Home', lat: 36.3567, lon: -95.8146, isCurrent: true }]
|
|
app.selectedLocationId = 'x'
|
|
app.settings = { ...app.settings, units: 'imperial' }
|
|
app.forecastData = { timezone: 'America/Chicago' }
|
|
storm.updatedAt = Date.now()
|
|
target = document.createElement('div')
|
|
document.body.appendChild(target)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (instance) await unmount(instance)
|
|
target?.remove()
|
|
instance = null
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
it('renders alerts, the outlook and nearby reports', async () => {
|
|
storm.alerts = [{
|
|
id: 'a1', event: 'Tornado Warning', type: 'storm', severity: 'Extreme',
|
|
severityRank: 3, headline: 'Tornado Warning until 5:30 PM',
|
|
description: 'Take shelter now.', instruction: 'Go to an interior room.',
|
|
areaDesc: 'Rogers, OK', senderName: 'NWS Tulsa OK',
|
|
ends: '2026-10-17T22:30:00Z', expires: '2026-10-17T22:45:00Z',
|
|
}]
|
|
storm.outlook = {
|
|
day1: risk('SLGT', 'Slight risk', 'SLGT', '#ffff00'),
|
|
day2: risk('TSTM', 'General thunderstorms', 'TSTM', '#55bb55'),
|
|
day3: risk('NONE', 'No outlook', '—', '#7b8eb5'),
|
|
probabilities: { tornado: 5, wind: 15, hail: 30 },
|
|
issued: '2026-10-17T16:14:00Z',
|
|
}
|
|
storm.reports = [{
|
|
id: 'r1', type: 'HAIL', label: 'Hail', icon: '🧊', magnitude: '1.75',
|
|
city: 'Claremore', county: 'Rogers', state: 'OK', source: 'Trained Spotter',
|
|
remark: 'Quarter size hail on Highway 20', valid: '2026-10-17T21:00:00Z',
|
|
lat: 36.3, lon: -95.6, distanceKm: 12.4,
|
|
}]
|
|
|
|
instance = mount(StormWatch, { target })
|
|
await tick()
|
|
|
|
const text = target.textContent
|
|
expect(text).toContain('Storm Watch')
|
|
expect(text).toContain('Tornado Warning')
|
|
expect(text).toContain('Active alerts')
|
|
expect(text).toContain('Slight risk')
|
|
expect(text).toContain('SLGT')
|
|
expect(text).toContain('15%') // wind probability
|
|
expect(text).toContain('Claremore')
|
|
expect(text).toContain('Hail')
|
|
expect(text).toContain('Rogers Co.')
|
|
expect(text).toContain('Quarter size hail')
|
|
expect(target.querySelectorAll('.alert-item')).toHaveLength(1)
|
|
expect(target.querySelectorAll('.report-item')).toHaveLength(1)
|
|
expect(target.querySelectorAll('.outlook-day')).toHaveLength(3)
|
|
})
|
|
|
|
it('shows all-clear states when nothing is active', async () => {
|
|
storm.alerts = []
|
|
storm.reports = []
|
|
storm.outlook = {
|
|
day1: risk('NONE', 'No outlook', '—', '#7b8eb5'),
|
|
day2: risk('NONE', 'No outlook', '—', '#7b8eb5'),
|
|
day3: risk('NONE', 'No outlook', '—', '#7b8eb5'),
|
|
probabilities: { tornado: 0, wind: 0, hail: 0 },
|
|
issued: null,
|
|
}
|
|
|
|
instance = mount(StormWatch, { target })
|
|
await tick()
|
|
|
|
const text = target.textContent
|
|
expect(text).toContain('No active watches, warnings or advisories')
|
|
expect(text).toContain('No severe-weather area covers this location')
|
|
expect(text).toContain('No storm reports within')
|
|
})
|
|
|
|
it('renders the non-US note instead of an outlook', async () => {
|
|
app.locations = [{ id: 'y', name: 'London', lat: 51.5, lon: -0.12, isCurrent: true }]
|
|
app.selectedLocationId = 'y'
|
|
storm.alerts = []
|
|
storm.reports = []
|
|
storm.outlook = null
|
|
|
|
instance = mount(StormWatch, { target })
|
|
await tick()
|
|
|
|
expect(target.textContent).toContain('SPC outlooks cover the United States only')
|
|
expect(target.textContent).toContain('Local Storm Reports cover the United States only')
|
|
})
|
|
})
|