import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { StormStore, STORM_REFRESH_MS } from '../../../src/lib/stores/storm.svelte.js' import { app } from '../../../src/lib/stores/app.svelte.js' import { clearStormCache } from '../../../src/lib/api/storm.js' // ---- fixtures ------------------------------------------------------------ const box = (w, s, e, n) => [[[w, s], [e, s], [e, n], [w, n], [w, s]]] const catFc = (label) => ({ type: 'FeatureCollection', features: [{ type: 'Feature', properties: { LABEL: label, ISSUE_ISO: '2026-10-17T16:14:00+00:00' }, geometry: { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }, }], }) const probFc = (label) => ({ type: 'FeatureCollection', features: [{ type: 'Feature', properties: { LABEL: label }, geometry: { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }, }], }) const alertsFc = (features) => ({ type: 'FeatureCollection', features }) const alertFeature = (props) => ({ type: 'Feature', properties: { status: 'Actual', messageType: 'Alert', ...props }, geometry: { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }, }) const lsrFc = (features) => ({ type: 'FeatureCollection', features }) const lsr = (city, lon, lat) => ({ type: 'Feature', properties: { typetext: 'HAIL', city, county: 'X', state: 'OK', source: 'Trained Spotter', valid: '2026-10-17T11:00:00Z', magnitude: '1.75', remark: 'quarter size', }, geometry: { type: 'Point', coordinates: [lon, lat] }, }) // ---- fetch stub ---------------------------------------------------------- let calls let overrides function installFetch() { calls = [] overrides = {} globalThis.fetch = vi.fn(async (url) => { const u = String(url) calls.push(u) for (const [match, handler] of Object.entries(overrides)) { if (u.includes(match)) return handler(u) } const json = (body) => ({ ok: true, status: 200, json: async () => body }) if (u.includes('api.weather.gov/alerts/active')) return json(alertsFc([])) if (u.includes('day1otlk_cat')) return json(catFc('SLGT')) if (u.includes('day2otlk_cat')) return json(catFc('MRGL')) if (u.includes('day3otlk_cat')) return json(catFc('TSTM')) if (u.includes('day1otlk_torn')) return json(probFc('5%')) if (u.includes('day1otlk_wind')) return json(probFc('15%')) if (u.includes('day1otlk_hail')) return json(probFc('30%')) if (u.includes('mesonet.agron.iastate.edu')) return json(lsrFc([lsr('Near', -95.82, 36.36)])) return { ok: false, status: 404, json: async () => ({}) } }) } function setLocation(lat, lon, name = 'Test') { app.locations = [{ id: 'loc-1', name, lat, lon, isCurrent: true }] app.selectedLocationId = 'loc-1' } // ---- tests --------------------------------------------------------------- describe('StormStore', () => { let store beforeEach(() => { clearStormCache() installFetch() store = new StormStore() setLocation(36.3567, -95.8146, 'Home') }) afterEach(() => { store.stop() vi.restoreAllMocks() }) it('loads alerts, outlook and reports for the selected location', async () => { overrides['api.weather.gov/alerts/active'] = async () => ({ ok: true, status: 200, json: async () => alertsFc([ alertFeature({ id: 't1', event: 'Tornado Warning', severity: 'Extreme', ends: '2026-10-17T19:00:00Z' }), ]), }) await store.watch({ force: true }) expect(store.alerts).toHaveLength(1) expect(store.alerts[0].event).toBe('Tornado Warning') expect(store.banner?.id).toBe('t1') expect(store.outlook.day1.key).toBe('SLGT') expect(store.outlook.day2.key).toBe('MRGL') expect(store.outlook.probabilities).toEqual({ tornado: 5, wind: 15, hail: 30 }) expect(store.reports).toHaveLength(1) expect(store.reports[0].city).toBe('Near') expect(store.loading).toBe(false) expect(store.updatedAt).toBeGreaterThan(0) expect(store.alertCount).toBe(1) }) it('serves fresh data from the store/HTTP cache until it expires', async () => { await store.watch({ force: true }) const firstCount = calls.length expect(firstCount).toBeGreaterThan(0) await store.watch() // store-level: data for this location is fresh expect(calls.length).toBe(firstCount) await store.watch({ force: true }) // HTTP cache still warm expect(calls.length).toBe(firstCount) clearStormCache() // simulate the TTLs expiring await store.watch({ force: true }) expect(calls.length).toBeGreaterThan(firstCount) }) it('refetches when the selected location changes', async () => { await store.watch({ force: true }) const firstCount = calls.length setLocation(27.2, -82.3, 'Elsewhere') await store.watch() const fresh = calls.slice(firstCount) expect(fresh.length).toBeGreaterThan(0) expect(fresh.some((u) => u.includes('36.3567'))).toBe(false) // old point not refetched expect(fresh.some((u) => u.includes('27.2'))).toBe(true) // new point queried }) it('keeps the other feeds when one request fails', async () => { overrides['mesonet.agron.iastate.edu'] = async () => ({ ok: false, status: 503, json: async () => ({}), }) await store.watch({ force: true }) expect(store.reports).toEqual([]) expect(store.outlook.day1.key).toBe('SLGT') expect(store.error).toMatch(/reports/) }) it('skips SPC/LSR requests outside the US and clears the outlook', async () => { setLocation(51.5, -0.12, 'London') await store.watch({ force: true }) expect(store.hasUsCoverage).toBe(false) expect(store.outlook).toBeNull() expect(store.reports).toEqual([]) expect(calls.some((u) => u.includes('spc.noaa.gov'))).toBe(false) expect(calls.some((u) => u.includes('api.weather.gov'))).toBe(true) }) it('reset() drops data and the location key', async () => { await store.watch({ force: true }) store.reset() expect(store.alerts).toEqual([]) expect(store.outlook).toBeNull() expect(store.reports).toEqual([]) expect(store.updatedAt).toBe(0) }) it('start() schedules a refresh at the documented interval', () => { vi.useFakeTimers() try { const spy = vi.spyOn(store, 'watch').mockResolvedValue() store.start() store.start() // idempotent vi.advanceTimersByTime(STORM_REFRESH_MS + 10) expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith({ force: true }) store.stop() } finally { vi.useRealTimers() } }) })