Release v2.17.2: normalize weather providers and fix unit placeholders
This commit is contained in:
1 parent
6ce70ad7cb
commit
5e23d16483
12 files changed
+296
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+106
-3
@@ -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 };
|
||||
module.exports = { convertWeatherSnapshot, normalizeWeatherSnapshot, normalizeOpenMeteoSnapshot, normalizePirateWeatherSnapshot };
|
||||
+5
-1
@@ -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() };
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/^([^.()]+)((?:\(.*\))|(?:\..*))?$/);
|
||||
|
||||
@@ -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] || '');
|
||||
|
||||
@@ -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] || '');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
+136
-1
@@ -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: '<p>{{temp_unit}}|{{wind_unit}}|{{precip_unit}}</p><p>{{current.temperature_unit}}|{{current.wind_speed_unit}}|{{current.precipitation_unit}}</p>' };
|
||||
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('<p>' + units.expected + '</p><p>' + units.expected + '</p>'), 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 = {
|
||||
|
||||
Reference in new issue
Block a user