import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import path from 'node:path' import { compassToDeg, parseWind, shortForecastToWmo, dateKey, localTimeString, normalizeNWS, mergeFallbackData, } from '../src/lib/api/nws.js' const FIX = (name) => JSON.parse(readFileSync(path.join('tests/fixtures/nws', name), 'utf8')) describe('nws helpers', () => { it('shortForecastToWmo maps text to WMO codes', () => { expect(shortForecastToWmo('Sunny')).toBe(0) expect(shortForecastToWmo('Mostly Sunny')).toBe(2) expect(shortForecastToWmo('Partly Cloudy')).toBe(2) expect(shortForecastToWmo('Overcast')).toBe(3) expect(shortForecastToWmo('Heavy Rain')).toBe(65) expect(shortForecastToWmo('Light Snow')).toBe(71) expect(shortForecastToWmo('Areas of Fog')).toBe(45) }) it('downgrades low-chance thunderstorms so they are not shown as storms', () => { // The word matters less than the raw probability. // 17% "slight chance" of showers/thunderstorms -> light rain showers, NOT a storm. expect(shortForecastToWmo('Slight Chance Showers And Thunderstorms')).toBe(80) expect(shortForecastToWmo('Slight Chance Showers And Thunderstorms', 17)).toBe(80) expect(shortForecastToWmo('Thunderstorms', 20)).toBe(80) expect(shortForecastToWmo('Isolated Thunderstorms', 20)).toBe(80) // A real storm (wordy + high probability) stays a storm. expect(shortForecastToWmo('Thunderstorms', 70)).toBe(95) expect(shortForecastToWmo('Severe Thunderstorm Warning', 80)).toBe(95) // Low-chance plain rain downgrades to light rain too. expect(shortForecastToWmo('Chance of Rain', 15)).toBe(61) expect(shortForecastToWmo('Showers And Thunderstorms', 50)).toBe(95) }) it('compassToDeg maps compass points to degrees', () => { expect(compassToDeg('N')).toBe(0) expect(compassToDeg('NE')).toBe(45) expect(compassToDeg('SW')).toBe(225) expect(compassToDeg('W')).toBe(270) expect(compassToDeg(null)).toBeNull() }) it('parseWind takes the max speed from a range string', () => { expect(parseWind('5 to 10 mph')).toBe(10) expect(parseWind('12 mph')).toBe(12) expect(parseWind('gusts up to 20 mph')).toBe(20) expect(parseWind(null)).toBeNull() }) it('dateKey and localTimeString produce naive local times (no UTC shift)', () => { const tz = 'America/Chicago' expect(dateKey('2026-08-16T15:00:00-05:00', tz)).toBe('2026-08-16') expect(localTimeString('2026-08-16T15:00:00-05:00', tz)).toBe('2026-08-16T15:00') expect(localTimeString('2026-08-19T15:00:00-05:00', tz)).toMatch(/^2026-08-19T/) }) }) describe('normalizeNWS (recorded live fixture for Rogers County, OK)', () => { const points = FIX('points.json') const forecast = FIX('forecast.json') const hourly = FIX('hourly.json') const grid = FIX('grid.json') const timeZone = points.properties.timeZone const data = normalizeNWS({ points, forecast, hourly, grid, timeZone, units: 'imperial' }) it('marks the source as nws', () => { expect(data._source).toBe('nws') }) it('normalizes air high (not heat index) for the 7-day, matching NWS values', () => { // NWS daytime highs: Today 103, Mon 94, Wed 104 ... (NO 116 outlier) expect(data.daily.time).toHaveLength(7) expect(data.daily.temperature_2m_max[0]).toBe(103) // Today expect(data.daily.temperature_2m_max[1]).toBe(94) // Monday expect(data.daily.temperature_2m_max[3]).toBe(104) // Wednesday expect(Math.max(...data.daily.temperature_2m_max)).toBeLessThan(115) expect(data.daily.temperature_2m_min[0]).toBe(75) expect(data.daily.precipitation_probability_max[0]).toBe(24) // Today/Monday are only ~17-24% "slight chance" of storms -> light showers. expect(data.daily.weather_code[0]).toBe(80) expect(data.daily.weather_code[1]).toBe(80) expect(data.current.weather_code).toBe(2) // "Mostly Sunny" hour }) it('builds a realistic current from the first hourly period', () => { expect(data.current.temperature_2m).toBe(102) expect(data.current.relative_humidity_2m).toBe(35) expect(data.current.wind_direction_10m).toBe(225) // SW expect(data.current.time).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/) }) it('normalizes to metric on request', () => { const m = normalizeNWS({ points, forecast, hourly, grid, timeZone, units: 'metric' }) expect(m.current.temperature_2m).toBeCloseTo((102 - 32) * 5 / 9, 1) expect(m.daily.temperature_2m_max[0]).toBeCloseTo((103 - 32) * 5 / 9, 1) expect(m.current.wind_speed_10m).toBeCloseTo(10 * 1.609344, 1) }) }) describe('mergeFallbackData', () => { it('backfills pressure + UV from Open-Meteo and tags them (non-selected source)', () => { const nws = { _source: 'nws', current: { temperature_2m: 103, pressure_msl: null, uv_index: null }, daily: { time: ['2026-08-16'], uv_index_max: [] }, } const om = { current: { pressure_msl: 1011.2, uv_index: 7.4 }, daily: { uv_index_max: [7.2, 6, 5] } } const r = mergeFallbackData(nws, om) expect(r.current.pressure_msl).toBe(1011.2) expect(r.current.uv_index).toBe(7.4) expect(r.daily.uv_index_max).toEqual([7.2, 6, 5]) expect(r._fallback).toMatchObject({ pressure: true, uv: true }) }) it('does not tag when Open-Meteo lacks the fields', () => { const nws = { current: { pressure_msl: null, uv_index: null }, daily: {} } const om = { current: { pressure_msl: null, uv_index: null }, daily: {} } const r = mergeFallbackData(nws, om) expect(r._fallback).toBeUndefined() }) })