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-31 The day of the month ') < html.indexOf('dddMon The abbreviated weekday name '));
assert.ok(html.indexOf('dddMon The abbreviated weekday name ') < html.indexOf('M1-12 The month, beginning at 1 '));
assert.ok(html.indexOf('YY18 The two-digit year ') < html.indexOf('YYYY2018 The 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);
});