'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); const rootDir = path.resolve(__dirname, '..', '..'); function runtime({ completed = false } = {}) { const ids = new Map(); const doc = { activeElement: null }; class Element { constructor(attrs = {}) { this.attrs = { ...attrs }; this.listeners = {}; this.queries = new Map(); this.dataset = {}; this.disabled = false; this.open = false; this.required = false; this.checked = Boolean(attrs.checked); this.value = attrs.value || ''; this.textContent = attrs.textContent || ''; this.validity = ''; this.isConnected = true; this.currentContent = null; const names = new Set(String(attrs.class || '').split(/\s+/).filter(Boolean)); this.classList = { add(name) { names.add(name); }, remove(name) { names.delete(name); }, toggle(name, force) { const enabled = force === undefined ? !names.has(name) : Boolean(force); if (enabled) names.add(name); else names.delete(name); return enabled; }, contains(name) { return names.has(name); }, }; } getAttribute(name) { return this.attrs[name] ?? null; } setAttribute(name, value) { this.attrs[name] = String(value); } removeAttribute(name) { delete this.attrs[name]; } addEventListener(type, callback) { (this.listeners[type] ||= []).push(callback); } fire(type, values = {}) { const event = { target: this, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, ...values, }; (this.listeners[type] || []).forEach((callback) => callback(event)); return event; } querySelector(selector) { const matches = this.querySelectorAll(selector); return matches[0] || null; } querySelectorAll(selector) { const mapped = this.queries.get(selector); if (mapped) return Array.isArray(mapped) ? mapped : [mapped]; if (this.currentContent) return this.currentContent.querySelectorAll(selector); return []; } map(selector, elements) { this.queries.set(selector, elements); return this; } closest(selector) { if (selector === '[data-aksrt-dialog-close]' && this.attrs['data-aksrt-dialog-close'] !== undefined) return this; return null; } focus() { doc.activeElement = this; } setCustomValidity(message) { this.validity = message; } replaceChildren(content = null) { this.currentContent = content; } getBoundingClientRect() { return { left: 100, top: 100, right: 500, bottom: 500 }; } } function fragment({ readOnly = false, initiallyChecked = false } = {}) { const part = new Element(); const title = new Element({ id: 'aksrt-ban-dialog-title', autofocus: '' }); const closeIcon = new Element({ 'data-aksrt-dialog-close': '' }); const closeFooter = new Element({ 'data-aksrt-dialog-close': '' }); part.map('#aksrt-ban-dialog-title', title) .map('[data-aksrt-dialog-close]', [closeIcon, closeFooter]); const controls = [closeIcon, closeFooter]; let form = null; let radios = []; let note = null; let submit = null; if (!readOnly) { radios = [ new Element({ type: 'radio', value: 'approve', required: '', checked: initiallyChecked }), new Element({ type: 'radio', value: 'reject', required: '', checked: initiallyChecked }), ]; note = new Element({ name: 'note', value: 'old note', 'data-required-error': 'Please enter a nonblank rejection reason' }); const label = new Element({ 'data-aksrt-ban-note-label': '', 'data-optional': 'Optional note', 'data-required': 'Reject reason required' }); submit = new Element({ 'data-aksrt-ban-submit': '', 'data-idle-label': 'Choose decision', 'data-approve-label': 'Confirm approval', 'data-reject-label': 'Confirm rejection', 'data-busy-label': 'Submitting' }); controls.push(submit); form = new Element(); form.dataset = {}; form.elements = { radios, note }; form.map('input[type="radio"]', radios) .map('textarea[name="note"]', note) .map('[data-aksrt-ban-note-label]', label) .map('[data-aksrt-ban-submit]', submit); form.reportValidity = () => { const hasRequiredRadio = radios.some((radio) => radio.required); const radioValid = !hasRequiredRadio || radios.some((radio) => radio.checked); return radioValid && !note.validity && (!note.required || note.value.trim() !== ''); }; part.map('form[data-aksrt-ban-review]', form); } part.map('[data-aksrt-ban-submit], [data-aksrt-dialog-close]', controls); return { part, form, radios, note, submit, title, closeIcon, closeFooter }; } const root = new Element(); const window = new Element(); doc.documentElement = new Element(); doc.querySelector = (selector) => selector === '.aksrt-ban-page' ? root : null; doc.getElementById = (id) => ids.get(id) || null; const dialog = new Element(); const originalDialogQuery = dialog.querySelector.bind(dialog); const originalDialogQueryAll = dialog.querySelectorAll.bind(dialog); dialog.querySelector = (selector) => dialog.currentContent ? dialog.currentContent.querySelector(selector) : originalDialogQuery(selector); dialog.querySelectorAll = (selector) => dialog.currentContent ? dialog.currentContent.querySelectorAll(selector) : originalDialogQueryAll(selector); dialog.showModal = () => { dialog.open = true; const title = dialog.querySelector('#aksrt-ban-dialog-title'); if (title) title.focus(); }; dialog.close = () => { if (!dialog.open) return; dialog.open = false; dialog.fire('close'); }; ids.set('aksrt-ban-dialog', dialog); const opener = new Element({ 'data-aksrt-ban-view': 'report-1' }); const readonlyOpener = new Element({ 'data-aksrt-ban-view': 'report-2' }); const templates = new Map(); templates.set('aksrt-ban-detail-report-1', { content: { cloneNode: () => fragment().part }, }); templates.set('aksrt-ban-detail-report-2', { content: { cloneNode: () => fragment({ readOnly: true }).part }, }); ids.set('aksrt-ban-detail-report-1', templates.get('aksrt-ban-detail-report-1')); ids.set('aksrt-ban-detail-report-2', templates.get('aksrt-ban-detail-report-2')); root.map('[data-aksrt-ban-view]', [opener, readonlyOpener]); vm.runInNewContext(fs.readFileSync(path.join(rootDir, 'assets/js/admin-user-ban.js'), 'utf8'), { document: doc, window }); return { doc, window, root, dialog, opener, readonlyOpener }; } function openReview(runtimeState, button = runtimeState.opener) { button.fire('click'); const frame = runtimeState.dialog.currentContent; const form = frame.querySelector('form[data-aksrt-ban-review]'); return { frame, form, radios: form.querySelectorAll('input[type="radio"]'), note: form.querySelector('textarea[name="note"]'), label: form.querySelector('[data-aksrt-ban-note-label]'), submit: form.querySelector('[data-aksrt-ban-submit]'), closeButtons: frame.querySelectorAll('[data-aksrt-ban-submit], [data-aksrt-dialog-close]').filter((button) => button.attrs['data-aksrt-dialog-close'] !== undefined), }; } test('opening review clears choices, focuses the dialog, and locks page scrolling', () => { const r = runtime(); const review = openReview(r); assert.equal(r.dialog.open, true); assert.equal(r.doc.activeElement, review.frame.querySelector('#aksrt-ban-dialog-title')); assert.deepEqual(review.radios.map((radio) => radio.checked), [false, false]); assert.equal(review.note.value, ''); assert.equal(review.submit.disabled, true); assert.equal(review.label.textContent, 'Optional note'); assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), true); }); test('reject decisions require a nonblank trimmed note before submission', () => { const r = runtime(); const review = openReview(r); review.radios[1].checked = true; review.form.fire('change'); assert.equal(review.note.required, true); assert.equal(review.label.textContent, 'Reject reason required'); assert.equal(review.submit.disabled, false); review.note.value = ' \n '; review.form.fire('input'); assert.notEqual(review.note.validity, ''); const invalid = review.form.fire('submit'); assert.equal(invalid.defaultPrevented, true); assert.equal(review.form.dataset.submitting, undefined); review.note.value = 'Specific reason'; review.form.fire('input'); assert.equal(review.note.validity, ''); const valid = review.form.fire('submit'); assert.equal(valid.defaultPrevented, false); assert.equal(review.form.dataset.submitting, '1'); assert.equal(review.submit.textContent, 'Submitting'); }); test('submission blocks duplicate posts and closing until pageshow restores state', () => { const r = runtime(); const review = openReview(r); review.radios[0].checked = true; review.form.fire('change'); review.form.fire('submit'); assert.equal(review.submit.disabled, true); assert.ok(review.closeButtons.every((button) => button.disabled)); assert.equal(r.dialog.open, true); assert.equal(review.form.fire('submit').defaultPrevented, true); r.dialog.fire('cancel'); r.dialog.fire('click', { target: review.closeButtons[0] }); assert.equal(r.dialog.open, true); r.window.fire('pageshow'); assert.equal(review.form.dataset.submitting, undefined); assert.equal(review.form.getAttribute('aria-busy'), null); assert.equal(review.submit.disabled, false); assert.ok(review.closeButtons.every((button) => !button.disabled)); assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), true); r.dialog.fire('click', { target: review.closeButtons[0] }); assert.equal(r.dialog.open, false); assert.equal(r.dialog.currentContent, null); assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), false); assert.equal(r.doc.activeElement, r.opener); }); test('completed records open read-only without a form or submit controls', () => { const r = runtime(); r.readonlyOpener.fire('click'); assert.equal(r.dialog.open, true); assert.equal(r.dialog.querySelector('form[data-aksrt-ban-review]'), null); assert.equal(r.dialog.querySelector('[data-aksrt-ban-submit]'), null); assert.ok(r.dialog.querySelector('[data-aksrt-dialog-close]')); r.dialog.fire('click', { target: r.dialog.querySelector('[data-aksrt-dialog-close]') }); assert.equal(r.dialog.open, false); assert.equal(r.doc.activeElement, r.readonlyOpener); }); test('backdrop closes only when the pointer starts and ends outside the dialog', () => { const r = runtime(); openReview(r); r.dialog.fire('pointerdown', { clientX: 120, clientY: 120 }); r.dialog.fire('click', { clientX: 0, clientY: 0 }); assert.equal(r.dialog.open, true); r.dialog.fire('pointerdown', { clientX: 0, clientY: 0 }); r.dialog.fire('click', { clientX: 0, clientY: 0 }); assert.equal(r.dialog.open, false); assert.equal(r.doc.documentElement.classList.contains('aksrt-ban-modal-open'), false); });