import { describe, it, expect } from 'vitest' import { pointInRing, pointInPolygon, pointInGeometry, haversineKm, riskKey, spcRiskAtPoint, spcProbabilityAtPoint, normalizeOutlook, spcOutlookUrl, isUsPoint, reportIcon, reportLabel, normalizeStormReports, normalizeActiveAlerts, alertType, isUrgentWarning, } from '../../../src/lib/api/storm.js' // --- fixtures ------------------------------------------------------------- const box = (w, s, e, n) => [[[w, s], [e, s], [e, n], [w, n], [w, s]]] const fc = (features) => ({ type: 'FeatureCollection', features }) const catFeature = (label, geom) => ({ type: 'Feature', properties: { LABEL: label, ISSUE_ISO: '2026-10-17T16:14:00+00:00' }, geometry: geom, }) const lsrFeature = (props, lon, lat) => ({ type: 'Feature', properties: { type: 'Feature', ...props }, geometry: { type: 'Point', coordinates: [lon, lat] }, }) // --- tests ---------------------------------------------------------------- describe('storm geometry helpers', () => { const square = box(-97, 35, -96, 36) it('pointInRing handles inside, outside and open rings', () => { expect(pointInRing(-96.5, 35.5, square[0])).toBe(true) expect(pointInRing(-97.5, 35.5, square[0])).toBe(false) expect(pointInRing(-96.5, 36.5, square[0])).toBe(false) expect(pointInRing(-96.5, 35.5, [])).toBe(false) }) it('pointInPolygon excludes holes', () => { const withHole = [box(-97, 35, -96, 36)[0], box(-96.8, 35.2, -96.6, 35.4)[0]] expect(pointInPolygon(-96.9, 35.6, withHole)).toBe(true) // in the ring, outside the hole expect(pointInPolygon(-96.7, 35.3, withHole)).toBe(false) // inside the hole }) it('pointInGeometry supports Polygon and MultiPolygon', () => { const poly = { type: 'Polygon', coordinates: square } const multi = { type: 'MultiPolygon', coordinates: [box(-100, 30, -99, 31), box(-97, 35, -96, 36)], } expect(pointInGeometry(-96.5, 35.5, poly)).toBe(true) expect(pointInGeometry(-99.5, 30.5, multi)).toBe(true) expect(pointInGeometry(-98, 33, multi)).toBe(false) expect(pointInGeometry(-96.5, 35.5, null)).toBe(false) }) it('haversineKm is within a few km of the real distance', () => { // Tulsa (36.154, -95.993) → Oklahoma City (35.468, -97.516) ≈ 157 km const km = haversineKm(36.154, -95.993, 35.468, -97.516) expect(km).toBeGreaterThan(150) expect(km).toBeLessThan(165) // Zero distance for the same point. expect(haversineKm(36, -95, 36, -95)).toBeCloseTo(0, 5) }) it('isUsPoint recognises CONUS, Alaska, Hawaii and rejects elsewhere', () => { expect(isUsPoint(36.36, -95.81)).toBe(true) // Oklahoma expect(isUsPoint(61.2, -149.9)).toBe(true) // Anchorage expect(isUsPoint(21.3, -157.8)).toBe(true) // Honolulu expect(isUsPoint(51.5, -0.12)).toBe(false) // London // The CONUS band is deliberately wide, so southern Canada passes; the SPC // polygons (not this check) decide whether a point actually has an outlook. expect(isUsPoint(43.6, -79.4)).toBe(true) // Toronto expect(isUsPoint(19.4, -99.1)).toBe(false) // Mexico City }) }) describe('SPC outlook parsing', () => { it('riskKey maps SPC labels and ignores unknown ones', () => { expect(riskKey('TSTM')).toBe('TSTM') expect(riskKey(' slgt ')).toBe('SLGT') expect(riskKey('HIGH')).toBe('HIGH') expect(riskKey('Less Than 2% All Areas')).toBe('NONE') expect(riskKey(undefined)).toBe('NONE') }) it('spcRiskAtPoint returns the highest containing category', () => { const data = fc([ catFeature('TSTM', { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }), catFeature('ENH', { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }), catFeature('SLGT', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) }), ]) const inside = spcRiskAtPoint(-96.5, 35.5, data) expect(inside.key).toBe('ENH') expect(inside.short).toBe('ENH') expect(inside.label).toBe('Enhanced risk') // Inside SLGT + TSTM but outside ENH. expect(spcRiskAtPoint(-97.5, 36.5, data).key).toBe('SLGT') // Inside the TSTM box only. expect(spcRiskAtPoint(-99, 39, data).key).toBe('TSTM') // Outside everything. const none = spcRiskAtPoint(-80, 25, data) expect(none.key).toBe('NONE') expect(none.label).toBe('No outlook') }) it('spcRiskAtPoint handles MultiPolygon and empty collections', () => { const multi = catFeature('MDT', { type: 'MultiPolygon', coordinates: [box(-100, 30, -99, 31), box(-97, 35, -96, 36)], }) expect(spcRiskAtPoint(-96.5, 35.5, fc([multi])).key).toBe('MDT') expect(spcRiskAtPoint(-96.5, 35.5, fc([])).key).toBe('NONE') expect(spcRiskAtPoint(-96.5, 35.5, null).key).toBe('NONE') }) it('spcProbabilityAtPoint takes the max and ignores the "less than" background', () => { const data = fc([ catFeature('Less Than 2% All Areas', { type: 'Polygon', coordinates: box(-130, 20, -60, 55) }), catFeature('2%', { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }), catFeature('15%', { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }), ]) expect(spcProbabilityAtPoint(-96.5, 35.5, data)).toBe(15) expect(spcProbabilityAtPoint(-99, 39, data)).toBe(2) expect(spcProbabilityAtPoint(-80, 25, data)).toBe(0) expect(spcProbabilityAtPoint(-96.5, 35.5, null)).toBe(0) }) it('normalizeOutlook resolves all three days plus day-1 probabilities', () => { const src = { cat1: fc([catFeature('SLGT', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), cat2: fc([catFeature('TSTM', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), cat3: null, torn: fc([catFeature('5%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), wind: fc([catFeature('15%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), hail: fc([catFeature('30%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]), } const out = normalizeOutlook(src, -96.5, 35.5) expect(out.day1.key).toBe('SLGT') expect(out.day2.key).toBe('TSTM') expect(out.day3.key).toBe('NONE') expect(out.probabilities).toEqual({ tornado: 5, wind: 15, hail: 30 }) expect(out.issued).toBe('2026-10-17T16:14:00+00:00') }) it('spcOutlookUrl builds the light no-label GeoJSON URLs', () => { expect(spcOutlookUrl(1, 'cat')).toBe( 'https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson' ) expect(spcOutlookUrl(3, 'hail')).toBe( 'https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson' ) }) }) describe('local storm reports', () => { it('reportLabel and reportIcon map NWS type codes', () => { expect(reportLabel('TORNADO')).toBe('Tornado') expect(reportLabel('TSTM WND GST')).toBe('Thunderstorm wind gust') expect(reportLabel('FLASH FLOOD')).toBe('Flash flood') expect(reportLabel('HAIL')).toBe('Hail') expect(reportIcon('TORNADO')).toBe('🌪️') expect(reportIcon('TSTM WND DMG')).toBe('💨') expect(reportIcon('HAIL')).toBe('🧊') expect(reportIcon('FLASH FLOOD')).toBe('🌊') }) it('normalizeStormReports filters by radius and sorts nearest first', () => { const data = fc([ lsrFeature({ typetext: 'HAIL', city: 'Far', county: 'X', state: 'OK', valid: '2026-10-17T10:00:00Z', magnitude: '1.75' }, -94.0, 36.3), lsrFeature({ typetext: 'TORNADO', city: 'Near', county: 'Y', state: 'OK', valid: '2026-10-17T11:00:00Z', magnitude: '' }, -95.82, 36.36), lsrFeature({ typetext: 'TSTM WND GST', city: 'Middle', county: 'Z', state: 'OK', valid: '2026-10-17T09:00:00Z', magnitude: '60' }, -96.5, 36.36), lsrFeature({ typetext: 'HAIL', city: 'TooFar', county: 'W', state: 'KS', valid: '2026-10-17T09:00:00Z', magnitude: '2.00' }, -90.0, 40.0), ]) const out = normalizeStormReports(data, 36.36, -95.82, 100) expect(out.map((r) => r.city)).toEqual(['Near', 'Middle']) expect(out[0].label).toBe('Tornado') expect(out[0].distanceKm).toBeLessThan(1) expect(out[0].magnitude).toBeNull() expect(out[1].magnitude).toBe('60') expect(out[1].distanceKm).toBeGreaterThan(50) }) }) describe('NWS alert normalization', () => { const alert = (props) => ({ type: 'Feature', properties: { status: 'Actual', messageType: 'Alert', ...props }, geometry: { type: 'Polygon', coordinates: box(-96, 35, -95, 36) }, }) it('alertType buckets events like the notification store does', () => { expect(alertType('Tornado Warning')).toBe('storm') expect(alertType('Severe Thunderstorm Watch')).toBe('storm') expect(alertType('Flood Warning')).toBe('flood') expect(alertType('Excessive Heat Warning')).toBe('heat') expect(alertType('Red Flag Warning')).toBe('advisory') }) it('isUrgentWarning flags tornado/severe warnings and Extreme alerts', () => { expect(isUrgentWarning('Tornado Warning', 'Extreme')).toBe(true) expect(isUrgentWarning('Severe Thunderstorm Warning', 'Severe')).toBe(true) expect(isUrgentWarning('Flood Warning', 'Severe')).toBe(false) expect(isUrgentWarning('Heat Advisory', 'Moderate')).toBe(false) expect(isUrgentWarning('Special Weather Statement', 'Extreme')).toBe(true) }) it('normalizeActiveAlerts sorts by severity, drops cancels, keeps fields', () => { const data = fc([ alert({ id: 'b', event: 'Flood Advisory', severity: 'Minor', ends: '2026-10-17T20:00:00Z' }), alert({ id: 'a', event: 'Tornado Warning', severity: 'Extreme', ends: '2026-10-17T19:00:00Z', headline: 'Take cover now' }), alert({ id: 'c', event: 'Heat Advisory', severity: 'Moderate', ends: '2026-10-18T00:00:00Z' }), alert({ id: 'd', event: 'Old Warning', severity: 'Extreme', messageType: 'Cancel' }), ]) const out = normalizeActiveAlerts(data) expect(out.map((a) => a.id)).toEqual(['a', 'c', 'b']) expect(out[0].event).toBe('Tornado Warning') expect(out[0].headline).toBe('Take cover now') expect(out[0].type).toBe('storm') expect(out[0].geometry.type).toBe('Polygon') }) })