const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); function createClassList() { const classes = new Set(); return { add(name) { classes.add(name); }, remove(name) { classes.delete(name); }, contains(name) { return classes.has(name); } }; } function createInput(name, value) { const listeners = Object.create(null); const attributes = Object.create(null); return { name: name, value: value || '', dataset: {}, classList: createClassList(), attributes: attributes, validityMessage: '', addEventListener(type, handler) { if (!listeners[type]) { listeners[type] = []; } listeners[type].push(handler); }, dispatchEvent(event) { const handlers = listeners[event.type] || []; handlers.forEach(function (handler) { handler.call(this, event); }, this); }, setCustomValidity(message) { this.validityMessage = String(message || ''); }, setAttribute(name, value) { attributes[name] = String(value); }, removeAttribute(name) { delete attributes[name]; } }; } function createRow(startValue, endValue) { const startInput = createInput('entry_start_datetime[]', startValue); const endInput = createInput('entry_end_datetime[]', endValue); const titleInput = createInput('entry_title[]', 'Opening'); const descriptionInput = createInput('entry_short_description[]', ''); const idInput = createInput('entry_id[]', '1'); const removeButton = { addEventListener() {} }; const row = { startInput: startInput, endInput: endInput, querySelector(selector) { if (selector === '[name="entry_start_datetime[]"]') { return startInput; } if (selector === '[name="entry_end_datetime[]"]') { return endInput; } if (selector === '[name="entry_title[]"]') { return titleInput; } if (selector === '[name="entry_short_description[]"]') { return descriptionInput; } if (selector === '[name="entry_id[]"]') { return idInput; } if (selector === '[data-remove-timetable-entry]') { return removeButton; } return null; }, remove() { row.removed = true; } }; return row; } function formatDateTimeLocalValue(date) { function pad(value) { return String(value).padStart(2, '0'); } return [ String(date.getFullYear()).padStart(4, '0'), '-', pad(date.getMonth() + 1), '-', pad(date.getDate()), 'T', pad(date.getHours()), ':', pad(date.getMinutes()) ].join(''); } test('timetable group form highlights an end time that is too early', () => { const row = createRow('2026-08-07T10:00', '2026-08-07T10:00'); const body = { rows: [row], querySelectorAll(selector) { return selector === '[data-timetable-entry-row]' ? this.rows.slice() : []; }, appendChild(node) { this.rows.push(node); return node; } }; const addButton = { listeners: Object.create(null), addEventListener(type, handler) { this.listeners[type] = handler; }, click() { if (this.listeners.click) { this.listeners.click(); } } }; const form = { dataset: {}, addEventListener() {} }; const templateRow = createRow('', ''); const template = { content: { cloneNode() { return { querySelector(selector) { return selector === '[data-timetable-entry-row]' ? templateRow : null; } }; } } }; const sandbox = { document: { getElementById(id) { if (id === 'timetable-group-form') { return form; } if (id === 'timetable-entry-row-template') { return template; } return null; }, querySelector(selector) { if (selector === '[data-timetable-entries-body]') { return body; } if (selector === '[data-add-timetable-entry]') { return addButton; } return null; } }, window: {}, Date: Date, Number: Number, String: String, Array: Array, Object: Object, Math: Math, JSON: JSON, console: console, module: { exports: {} }, exports: {} }; sandbox.window = sandbox; const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'data-sources', 'timetable-group-form.js'); const script = fs.readFileSync(scriptPath, 'utf8'); vm.runInNewContext(script, sandbox, { filename: scriptPath }); assert.equal(row.endInput.validityMessage, 'End time must be at least 1 minute after the start time.'); assert.equal(row.endInput.classList.contains('is-invalid'), true); assert.equal(row.endInput.attributes['aria-invalid'], 'true'); row.endInput.value = formatDateTimeLocalValue(new Date(new Date(row.startInput.value).getTime() + 60000)); row.endInput.dispatchEvent({ type: 'input' }); assert.equal(row.endInput.validityMessage, ''); assert.equal(row.endInput.classList.contains('is-invalid'), false); assert.equal(Object.prototype.hasOwnProperty.call(row.endInput.attributes, 'aria-invalid'), false); });