const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); function loadScheduleModule(overrides) { const timeDatePlaceholdersScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/time-date-placeholders.js'), 'utf8'); const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'); const scriptPath = require.resolve('../src/web/public/js/regions/type/schedule.js'); const script = fs.readFileSync(scriptPath, 'utf8'); const registry = new Map(); const customWindow = overrides && overrides.window ? overrides.window : {}; const sandbox = { document: { addEventListener() {} }, window: { pulseRegionTypes: { register(type, module) { registry.set(type, module); } }, pulseRegionUtils: { escapeHtml(value) { return String(value === undefined || value === null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, sanitizeRichText(value) { return String(value === undefined || value === null ? '' : value); } }, placeholderChips: { renderChips(tokens) { return tokens.map((token) => '{{' + token + '}}').join(' '); } }, initialData: { timetableGroups: [ { id: 1, name: 'Main board', timezone: 'UTC', entries: [ { id: 101, title: 'Launch', short_description: 'Doors open', start_datetime: '2026-08-09T10:00:00.000Z', end_datetime: '2026-08-09T11:00:00.000Z' } ] } ] }, Intl: Intl, Date: Date, Object: Object, Array: Array, Number: Number, String: String, Boolean: Boolean, Math: Math, JSON: JSON, RegExp: RegExp, console: console }, ...customWindow }; sandbox.window = Object.assign({}, sandbox.window, customWindow); vm.runInNewContext(timeDatePlaceholdersScript, sandbox, { filename: 'time-date-placeholders.js' }); vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' }); if (customWindow.placeholderUtils) { sandbox.window.placeholderUtils = customWindow.placeholderUtils; } vm.runInNewContext(script, sandbox, { filename: scriptPath }); return registry.get('timetable'); } test('timetable region editor lists timezone transforms in helper text', () => { const module = loadScheduleModule(); const html = module.renderEditorCard({ region: { id: 47, label: 'Timetable' }, current: { value: '

{{title}} {{start.tz()}} {{start.tz_long()}}

', timetable_group_id: 1, display_mode: 'current', max_items: 3 }, timetableGroups: [ { id: 1, name: 'Main board', timezone: 'UTC', entries: [] } ] }); assert.match(html, /Placeholder values support transforms/); assert.match(html, /\{\{start\.tz\(\)\}\}<\/code>/); assert.match(html, /\{\{start\.tz_long\(\)\}\}<\/code>/); assert.doesNotMatch(html, /class="chip">\{\{start\.tz\(\)\}\}<\/span>/); }); test('timetable region editor shows supported date format tokens', () => { const module = loadScheduleModule(); const html = module.renderEditorCard({ region: { id: 47, label: 'Timetable' }, current: { value: '

{{start.format("MMM D, YYYY h:mm A")}}

', timetable_group_id: 1, display_mode: 'current', max_items: 3 }, timetableGroups: [ { id: 1, name: 'Main board', timezone: 'UTC', entries: [] } ] }); assert.match(html, /Supported date format tokens/); assert.match(html, /Format<\/th>/); assert.ok(html.indexOf('D1-31The day of the month') < html.indexOf('dddMonThe abbreviated weekday name')); assert.ok(html.indexOf('dddMonThe abbreviated weekday name') < html.indexOf('M1-12The month, beginning at 1')); assert.ok(html.indexOf('YY18The two-digit year') < html.indexOf('YYYY2018The four-digit year')); assert.doesNotMatch(html, /y<\/code><\/td>/); assert.doesNotMatch(html, /yyyy<\/code><\/td>/); assert.doesNotMatch(html, /yy<\/code><\/td>/); assert.doesNotMatch(html, /tz<\/code><\/td>/); assert.match(html, /start\.tz_long\(\)/); assert.match(html, /Use these tokens inside \.format\(\.\.\.\)<\/code>/); }); test('timetable region preview resolves timezone placeholders from explicit timezone transforms', () => { const module = loadScheduleModule(); const preview = module.renderPreview( { id: 47, width: 500, height: 280 }, { value: '

{{start.tz("UTC")}} {{start.tz_long("UTC")}}

', timetable_group_id: 1, display_mode: 'both', max_items: 3 }, { timetableGroups: [ { id: 1, name: 'Main board', timezone: 'UTC', entries: [ { id: 101, title: 'Launch', short_description: 'Doors open', start_datetime: '2026-08-10T10:00:00.000Z', end_datetime: '2026-08-10T11:00:00.000Z' } ] } ] } ); assert.match(preview, /UTC/); }); test('timetable placeholder format transform respects uppercase and lowercase day period tokens', () => { const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'); const sandbox = { window: {}, Intl: Intl, Date: Date, Object: Object, Array: Array, Number: Number, String: String, Boolean: Boolean, Math: Math, JSON: JSON, RegExp: RegExp, console: console }; vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' }); const upper = sandbox.window.placeholderUtils.resolvePlaceholderExpression( { start: '2026-08-09T13:05:00.000Z' }, 'start.format("MMM D, YYYY h:mm A")' ); const lower = sandbox.window.placeholderUtils.resolvePlaceholderExpression( { start: '2026-08-09T13:05:00.000Z' }, 'start.format("MMM D, YYYY h:mm a")' ); assert.match(String(upper), /PM$/); assert.match(String(lower), /pm$/); }); test('timetable timezone abbreviation changes across daylight saving boundaries', () => { const module = loadScheduleModule(); const preview = module.renderPreview( { id: 47, width: 500, height: 280 }, { value: '

{{start.tz("Europe/London")}}

', timetable_group_id: 1, display_mode: 'both', max_items: 10 }, { timetableGroups: [ { id: 1, name: 'London board', timezone: 'Europe/London', entries: [ { id: 201, title: 'Before DST ends', short_description: '', start_datetime: '2026-10-24T12:00:00.000Z', end_datetime: '2026-10-24T13:00:00.000Z' }, { id: 202, title: 'After DST ends', short_description: '', start_datetime: '2026-10-26T12:00:00.000Z', end_datetime: '2026-10-26T13:00:00.000Z' } ] } ] } ); assert.match(preview, /BST/); assert.match(preview, /(GMT|UTC)/); }); test('timetable preview does not force the group timezone into placeholder transforms', () => { const calls = []; const module = loadScheduleModule({ window: { placeholderUtils: { resolvePlaceholderExpression(value, expression, options) { calls.push({ expression, options: options || {} }); return options && options.timeZone ? options.timeZone : 'local'; }, formatPlaceholderValue(value) { return String(value === undefined || value === null ? '' : value); } } } }); const preview = module.renderPreview( { id: 47, width: 500, height: 280 }, { value: '

{{start.tz_long()}}

', timetable_group_id: 1, display_mode: 'both', max_items: 3 }, { timetableGroups: [ { id: 1, name: 'Madrid board', timezone: 'Europe/Madrid', entries: [ { id: 101, title: 'Launch', short_description: 'Doors open', start_datetime: '2026-08-10T10:00:00.000Z', end_datetime: '2026-08-10T11:00:00.000Z' } ] } ] } ); assert.match(preview, /local/); assert.equal(calls.length > 0 ? Boolean(calls[0].options && calls[0].options.timeZone) : false, false); });