const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const { convertWeatherSnapshot, normalizeOpenMeteoSnapshot, normalizePirateWeatherSnapshot } = require('../src/data/weather-units'); const placeholderUtils = require('../src/web/public/js/shared/placeholder-utils'); const weatherData = require('../src/web/public/js/shared/weather-data'); test('hourly weather advances at the local hour boundary without refetching', () => { const snapshot = { hourly: { time: ['2026-10-23T02:00', '2026-10-23T03:00', '2026-10-23T04:00'], temperature_2m: [12, 13, 14], weather_code: [61, 3, 0], temperature_unit: '\u00b0C' } }; const before = weatherData.filterHourlySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-23T01:59:59.999Z')); assert.deepEqual(before.hourly.temperature_2m, [12, 13, 14]); const after = weatherData.filterHourlySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-23T02:00:00Z')); assert.deepEqual(after.hourly.time, ['2026-10-23T03:00', '2026-10-23T04:00']); assert.deepEqual(after.hourly.temperature_2m, [13, 14]); assert.deepEqual(after.hourly.weather_code, [3, 0]); assert.equal(after.hourly.temperature_unit, '\u00b0C'); assert.equal(snapshot.hourly.time.length, 3); assert.deepEqual(weatherData.filterHourlySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-24T00:00Z')).hourly.time, []); }); test('hourly weather handles UTC provider timestamps and fractional-offset locations', () => { const snapshot = { hourly: { time: ['2026-10-23T02:00:00.000Z', '2026-10-23T03:00:00.000Z'], temperature_2m: [12, 13] } }; assert.deepEqual(weatherData.filterHourlySnapshot(snapshot, 'Asia/Kathmandu', Date.parse('2026-10-23T03:00Z')).hourly.temperature_2m, [13]); const local = { hourly: { time: ['2026-10-23T02:00', '2026-10-23T03:00'], temperature_2m: [12, 13] } }; assert.deepEqual(weatherData.filterHourlySnapshot(local, 'Asia/Kathmandu', Date.parse('2026-10-22T21:15Z')).hourly.temperature_2m, [13]); }); test('daily weather advances at local midnight and keeps forecast columns aligned', () => { const snapshot = { daily: { time: ['2026-10-23', '2026-10-24', '2026-10-25'], temperature_2m_max: [12, 13, 14], weather_code: [61, 3, 0], temperature_unit: '\u00b0C' } }; const before = weatherData.filterDailySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-23T22:59:59.999Z')); assert.deepEqual(before.daily.temperature_2m_max, [12, 13, 14]); const after = weatherData.filterDailySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-23T23:00:00Z')); assert.deepEqual(after.daily.time, ['2026-10-24', '2026-10-25']); assert.deepEqual(after.daily.temperature_2m_max, [13, 14]); assert.deepEqual(after.daily.weather_code, [3, 0]); assert.equal(after.daily.temperature_unit, '\u00b0C'); assert.equal(snapshot.daily.time.length, 3); assert.deepEqual(weatherData.filterDailySnapshot(snapshot, 'Europe/London', Date.parse('2026-10-26T00:00Z')).daily.time, []); }); test('daily weather interprets offset timestamps in the location timezone', () => { const snapshot = { daily: { time: ['2026-10-23T07:00:00Z', '2026-10-24T07:00:00Z'], temperature_2m_max: [12, 13] } }; assert.deepEqual(weatherData.filterDailySnapshot(snapshot, 'America/Los_Angeles', Date.parse('2026-10-24T06:59:59Z')).daily.temperature_2m_max, [12, 13]); assert.deepEqual(weatherData.filterDailySnapshot(snapshot, 'America/Los_Angeles', Date.parse('2026-10-24T07:00Z')).daily.temperature_2m_max, [13]); }); for (const mode of ['hourly', 'daily']) test('weather previews and held player regions roll ' + mode + ' placeholders forward and clean up timers', () => { let now = Date.parse(mode === 'hourly' ? '2026-10-23T01:59:59.999Z' : '2026-10-23T22:59:59.999Z'); const times = mode === 'hourly' ? ['2026-10-23T02:00', '2026-10-23T03:00'] : ['2026-10-23', '2026-10-24']; const field = mode === 'hourly' ? 'temperature_2m' : 'temperature_2m_max'; const alias = mode === 'hourly' ? 'temp' : 'temp_max'; const location = { id: 1, timezone: 'Europe/London', responseJson: { [mode]: { time: times, [field]: [12, 13], weather_code: [61, 3] } } }; const content = { weather_location_id: 1, value: '
{{' + mode + '.0.time}}:{{' + mode + '.0.' + alias + '}}
{{' + mode + '.0.icon}}' }; const region = { pixelWidth: 600, pixelHeight: 300, baseStyle: '' }; const timers = new Map(); let timerId = 0; class Clock extends Date { static now() { return now; } } const renderers = {}; for (const target of ['editor', 'player']) { const sandbox = { Date: Clock, document: { addEventListener() {} }, window: { placeholderUtils, pulseWeatherData: { filterForecastSnapshot(snapshot, timeZone) { return weatherData.filterForecastSnapshot(snapshot, timeZone, now); } }, pulseRegionTypes: { register(_type, module) { renderers.editor = module; } }, pulsePlayerRegionTypes: { register(_type, module) { renderers.player = module; } }, setTimeout(callback, delay) { timers.set(++timerId, { callback, delay }); return timerId; }, clearTimeout(id) { timers.delete(id); } }, initialData: { weatherLocations: [location] }, escapeHtml: value => String(value), renderEditorJsContent: value => value }; const script = target === 'editor' ? '../src/web/public/js/regions/type/weather.js' : '../src/player/regions/weather.js'; vm.runInNewContext(fs.readFileSync(require.resolve(script), 'utf8'), sandbox); } const before = '' + times[0] + ':12
'; const after = '' + times[1] + ':13
'; assert.ok(renderers.editor.renderPreview(region, content, { weatherLocations: [location] }).includes(before)); const playerHtml = renderers.player.renderRegion(region, content); assert.ok(playerHtml.includes(before)); assert.match(playerHtml, /data-weather-content=/); const output = { innerHTML: '' }; const element = { isConnected: true, getAttribute() { return JSON.stringify(content); }, querySelector() { return output; } }; const root = { querySelectorAll() { return [element]; } }; renderers.player.initRegion(root); assert.ok(output.innerHTML.includes(before)); assert.equal(timers.get(element.__weatherTimer).delay, 1); const timer = timers.get(element.__weatherTimer); timers.delete(element.__weatherTimer); now += 1; timer.callback(); assert.ok(output.innerHTML.includes(after)); assert.ok(!output.innerHTML.includes(before)); assert.ok(renderers.editor.renderPreview(region, content, { weatherLocations: [location] }).includes(after)); renderers.player.destroyRegion(root); assert.equal(timers.size, 0); now = Date.parse('2026-10-26T00:00Z'); const expired = renderers.editor.renderPreview(region, content, { weatherLocations: [location] }); assert.doesNotMatch(expired, /bi-cloud|bi-sun|bi-lightning/); }); for (const mode of ['hourly', 'daily']) test('weather thumbnails filter cached ' + mode + ' fields at capture time', () => { const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnails'); const originalDateNow = Date.now; Date.now = () => Date.parse(mode === 'hourly' ? '2026-10-23T02:00Z' : '2026-10-23T23:00Z'); try { const times = mode === 'hourly' ? ['2026-10-23T02:00', '2026-10-23T03:00'] : ['2026-10-23', '2026-10-24']; const field = mode === 'hourly' ? 'temperature_2m' : 'temperature_2m_max'; const alias = mode === 'hourly' ? 'temp' : 'temp_max'; const slide = { template: { regions: [{ region_key: 'weather', region_type: 'weather', width: 600, height: 300 }] }, content: { weather: { type: 'weather', value: '{{' + mode + '.0.time}}:{{' + mode + '.0.' + alias + '}}
' } } }; const payload = buildThumbnailPreviewPayload(slide, { getItem() { return { timezone: 'Europe/London', [mode]: { time: times, [field]: [12, 13] } }; } }); assert.ok(payload.html.includes(times[1] + ':13')); assert.ok(!payload.html.includes(times[0] + ':12')); } finally { Date.now = originalDateNow; } }); test('Pirate Weather snapshots normalize into shared weather fields and selected units', () => { const time = Date.parse('2026-10-23T12:00:00Z') / 1000; const snapshot = { timezone: 'Europe/London', flags: { units: 'si' }, currently: { time, temperature: 20, apparentTemperature: 18, humidity: 0.8, windSpeed: 10, precipIntensity: 2.54, cloudCover: 0.25, icon: 'partly-cloudy-day' }, hourly: { data: [{ time, temperature: 20, windSpeed: 10, precipIntensity: 2.54, precipProbability: 0.5, icon: 'rain' }, { time: time + 3600, icon: 'snow' }] }, daily: { data: [{ time, temperatureMax: 25, temperatureMin: 15, precipIntensity: 1, icon: 'rain', sunriseTime: time - 21600, sunsetTime: time + 21600 }] } }; const normalized = normalizePirateWeatherSnapshot(snapshot); assert.equal(normalized.current.temperature_2m, 20); assert.equal(normalized.current.relative_humidity_2m, 80); assert.equal(normalized.current.cloud_cover, 25); assert.equal(normalized.current.weather_code, 2); assert.equal(normalized.current.is_day, 1); assert.equal(normalized.hourly.time[0], '2026-10-23T12:00:00.000Z'); assert.deepEqual(normalized.hourly.weather_code, [61, 71]); assert.equal(normalized.hourly.precipitation_probability[0], 50); assert.equal(normalized.daily.time[0], '2026-10-23'); assert.equal(normalized.daily.precipitation_sum[0], 24); const converted = convertWeatherSnapshot(snapshot, { temperature: 'fahrenheit', wind: 'mph', precipitation: 'inch' }); assert.equal(converted.current.temperature_2m, 68); assert.equal(converted.current.wind_speed_10m, 22.4); assert.equal(converted.current.precipitation, 0.1); assert.equal(converted.hourly.temperature_2m[1], null); assert.equal(converted.hourly.wind_speed_10m[1], null); assert.equal(converted.daily.temperature_2m_max[0], 77); assert.equal(converted.current_units.temperature_2m, '\u00b0F'); assert.deepEqual(convertWeatherSnapshot(converted, { temperature: 'fahrenheit', wind: 'mph', precipitation: 'inch' }), converted); assert.equal(snapshot.currently.temperature, 20); }); test('Open-Meteo normalization preserves fields without mutating provider data', () => { const snapshot = { current: { temperature_2m: 20 }, hourly: { temperature_2m: [21] }, daily: { temperature_2m_max: [25] }, current_units: { temperature_2m: '\u00b0C' } }; const normalized = normalizeOpenMeteoSnapshot(snapshot); assert.deepEqual(normalized, snapshot); normalized.current.temperature_2m = 99; assert.equal(snapshot.current.temperature_2m, 20); }); test('cached Pirate Weather native unit systems convert consistently with Open-Meteo', () => { const target = { temperature: 'celsius', wind: 'kmh', precipitation: 'mm' }; const expected = convertWeatherSnapshot({ current: { temperature_2m: 20, wind_speed_10m: 36, precipitation: 25.4 }, current_units: { temperature_2m: '\u00b0C', wind_speed_10m: 'km/h', precipitation: 'mm' } }, target).current; for (const native of [ { units: 'us', temperature: 68, windSpeed: 36 / 1.609344, precipIntensity: 1 }, { units: 'si', temperature: 20, windSpeed: 10, precipIntensity: 25.4 }, { units: 'ca', temperature: 20, windSpeed: 36, precipIntensity: 25.4 }, { units: 'uk', temperature: 20, windSpeed: 36 / 1.609344, precipIntensity: 25.4 } ]) { const snapshot = { flags: { units: native.units }, currently: native }; const converted = convertWeatherSnapshot(snapshot, target); for (const field of ['temperature_2m', 'wind_speed_10m', 'precipitation']) assert.equal(converted.current[field], expected[field], native.units + ': ' + field); assert.deepEqual(convertWeatherSnapshot(converted, target), converted); } }); test('Pirate Weather dates and daily rainfall respect timezone and daylight saving', () => { const start = Date.parse('2026-10-24T23:00:00Z') / 1000; const next = Date.parse('2026-10-26T00:00:00Z') / 1000; const normalized = normalizePirateWeatherSnapshot({ timezone: 'Europe/London', flags: { units: 'si' }, currently: { time: start, icon: 'clear-night' }, daily: { data: [{ time: start, precipIntensity: 1 }, { time: next }] } }); assert.equal(normalized.daily.precipitation_sum[0], 25); assert.equal(normalized.current.is_day, 0); const localDate = normalizePirateWeatherSnapshot({ timezone: 'America/Los_Angeles', daily: { data: [{ time: Date.parse('2026-10-23T00:00:00Z') / 1000 }] } }); assert.equal(localDate.daily.time[0], '2026-10-22'); }); test('Pirate Weather missing and unavailable values stay missing, with bounded forecasts', () => { const time = Date.parse('2026-10-23T12:00:00Z') / 1000; const snapshot = { flags: { units: 'si' }, currently: { time, temperature: -999, icon: 'wind', cloudCover: 0.9 }, hourly: { data: [null, ...Array.from({ length: 50 }, (_, index) => ({ time: time + (index - 1) * 3600, temperature: null, windSpeed: index }))] }, daily: { data: [null, ...Array.from({ length: 8 }, (_, index) => ({ time: time + index * 86400 }))] } }; const converted = convertWeatherSnapshot(snapshot, { temperature: 'fahrenheit' }); assert.equal(converted.current.temperature_2m, null); assert.equal(converted.current.weather_code, 3); assert.equal(converted.hourly.time.length, 24); assert.equal(converted.daily.time.length, 7); assert.equal(converted.hourly.time[0], '2026-10-23T12:00:00.000Z'); assert.ok(converted.hourly.temperature_2m.every(value => value === null)); assert.equal(converted.daily.wind_speed_10m_max[1], 129.6); assert.equal(converted.daily.wind_speed_10m_max[6], null); assert.equal(converted.daily.precipitation_sum[0], null); }); test('global weather unit placeholders resolve in editor and player without root unit fields', () => { for (const units of [ { temperature_unit: 'celsius', wind_unit: 'kmh', precipitation_unit: 'mm', expected: '\u00b0C|km/h|mm' }, { temperature_unit: 'fahrenheit', wind_unit: 'mph', precipitation_unit: 'inch', expected: '\u00b0F|mph|in' }, { temperature_unit: 'celsius', wind_unit: 'ms', precipitation_unit: 'mm', expected: '\u00b0C|m/s|mm' } ]) { const location = { id: 1, ...units, responseJson: { current: {}, hourly: {}, daily: {} } }; const region = { pixelWidth: 600, pixelHeight: 300, baseStyle: '' }; const content = { weather_location_id: 1, value: '{{temp_unit}}|{{wind_unit}}|{{precip_unit}}
{{current.temperature_unit}}|{{current.wind_speed_unit}}|{{current.precipitation_unit}}
' }; for (const target of ['editor', 'player']) { let renderer; const registry = { register(_type, module) { renderer = module; } }; const sandbox = { document: { addEventListener() {} }, window: { placeholderUtils, pulseRegionTypes: registry, pulsePlayerRegionTypes: registry }, initialData: { weatherLocations: [location] }, escapeHtml: value => String(value), renderEditorJsContent: value => value }; const script = target === 'editor' ? '../src/web/public/js/regions/type/weather.js' : '../src/player/regions/weather.js'; vm.runInNewContext(fs.readFileSync(require.resolve(script), 'utf8'), sandbox); const html = target === 'editor' ? renderer.renderPreview(region, content, { weatherLocations: [location] }) : renderer.renderRegion(region, content); assert.ok(html.includes('' + units.expected + '
' + units.expected + '
'), target + ': ' + units.expected); assert.doesNotMatch(html, /\{\{/); } } }); test('thumbnail global weather unit aliases resolve to populated current unit fields', () => { const source = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8'); const normalization = source.slice(source.indexOf('function normalizeWeatherExpression('), source.indexOf('function weatherIconForCode(')); const sandbox = {}; vm.runInNewContext(normalization, sandbox); const data = { current: { temperature_unit: '\u00b0F', wind_speed_unit: 'mph', precipitation_unit: 'in' } }; for (const [expression, expected] of [['temp_unit', '\u00b0F'], ['wind_unit', 'mph'], ['precip_unit', 'in']]) { const normalized = sandbox.normalizeWeatherExpression(expression); assert.equal(placeholderUtils.resolvePlaceholderExpression(data, normalized), expected); } }); test('converts cached weather snapshot values to selected units', () => { const snapshot = { current_units: { temperature_2m: '°C', apparent_temperature: '°C', wind_speed_10m: 'km/h', precipitation: 'mm' }, current: { temperature_2m: 20, apparent_temperature: 18, wind_speed_10m: 36, precipitation: 2.54 }, daily_units: { temperature_2m_max: '°C', temperature_2m_min: '°C', wind_speed_10m_max: 'km/h', precipitation_sum: 'mm' }, daily: { temperature_2m_max: [25], temperature_2m_min: [15], wind_speed_10m_max: [18], precipitation_sum: [25.4] } }; const converted = convertWeatherSnapshot(snapshot, { temperature: 'fahrenheit', wind: 'mph', precipitation: 'inch' }); assert.equal(converted.current.temperature_2m, 68); assert.equal(converted.current.apparent_temperature, 64.4); assert.equal(converted.current.wind_speed_10m, 22.4); assert.equal(converted.current.precipitation, 0.1); assert.equal(converted.daily.temperature_2m_max[0], 77); assert.equal(converted.daily.wind_speed_10m_max[0], 11.2); assert.equal(converted.daily.precipitation_sum[0], 1); assert.equal(snapshot.current.temperature_2m, 20); });