const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); const sharp = require('sharp'); require('../src/common'); const library = require('../src/web/lib/media/weather-icon-library'); const weatherIcons = require('../src/web/public/js/shared/weather-icons'); const vm = require('node:vm'); const placeholderContext = { window: {} }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), placeholderContext); const placeholders = placeholderContext.window.placeholderUtils; test('weather icon picker warns about each missing condition', function () { const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/weather-icons.js'), 'utf8'); assert.match(source, /window\.showToast\('Add images for: ' \+ labels\.join\(', '\) \+ '\.', 'warning'\)/); assert.match(source, /replace\(\/\^Upload\\s\+\|\\s\+icon\$\/g, ''\)/); }); test('weather editor and player render day or night variants from one selected set', function () { const sets = [{ id: 'abc', name: 'Colour', icons: { clear: '/media/weather-icons/abc/clear-123abc.png' }, nightIcons: { clear: '/media/weather-icons/abc/night-clear-456def.png' } }]; const location = { id: 1, responseJson: { current: { weather_code: 0, is_day: 1 } } }; let editor; const editorContext = { window: { pulseWeatherIconSets: sets, pulseWeatherIcons: weatherIcons, placeholderUtils: placeholders, pulseRegionTypes: { register: function (_type, module) { editor = module; } } }, document: { addEventListener: function () {} } }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/weather.js'), 'utf8'), editorContext); const region = { id: 1, region_key: 'weather', label: 'Forecast', baseStyle: '' }; const content = { weather_location_id: 1, weather_icon_set_id: 'abc', value: '{{current.icon(48,24)}}' }; const card = editor.renderEditorCard({ region: region, current: content, weatherLocations: [location] }); assert.match(card, /name="region_weather_icon_set_id_1"/); assert.match(card, /value="abc" selected/); assert.match(card, /row g-3 align-items-end">
Colour<\/option>/); assert.doesNotMatch(disabledCard, /value="abc" selected disabled/); sets[0].enabled = true; const preview = editor.renderPreview(region, content, { weatherLocations: [location] }); assert.match(preview, /clear-123abc.png/); assert.match(preview, /width:48px;height:24px/); const topPreview = editor.renderPreview(region, Object.assign({}, content, { value: '{{current.icon(48,96,top)}}' }), { weatherLocations: [location] }); assert.match(topPreview, /vertical-align:top/); let player; const playerContext = { window: { pulseWeatherIcons: weatherIcons, placeholderUtils: placeholders, pulsePlayerRegionTypes: { register: function (_type, module) { player = module; } } }, initialData: { weatherLocations: [location], weatherIconSets: sets }, escapeHtml: String, renderEditorJsContent: String }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/weather.js'), 'utf8'), playerContext); assert.match(player.renderRegion(region, content), /clear-123abc.png/); assert.match(player.renderRegion(region, Object.assign({}, content, { value: '{{current.icon(48,96,bottom)}}' })), /vertical-align:bottom/); location.responseJson.current.is_day = 0; assert.match(player.renderRegion(region, content), /night-clear-456def.png/); location.responseJson.current.is_day = 1; assert.match(player.renderRegion(region, Object.assign({}, content, { weather_icon_set_id: '' })), /bi-sun/); location.responseJson.current.weather_code = 61; assert.match(player.renderRegion(region, content), /bi-cloud-rain/); }); test('weather icons map exact WMO codes and fall back for missing or unsafe images', function () { assert.equal(weatherIcons.conditionForCode(3), 'overcast'); assert.equal(weatherIcons.conditionForCode(85), 'snow'); assert.equal(weatherIcons.conditionForCode(null), ''); assert.equal(weatherIcons.conditionForCode(4), ''); const sets = [{ id: 'abc', icons: { clear: '/media/weather-icons/abc/clear-123abc.png', rain: 'javascript:alert(1)' } }]; assert.equal(weatherIcons.imageForCode(sets, 'abc', 0), sets[0].icons.clear); assert.equal(weatherIcons.imageForCode(sets, 'abc', 61), ''); assert.equal(weatherIcons.imageForCode(sets, 'abc', 45), ''); sets[0].enabled = false; assert.equal(weatherIcons.imageForCode(sets, 'abc', 0), sets[0].icons.clear); assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 24), /width:48px;height:24px/); assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 24), /display:inline-flex;vertical-align:middle;align-items:center;justify-content:center/); assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 24), /vertical-align:middle.*line-height:0/); assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 96, 'bottom'), /vertical-align:bottom/); assert.match(weatherIcons.renderImage(sets[0].icons.clear, 48, 24), /object-fit:contain/); }); test('weather management template renders Assets and permission gated uploads', function () { const html = require('../src/web/view').renderView('settings/weather-icons/list', { title: 'Weather icons', active: 'weather-icons', assetsMenuOpen: true, conditions: library.CONDITIONS, sets: [], currentUser: { permissionKeys: ['weather-icons.read', 'weather-icons.create'] } }); assert.match(html, /Weather icons/); assert.match(html, /

Assets/); assert.match(html, /nav-icon bi bi-folder2-open/); assert.ok(html.indexOf('User management') < html.indexOf('Assets')); assert.match(html, /action="\/settings\/weather-icons"/); assert.match(html, /name="icon_clear"/); assert.match(html, /class="page-header"/); assert.match(html, /Upload and manage shared icon sets for weather regions/); assert.match(html, /class="card card-outline card-primary mb-4 collapsed-card"/); assert.match(html, /class="card-title mb-0">Upload icon set/); assert.doesNotMatch(html, /Add a PNG or WebP image for every weather condition/); assert.match(html, /data-lte-toggle="card-collapse"/); assert.match(html, /data-lte-icon="expand"/); assert.match(html, /data-lte-icon="collapse"/); assert.match(html, /action="\/settings\/weather-icons" enctype="multipart\/form-data" class="card-body p-0"/); assert.match(html, /class="card-title">Managed icon sets/); assert.match(html, /data-table-sort-key="status">Status/); assert.equal((html.match(/class="app-content"/g) || []).length, 1); assert.doesNotMatch(html, /Day icons/g) || []).length, 1); assert.equal((html.match(/Night icons Optional/g) || []).length, 1); assert.match(html, /data-weather-icon-preview/); assert.match(html, /data-weather-icon-status>Required/); assert.match(html, /hidden aria-label="Clear icon" data-weather-icon-file/); assert.match(html, /accept="image\/png,image\/webp,image\/svg\+xml"/); assert.match(html, /data-weather-icon-set-form/); }); test('weather management route paginates, sorts and preserves search state', async function () { const originalRead = library.readWeatherIconSets; const handlers = {}; library.readWeatherIconSets = function () { return Array.from({ length: 30 }, function (_unused, index) { return { id: 'set-' + index, name: 'Set ' + String(index).padStart(2, '0'), icons: {} }; }); }; try { require('../src/web/routes/settings/weather-icons')({ get: function (url, ...callbacks) { handlers[url] = callbacks; }, post: function () {} }, { mediaDir: 'media', pool: { query: async function () { return [[]]; } }, requirePermission: function () { return function () {}; } }); const response = { status: function () { return this; }, send: function (html) { this.html = html; } }; const currentUser = { permissionKeys: ['weather-icons.read', 'weather-icons.update'] }; const fail = function (error) { throw error; }; await handlers['/settings/weather-icons'][1]({ query: { page: '2', sort: 'name', direction: 'asc' }, currentUser: currentUser }, response, fail); assert.equal((response.html.match(//g) || []).length, 5); assert.match(response.html, /Set 25/); assert.doesNotMatch(response.html, /Set 00/); assert.match(response.html, /data-bs-toggle="modal"/); assert.match(response.html, /href="\/assets\/css\/weather-icons.css"/); await handlers['/settings/weather-icons'][1]({ query: { search: 'Set 29', page: '2' }, currentUser: currentUser }, response, fail); assert.equal((response.html.match(//g) || []).length, 1); assert.match(response.html, /Set 29/); await handlers['/settings/weather-icons'][1]({ query: { search: 'Set', page: '2' }, currentUser: currentUser }, response, fail); assert.match(response.html, /search(?:=|=)Set/); } finally { library.readWeatherIconSets = originalRead; } }); test('weather sets require every condition image and support replacement', async function () { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'weather-icons-')); try { const buffer = await sharp({ create: { width: 16, height: 16, channels: 4, background: '#ff0000' } }).png().toBuffer(); const allFiles = library.CONDITIONS.map(function (condition) { return { fieldname: 'icon_' + condition.key, buffer: buffer }; }); const svgBuffer = Buffer.from(''); await assert.rejects(library.saveWeatherIconSet(root, 'Partial', allFiles.slice(0, 2)), /every weather condition.*Overcast/); const first = await library.saveWeatherIconSet(root, 'Colour', allFiles); const second = await library.saveWeatherIconSet(root, 'Outline', allFiles); assert.notEqual(first.id, second.id); assert.equal(Object.keys(first.icons).length, library.CONDITIONS.length); assert.match(first.icons.clear, /^\/media\/assets\/weather-icons\//); const disabled = library.setWeatherIconSetEnabled(root, first.id, false); assert.equal(disabled.enabled, false); assert.equal(library.readWeatherIconSets(root).find(function (set) { return set.id === first.id; }).enabled, false); assert.ok(library.collectWeatherIconSyncOperations(root).some(function (operation) { return operation.type === 'put' && operation.uploadPath === first.icons.clear; })); assert.equal(fs.existsSync(path.join(root, 'assets', 'weather-icons', first.id, path.basename(first.icons.clear))), true); library.setWeatherIconSetEnabled(root, first.id, true); assert.equal(library.getWeatherManifestPath(root), path.join(root, 'assets', 'weather.json')); const svgSet = await library.saveWeatherIconSet(root, 'Vector', library.CONDITIONS.map(function (condition) { return { fieldname: 'icon_' + condition.key, buffer: svgBuffer }; })); const svgFileName = path.basename(svgSet.icons.clear); assert.match(svgFileName, /\.svg$/); assert.match(fs.readFileSync(path.join(root, 'assets', 'weather-icons', svgSet.id, svgFileName), 'utf8'), /') }; })), /cannot contain scripts/); assert.equal(library.readWeatherIconSets(root).length, 2); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); test('weather icon usage recognizes one set reference', function () { const content = { weather: { weather_icon_set_id: 'set' } }; const isUsed = function (setId) { return Object.values(content).some(function (region) { return region && region.weather_icon_set_id === setId; }); }; assert.equal(isUsed('set'), true); assert.equal(isUsed('other'), false); }); test('weather icon set create, update, toggle, and delete are audited', async function () { const originals = { readWeatherIconSets: library.readWeatherIconSets, saveWeatherIconSet: library.saveWeatherIconSet, collectWeatherIconRemovalOperations: library.collectWeatherIconRemovalOperations, setWeatherIconSetEnabled: library.setWeatherIconSetEnabled, deleteWeatherIconSet: library.deleteWeatherIconSet }; const handlers = {}; const events = []; let sets = []; const app = { get(route, ...callbacks) { handlers['GET ' + route] = callbacks; }, post(route, ...callbacks) { handlers['POST ' + route] = callbacks; } }; const iconSet = { id: 'clear-set', name: 'Clear skies', icons: { clear: '/media/assets/weather-icons/clear-set/clear.png' }, nightIcons: {} }; library.readWeatherIconSets = function () { return sets.slice(); }; library.saveWeatherIconSet = async function (_root, name, _files, id) { const saved = Object.assign({}, iconSet, { id: id || iconSet.id, name: name }); sets = id ? sets.map(function (set) { return set.id === id ? saved : set; }) : sets.concat(saved); return saved; }; library.collectWeatherIconRemovalOperations = function () { return []; }; library.setWeatherIconSetEnabled = function (_root, id, enabled) { const updated = Object.assign({}, sets.find(function (set) { return set.id === id; }), { enabled: enabled }); sets = sets.map(function (set) { return set.id === id ? updated : set; }); return updated; }; library.deleteWeatherIconSet = function (_root, id) { const removed = sets.find(function (set) { return set.id === id; }); sets = sets.filter(function (set) { return set.id !== id; }); return removed; }; try { require('../src/web/routes/settings/weather-icons') (app, { mediaDir: 'unused', pool: { async query() { return [[]]; } }, requirePermission() { return function (_req, _res, next) { next(); }; }, recordRequestAuditEvent(_pool, _req, event) { events.push(event); } }); const response = { redirect() { return this; }, status() { return this; }, send() { return this; } }; const next = function (error) { if (error) throw error; }; const currentUser = { id: 21 }; const createRequest = { currentUser: currentUser, body: { name: 'Clear skies' }, files: {}, params: {} }; await handlers['POST /settings/weather-icons'].at(-1)(createRequest, response, next); await handlers['POST /settings/weather-icons/:id'].at(-1)( { currentUser: currentUser, body: { name: 'Updated skies' }, files: {}, params: { id: iconSet.id } }, response, next ); await handlers['POST /settings/weather-icons/:id/toggle'].at(-1)( { currentUser: currentUser, body: { enabled: '0' }, params: { id: iconSet.id } }, response, next ); await handlers['POST /settings/weather-icons/:id/delete'].at(-1)( { currentUser: currentUser, params: { id: iconSet.id } }, response, next ); assert.deepEqual(events.map(function (event) { return event.eventType; }), [ 'weather-icon-set.created', 'weather-icon-set.updated', 'weather-icon-set.disabled', 'weather-icon-set.deleted' ]); assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 21; })); } finally { library.readWeatherIconSets = originals.readWeatherIconSets; library.saveWeatherIconSet = originals.saveWeatherIconSet; library.collectWeatherIconRemovalOperations = originals.collectWeatherIconRemovalOperations; library.setWeatherIconSetEnabled = originals.setWeatherIconSetEnabled; library.deleteWeatherIconSet = originals.deleteWeatherIconSet; } });