diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb2ea6..ae0e402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## 2.17.2 - 2026-10-23 + +### Added + +- Added provider-specific weather normalization for Open-Meteo and Pirate Weather, giving current, hourly, and daily forecasts a shared format across previews, playback, and thumbnails, including cached responses, condition icons, timezone-aware dates, and independently selected display units. + +### Fixed + +- Global weather unit placeholders (`{{temp_unit}}`, `{{wind_unit}}`, and `{{precip_unit}}`) display the selected location's units consistently in editor previews, playback, and thumbnails. +- Weather unit conversion preserves missing readings and updates unit metadata so repeated conversion does not convert values twice. + ## 2.17.1 - 2026-10-23 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index 75949d7..51e35b6 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.17.1", + "version": "2.17.2", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 42960b8..41d896f 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.17.1", + "version": "2.17.2", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 8c4dfcd..6388299 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.17.1", + "version": "2.17.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.17.1", + "version": "2.17.2", "dependencies": { "animate.css": "^4.1.1", "bootstrap-icons": "1.13.1", diff --git a/package.json b/package.json index 826ac8f..e76c661 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.17.1", + "version": "2.17.2", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/weather-units.js b/src/data/weather-units.js index da682d1..7bc6e0d 100644 --- a/src/data/weather-units.js +++ b/src/data/weather-units.js @@ -1,5 +1,99 @@ // Convert cached weather snapshots for display without refetching. +function normalizeOpenMeteoSnapshot(snapshot) { + const result = JSON.parse(JSON.stringify(snapshot || {})); + for (const block of ['current', 'hourly', 'daily']) { + if (!result[block] || typeof result[block] !== 'object') result[block] = {}; + } + return result; +} + +function normalizePirateWeatherSnapshot(snapshot, options) { + const source = snapshot || {}; + const units = source.flags && source.flags.units || options && options.units || 'us'; + const timezone = source.timezone || options && options.timezone || 'UTC'; + const temperatureUnit = units === 'us' ? '\u00b0F' : '\u00b0C'; + const windUnit = units === 'us' || units === 'uk' || units === 'uk2' ? 'mph' : units === 'ca' ? 'km/h' : 'm/s'; + const precipitationUnit = units === 'us' ? 'in' : 'mm'; + const number = value => value === null || value === undefined || value === '' || !Number.isFinite(Number(value)) || Number(value) === -999 ? null : Number(value); + const percent = value => number(value) === null ? null : Math.round(Number(value) * 100); + const timestamp = value => { + const seconds = number(value); + const date = seconds === null ? null : new Date(seconds * 1000); + return date && Number.isFinite(date.getTime()) ? date.toISOString() : null; + }; + const dateFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }); + const dateOnly = value => { + const iso = timestamp(value); + if (!iso) return null; + const parts = Object.fromEntries(dateFormatter.formatToParts(new Date(iso)).map(part => [part.type, part.value])); + return parts.year + '-' + parts.month + '-' + parts.day; + }; + const codes = { clear: 0, 'clear-day': 0, 'clear-night': 0, 'partly-cloudy-day': 2, 'partly-cloudy-night': 2, cloudy: 3, fog: 45, rain: 61, sleet: 67, snow: 71, thunderstorm: 95, hail: 96 }; + const code = entry => { + if (codes[entry.icon] !== undefined) return codes[entry.icon]; + const clouds = number(entry.cloudCover); + return entry.icon === 'wind' && clouds !== null ? (clouds > 0.875 ? 3 : clouds > 0.375 ? 2 : 0) : null; + }; + const current = source.currently || {}; + const allDailyRows = (Array.isArray(source.daily && source.daily.data) ? source.daily.data : []).filter(entry => entry && typeof entry === 'object'); + const dailyRows = allDailyRows.slice(0, 7); + const currentHour = number(current.time) === null ? null : Math.floor(Number(current.time) / 3600) * 3600; + const allHourlyRows = (Array.isArray(source.hourly && source.hourly.data) ? source.hourly.data : []).filter(entry => entry && typeof entry === 'object'); + const hourlyRows = allHourlyRows.filter(entry => currentHour === null || number(entry.time) !== null && Number(entry.time) >= currentHour).slice(0, 24); + const day = dailyRows.find(entry => dateOnly(entry.time) === dateOnly(current.time)); + const sunrise = day && number(day.sunriseTime); + const sunset = day && number(day.sunsetTime); + const isDay = /-night$/.test(current.icon || '') ? 0 : /-day$/.test(current.icon || '') ? 1 + : sunrise !== null && sunrise !== undefined && sunset !== null && sunset !== undefined && number(current.time) !== null + ? Number(Number(current.time) >= sunrise && Number(current.time) < sunset) : null; + const columns = (rows, fields) => Object.fromEntries(Object.entries(fields).map(([field, read]) => [field, rows.map(read)])); + const sharedUnits = { time: 'iso8601', weather_code: 'wmo code', temperature_2m: temperatureUnit, wind_speed_10m: windUnit, precipitation: precipitationUnit, uv_index: '', cloud_cover: '%' }; + return { + latitude: source.latitude, + longitude: source.longitude, + timezone, + current_units: { ...sharedUnits, apparent_temperature: temperatureUnit, relative_humidity_2m: '%', wind_direction_10m: '\u00b0', is_day: '' }, + current: { + time: timestamp(current.time), temperature_2m: number(current.temperature), apparent_temperature: number(current.apparentTemperature), + relative_humidity_2m: percent(current.humidity), wind_speed_10m: number(current.windSpeed), wind_direction_10m: number(current.windBearing), + precipitation: number(current.precipIntensity), weather_code: code(current), uv_index: number(current.uvIndex), cloud_cover: percent(current.cloudCover), is_day: isDay + }, + hourly_units: { ...sharedUnits, precipitation_probability: '%' }, + hourly: columns(hourlyRows, { + time: entry => timestamp(entry.time), temperature_2m: entry => number(entry.temperature), precipitation: entry => number(entry.precipIntensity), + precipitation_probability: entry => percent(entry.precipProbability), weather_code: code, wind_speed_10m: entry => number(entry.windSpeed), + uv_index: entry => number(entry.uvIndex), cloud_cover: entry => percent(entry.cloudCover) + }), + daily_units: { time: 'iso8601', weather_code: 'wmo code', temperature_2m_max: temperatureUnit, temperature_2m_min: temperatureUnit, sunrise: 'iso8601', sunset: 'iso8601', precipitation_sum: precipitationUnit, precipitation_probability_max: '%', wind_speed_10m_max: windUnit, uv_index_max: '', cloud_cover_mean: '%' }, + daily: columns(dailyRows, { + time: entry => dateOnly(entry.time), weather_code: code, + temperature_2m_max: entry => number(entry.temperatureMax ?? entry.temperatureHigh), temperature_2m_min: entry => number(entry.temperatureMin ?? entry.temperatureLow), + sunrise: entry => timestamp(entry.sunriseTime), sunset: entry => timestamp(entry.sunsetTime), + precipitation_sum: (entry, index) => { + const intensity = number(entry.precipIntensity); + const next = allDailyRows[index + 1]; + const hours = next && number(next.time) !== null && number(entry.time) !== null ? (Number(next.time) - Number(entry.time)) / 3600 : 24; + return intensity === null ? null : Math.round(intensity * hours * 10000) / 10000; + }, + precipitation_probability_max: entry => percent(entry.precipProbability), + wind_speed_10m_max: entry => { + const winds = allHourlyRows.filter(hour => dateOnly(hour.time) === dateOnly(entry.time)).map(hour => number(hour.windSpeed)).filter(value => value !== null); + return winds.length ? Math.max(...winds) : null; + }, + uv_index_max: entry => number(entry.uvIndex), cloud_cover_mean: entry => percent(entry.cloudCover) + }) + }; +} + +function normalizeWeatherSnapshot(snapshot, provider, options) { + const source = snapshot && typeof snapshot === 'object' ? snapshot : {}; + const pirateShape = source.currently || Array.isArray(source.hourly && source.hourly.data) || Array.isArray(source.daily && source.daily.data); + return pirateShape || provider === 'pirate-weather' && !source.current + ? normalizePirateWeatherSnapshot(source, options) + : normalizeOpenMeteoSnapshot(source); +} + function convertTemperature(value, fromUnit, toUnit) { const number = Number(value); if (!Number.isFinite(number) || fromUnit === toUnit) return value; @@ -39,13 +133,13 @@ function convertField(data, fields, converter, fromUnit, toUnit) { fields.forEach(function (field) { if (data[field] === undefined || data[field] === null) return; data[field] = Array.isArray(data[field]) - ? data[field].map(function (value) { return converter(value, fromUnit, toUnit); }) + ? data[field].map(function (value) { return value === null || value === undefined ? value : converter(value, fromUnit, toUnit); }) : converter(data[field], fromUnit, toUnit); }); } function convertWeatherSnapshot(snapshot, targetUnits) { - const source = snapshot && typeof snapshot === 'object' ? snapshot : {}; + const source = normalizeWeatherSnapshot(snapshot); const target = Object.assign({ temperature: 'celsius', wind: 'kmh', precipitation: 'mm' }, targetUnits || {}); const result = JSON.parse(JSON.stringify(source)); const currentUnits = source.current_units || {}; @@ -61,7 +155,16 @@ function convertWeatherSnapshot(snapshot, targetUnits) { convertField(result.daily || {}, ['temperature_2m_max', 'temperature_2m_min'], convertTemperature, temperatureUnitFromLabel(dailyUnits.temperature_2m_max), target.temperature); convertField(result.daily || {}, ['wind_speed_10m_max'], convertWind, windUnitFromLabel(dailyUnits.wind_speed_10m_max), target.wind); convertField(result.daily || {}, ['precipitation_sum'], convertPrecipitation, precipitationUnitFromLabel(dailyUnits.precipitation_sum), target.precipitation); + for (const block of ['current', 'hourly', 'daily']) { + const metadata = result[block + '_units'] || {}; + for (const field of Object.keys(result[block])) { + if (['temperature_2m', 'apparent_temperature', 'temperature_2m_max', 'temperature_2m_min'].includes(field)) metadata[field] = target.temperature === 'fahrenheit' ? '\u00b0F' : '\u00b0C'; + if (['wind_speed_10m', 'wind_speed_10m_max'].includes(field)) metadata[field] = target.wind === 'mph' ? 'mph' : target.wind === 'ms' ? 'm/s' : 'km/h'; + if (['precipitation', 'rain', 'precipitation_sum'].includes(field)) metadata[field] = target.precipitation === 'inch' ? 'in' : 'mm'; + } + result[block + '_units'] = metadata; + } return result; } -module.exports = { convertWeatherSnapshot }; \ No newline at end of file +module.exports = { convertWeatherSnapshot, normalizeWeatherSnapshot, normalizeOpenMeteoSnapshot, normalizePirateWeatherSnapshot }; \ No newline at end of file diff --git a/src/data/weather.js b/src/data/weather.js index a28ea85..38497e9 100644 --- a/src/data/weather.js +++ b/src/data/weather.js @@ -2,6 +2,7 @@ const { fetchPagedRows, validateMaxLength } = require('#src/data/utils'); const { fetchAppSettings } = require('#src/data/app-settings'); +const { normalizeWeatherSnapshot } = require('#src/data/weather-units'); const NAME_MAX_LENGTH = 255; const LOCATION_MAX_LENGTH = 255; @@ -97,7 +98,10 @@ async function fetchWeatherLocationForecast(pool, location) { const response = await fetch(url, { headers: headers }); if (!response.ok) throw new Error('Weather provider returned HTTP ' + response.status + '.'); - const snapshot = await response.json(); + const snapshot = normalizeWeatherSnapshot(await response.json(), source.provider, { + units: temperatureUnit === 'fahrenheit' ? 'us' : 'si', + timezone: source.timezone + }); return { snapshot: snapshot, responseJson: JSON.stringify(snapshot), fetchedAt: new Date() }; } diff --git a/src/player/regions/weather.js b/src/player/regions/weather.js index 746e9e7..2e5012a 100644 --- a/src/player/regions/weather.js +++ b/src/player/regions/weather.js @@ -21,7 +21,7 @@ function getWeatherLocation(locationId) { function normalizeWeatherExpression(expression) { var value = String(expression || '').trim(); var currentAliases = { temp: 'current.temperature_2m', temp_unit: 'current.temperature_unit', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', code: 'current.weather_code', wind: 'current.wind_speed_10m', wind_unit: 'current.wind_speed_unit', precip: 'current.precipitation', precip_unit: 'current.precipitation_unit', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon' }; - var globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' }; + var globalAliases = { temp_unit: 'current.temperature_unit', wind_unit: 'current.wind_speed_unit', precip_unit: 'current.precipitation_unit' }; if (globalAliases[value]) return globalAliases[value]; var currentField = value.replace(/^current\./, ''); var currentMatch = currentField.match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/); diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index ceebea2..b33efe8 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -169,7 +169,7 @@ function getImagePlaceholderConfig(expression) { function normalizeWeatherExpression(expression) { const value = String(expression || '').trim(); const currentAliases = { temp: 'current.temperature_2m', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', wind: 'current.wind_speed_10m', precip: 'current.precipitation', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon' }; - const globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' }; + const globalAliases = { temp_unit: 'current.temperature_unit', wind_unit: 'current.wind_speed_unit', precip_unit: 'current.precipitation_unit' }; if (globalAliases[value]) return globalAliases[value]; const currentField = value.replace(/^current\./, '').match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/); if (currentField && currentAliases[currentField[1]]) return currentAliases[currentField[1]] + (currentField[2] || ''); diff --git a/src/web/public/js/regions/type/weather.js b/src/web/public/js/regions/type/weather.js index be73143..6565f8d 100644 --- a/src/web/public/js/regions/type/weather.js +++ b/src/web/public/js/regions/type/weather.js @@ -83,7 +83,7 @@ var currentAliases = { temp: 'current.temperature_2m', temp_unit: 'current.temperature_unit', feels_like: 'current.apparent_temperature', humidity: 'current.relative_humidity_2m', code: 'current.weather_code', wind: 'current.wind_speed_10m', wind_unit: 'current.wind_speed_unit', precip: 'current.precipitation', precip_unit: 'current.precipitation_unit', uv_index: 'current.uv_index', cloud_cover: 'current.cloud_cover', icon: 'current.weather_code.icon' }; - var globalAliases = { temp_unit: 'temperature_unit', wind_unit: 'wind_speed_unit', precip_unit: 'precipitation_unit' }; + var globalAliases = { temp_unit: 'current.temperature_unit', wind_unit: 'current.wind_speed_unit', precip_unit: 'current.precipitation_unit' }; if (globalAliases[value]) return globalAliases[value]; var currentField = value.replace(/^current\./, '').match(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/); if (currentField && currentAliases[currentField[1]]) return currentAliases[currentField[1]] + (currentField[2] || ''); diff --git a/test/weather-forecast.test.js b/test/weather-forecast.test.js index a732b7d..9fea43b 100644 --- a/test/weather-forecast.test.js +++ b/test/weather-forecast.test.js @@ -39,4 +39,34 @@ test('weather forecast preview only shows the fetch hint without a snapshot', () assert.match(template, /id="weather-hourly-forecast" class="d-none"/); assert.doesNotMatch(template, /Hourly forecast ยท next 24 hours/); assert.match(template, /Daily and hourly forecasts will appear here after the first successful fetch\./); +}); + +test('Pirate Weather fetch persists normalized fields and source units for mixed-unit display', async () => { + const { convertWeatherSnapshot } = require('../src/data/weather-units'); + const originalFetch = global.fetch; + let requestUrl; + global.fetch = async function (url) { + requestUrl = new URL(url); + return { ok: true, json: async function () { + return { timezone: 'Europe/London', currently: { time: 1792756800, temperature: 68, windSpeed: 10, precipIntensity: 1, icon: 'clear-day' } }; + } }; + }; + try { + const pool = { query: async function () { return [[{ setting_key: 'weather.pirate_weather_api_key', setting_value: JSON.stringify('test-key') }]]; } }; + const result = await fetchWeatherLocationForecast(pool, { + latitude: 51.5, longitude: -0.1, timezone: 'Europe/London', provider: 'pirate-weather', + temperature_unit: 'fahrenheit', wind_unit: 'kmh', precipitation_unit: 'mm' + }); + assert.equal(requestUrl.searchParams.get('units'), 'us'); + assert.equal(result.snapshot.current.temperature_2m, 68); + assert.equal(result.snapshot.current_units.wind_speed_10m, 'mph'); + assert.equal(result.snapshot.current_units.precipitation, 'in'); + assert.deepEqual(JSON.parse(result.responseJson), JSON.parse(JSON.stringify(result.snapshot))); + const display = convertWeatherSnapshot(JSON.parse(result.responseJson), { temperature: 'fahrenheit', wind: 'kmh', precipitation: 'mm' }); + assert.equal(display.current.temperature_2m, 68); + assert.equal(display.current.wind_speed_10m, 16.1); + assert.equal(display.current.precipitation, 25.4); + } finally { + global.fetch = originalFetch; + } }); \ No newline at end of file diff --git a/test/weather-units.test.js b/test/weather-units.test.js index 2ea9495..f5d2bf0 100644 --- a/test/weather-units.test.js +++ b/test/weather-units.test.js @@ -1,7 +1,142 @@ const test = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); -const { convertWeatherSnapshot } = require('../src/data/weather-units'); +const { convertWeatherSnapshot, normalizeOpenMeteoSnapshot, normalizePirateWeatherSnapshot } = require('../src/data/weather-units'); +const placeholderUtils = require('../src/web/public/js/shared/placeholder-utils'); + +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 = {