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, '''); }, 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' }); 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/); groups[0].entries = [ { id: 5, title: 'Future', start_datetime: '2099-08-20T10:00:00Z' }, groups[0].entries[0], { id: 6, title: 'Past', start_datetime: '2000-08-20T10:00:00Z', end_datetime: '2000-08-20T11:00:00Z' } ]; const displayCases = [ [{ display_mode: 'upcoming' }, ['Future']], [{ display_mode: 'current' }, ['Live']], [{ display_mode: 'both' }, ['Live', 'Future']], [{ display_mode: 'all' }, ['Past', 'Live', 'Future']], [{ display_mode: 'all', max_items: 1 }, ['Past']], [{ display_mode: 'all', date_filter: 'before', date_to: '2001-01-01T00:00:00Z' }, ['Past']], [{ display_mode: 'all', date_filter: 'after', date_from: '2099-01-01T00:00:00Z' }, ['Future']], [{ display_mode: 'all', date_filter: 'between', date_from: '2000-01-01T00:00:00Z', date_to: '2001-01-01T00:00:00Z' }, ['Past']] ]; for (const [settings, titles] of displayCases) { const content = { value: '{{title}}', timetable_group_id: 1, max_items: 5, ...settings }; const outputs = [previewModule.renderPreview({}, content, { timetableGroups: groups }), playerModule.renderRegion({}, content)]; for (const output of outputs) { for (const title of ['Past', 'Live', 'Future']) { assert.equal(output.includes(title), titles.includes(title), JSON.stringify(settings)); } for (let index = 1; index < titles.length; index++) { assert.ok(output.indexOf(titles[index - 1]) < output.indexOf(titles[index])); } } } }); 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, /