385 lines
15 KiB
JavaScript
385 lines
15 KiB
JavaScript
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 placeholderInfoScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-info.js'), 'utf8');
|
|
const scriptPath = require.resolve('../src/web/public/js/regions/type/timetable.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, '"')
|
|
.replace(/'/g, ''');
|
|
},
|
|
sanitizeRichText(value) {
|
|
return String(value === undefined || value === null ? '' : value);
|
|
}
|
|
},
|
|
placeholderChips: {
|
|
renderChips(tokens) {
|
|
return tokens.map((token) => '<span class="chip">{{' + token + '}}</span>').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' });
|
|
vm.runInNewContext(placeholderInfoScript, sandbox, { filename: 'placeholder-info.js' });
|
|
if (customWindow.placeholderUtils) {
|
|
sandbox.window.placeholderUtils = customWindow.placeholderUtils;
|
|
}
|
|
vm.runInNewContext(script, sandbox, { filename: scriptPath });
|
|
return registry.get('timetable');
|
|
}
|
|
|
|
test('timetable date ranges agree in preview and player, before limiting items', () => {
|
|
const previewModule = loadScheduleModule();
|
|
const groups = [{ id: 1, entries: [
|
|
{ id: 1, title: 'Early', start_datetime: '2099-08-20T09:00:00Z' },
|
|
{ id: 2, title: 'Middle', start_datetime: '2099-08-20T10:00:00Z' },
|
|
{ id: 3, title: 'Late', start_datetime: '2099-08-20T11:00:00Z' }
|
|
] }];
|
|
let playerModule;
|
|
const sandbox = {
|
|
window: {
|
|
pulsePlayerRegionTypes: { register(type, module) { playerModule = module; } },
|
|
placeholderUtils: {
|
|
resolvePlaceholderExpression(entry, expression) { return entry[expression]; },
|
|
formatPlaceholderValue(value) { return value; }
|
|
}
|
|
},
|
|
initialData: { timetableGroups: groups },
|
|
escapeHtml: String,
|
|
sanitizeRichText: String
|
|
};
|
|
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8'), sandbox);
|
|
const cases = [
|
|
[{}, ['Early', 'Middle', 'Late']],
|
|
[{ date_filter: 'all', date_from: '2099-08-20T10:00:00Z' }, ['Early', 'Middle', 'Late']],
|
|
[{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z' }, ['Middle', 'Late']],
|
|
[{ date_filter: 'between', date_to: '2099-08-20T10:00:00Z' }, ['Early', 'Middle']],
|
|
[{ date_filter: 'between', date_from: '2099-08-20T12:00:00+02:00', date_to: '2099-08-20T10:00:00Z' }, ['Middle']],
|
|
[{ date_filter: 'between', date_from: '2099-08-20T10:00:00Z', max_items: 1 }, ['Middle']],
|
|
[{ date_filter: 'between', date_from: '2099-08-21T00:00:00Z' }, []],
|
|
[{ date_filter: 'after', date_from: '2099-08-20T10:00:00Z', date_to: '2099-08-20T09:00:00Z' }, ['Middle', 'Late']],
|
|
[{ date_filter: 'before', date_to: '2099-08-20T10:00:00Z', date_from: '2099-08-20T11:00:00Z' }, ['Early', 'Middle']]
|
|
];
|
|
for (const [range, titles] of cases) {
|
|
const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'both', max_items: 5, ...range };
|
|
const region = { id: 47, baseStyle: '' };
|
|
const preview = previewModule.renderPreview(region, config, { timetableGroups: groups });
|
|
const player = playerModule.renderRegion(region, config);
|
|
for (const title of ['Early', 'Middle', 'Late']) {
|
|
assert.equal(preview.includes(title), titles.includes(title), 'preview ' + JSON.stringify(range));
|
|
assert.equal(player.includes(title), titles.includes(title), 'player ' + JSON.stringify(range));
|
|
}
|
|
}
|
|
|
|
groups[0].entries = [{ id: 4, title: 'Live', start_datetime: new Date(Date.now() - 60000).toISOString(), end_datetime: new Date(Date.now() + 60000).toISOString() }];
|
|
const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'upcoming', date_filter: 'between', date_from: '2099-08-20T00:00:00Z' };
|
|
assert.doesNotMatch(previewModule.renderPreview({}, config, { timetableGroups: groups }), /Live/);
|
|
assert.doesNotMatch(playerModule.renderRegion({}, config), /Live/);
|
|
});
|
|
|
|
test('timetable date controls default to all and pass unsaved date ranges to preview', () => {
|
|
const module = loadScheduleModule();
|
|
const region = { id: 47, region_key: 'timetable', label: 'Timetable' };
|
|
const html = module.renderEditorCard({ region, current: {}, timetableGroups: [] });
|
|
assert.match(html, /<option value="all" selected>All<\/option>/);
|
|
assert.match(html, /data-timetable-date-range hidden/);
|
|
assert.match(html, /data-timetable-date="date_from"[^>]* disabled/);
|
|
const saved = { date_filter: 'between', date_from: '2099-08-20T10:00:45.000Z', date_to: '' };
|
|
const enabled = module.renderEditorCard({ region, current: saved, timetableGroups: [] });
|
|
assert.equal((enabled.match(/type="datetime-local" step="60"/g) || []).length, 2);
|
|
assert.match(enabled, /data-timetable-date="date_from" value="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}"/);
|
|
assert.match(enabled, /<option value="between" selected>Between Dates<\/option>/);
|
|
assert.doesNotMatch(enabled, /data-timetable-date-range hidden/);
|
|
assert.doesNotMatch(enabled, /data-timetable-date="date_from"[^>]* disabled/);
|
|
const config = module.getCurrentConfig(region, { timetable: saved });
|
|
assert.equal(config.date_from, saved.date_from);
|
|
const fields = {
|
|
'[name="region_date_filter_47"]': { value: 'between' },
|
|
'[name="region_date_from_47"]': { value: '' },
|
|
'[name="region_date_to_47"]': { value: '2099-08-21T10:00:00.000Z' }
|
|
};
|
|
const context = module.buildPreviewRenderContext(region, { querySelector(selector) { return fields[selector] || null; } }, { timetable: saved }, []);
|
|
assert.equal(context.date_filter, 'between');
|
|
assert.equal(context.date_from, '');
|
|
assert.equal(context.date_to, fields['[name="region_date_to_47"]'].value);
|
|
assert.doesNotMatch(html, /Use the selected group and display mode/);
|
|
for (const mode of ['before', 'after']) {
|
|
const current = { ...saved, date_filter: mode };
|
|
const markup = module.renderEditorCard({ region, current, timetableGroups: [] });
|
|
assert.match(markup, new RegExp('<option value="' + mode + '" selected>'));
|
|
assert.equal(module.getCurrentConfig(region, { timetable: current }).date_filter, mode);
|
|
const activeField = mode === 'after' ? 'date_from' : 'date_to';
|
|
const inactiveField = mode === 'after' ? 'date_to' : 'date_from';
|
|
assert.doesNotMatch(markup, new RegExp('data-timetable-date="' + activeField + '"[^>]* disabled'));
|
|
assert.match(markup, new RegExp('data-timetable-date="' + inactiveField + '"[^>]* disabled'));
|
|
}
|
|
});
|
|
|
|
test('timetable region editor lists timezone transforms in helper text', () => {
|
|
const module = loadScheduleModule();
|
|
const html = module.renderEditorCard({
|
|
region: { id: 47, label: 'Timetable' },
|
|
current: {
|
|
value: '<p>{{title}} {{start.tz()}} {{start.tz_long()}}</p>',
|
|
timetable_group_id: 1,
|
|
display_mode: 'current',
|
|
max_items: 3
|
|
},
|
|
timetableGroups: [
|
|
{
|
|
id: 1,
|
|
name: 'Main board',
|
|
timezone: 'UTC',
|
|
entries: []
|
|
}
|
|
]
|
|
});
|
|
|
|
assert.match(html, /Timetable placeholder transforms/);
|
|
assert.match(html, /<dt class="fw-normal"><code>tz\(\)<\/code>/);
|
|
assert.match(html, /<dt class="fw-normal"><code>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: '<p>{{start.format("MMM D, YYYY h:mm A")}}</p>',
|
|
timetable_group_id: 1,
|
|
display_mode: 'current',
|
|
max_items: 3
|
|
},
|
|
timetableGroups: [
|
|
{
|
|
id: 1,
|
|
name: 'Main board',
|
|
timezone: 'UTC',
|
|
entries: []
|
|
}
|
|
]
|
|
});
|
|
|
|
assert.match(html, /Date format tokens/);
|
|
assert.match(html, /<th>Token<\/th><th>Meaning<\/th><th>Example<\/th>/);
|
|
assert.match(html, /<tr><td><code>D<\/code><\/td><td>Day number<\/td><td><code>16<\/code><\/td><\/tr>/);
|
|
assert.match(html, /<tr><td><code>YYYY<\/code><\/td><td>Four-digit year<\/td><td><code>2026<\/code><\/td><\/tr>/);
|
|
assert.match(html, /Date format tokens/);
|
|
assert.match(html, /<th>Token<\/th><th>Meaning<\/th><th>Example<\/th>/);
|
|
assert.match(html, /<tr><td><code>D<\/code><\/td><td>Day number<\/td><td><code>16<\/code><\/td><\/tr>/);
|
|
assert.match(html, /<tr><td><code>YYYY<\/code><\/td><td>Four-digit year<\/td><td><code>2026<\/code><\/td><\/tr>/);
|
|
assert.doesNotMatch(html, /<td><code>tz<\/code><\/td>/);
|
|
assert.match(html, /Use <code>tz\(\)<\/code> for a short timezone name and <code>tz_long\(\)<\/code> for the full timezone name/);
|
|
assert.match(html, /Use <code>tz\(\)<\/code> for a short timezone name and <code>tz_long\(\)<\/code> for the full timezone name/);
|
|
});
|
|
|
|
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: '<p>{{start.tz("UTC")}} {{start.tz_long("UTC")}}</p>',
|
|
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: '2099-08-20T10:00:00.000Z',
|
|
end_datetime: '2099-08-20T11: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: '<p>{{start.tz("Europe/London")}}</p>',
|
|
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: '<p>{{start.tz_long()}}</p>',
|
|
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: '2099-08-20T10:00:00.000Z',
|
|
end_datetime: '2099-08-20T11:00:00.000Z'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
);
|
|
|
|
assert.match(preview, /local/);
|
|
assert.equal(calls.length > 0 ? Boolean(calls[0].options && calls[0].options.timeZone) : false, false);
|
|
});
|