diff --git a/tests/resources/aria-at-harness.mjs b/tests/resources/aria-at-harness.mjs index a47e1a1d0..2bb3cb44a 100644 --- a/tests/resources/aria-at-harness.mjs +++ b/tests/resources/aria-at-harness.mjs @@ -1,20 +1,25 @@ -import {commandsAPI} from './at-commands.mjs'; - -const UNDESIRABLES = [ +import {commandsAPI} from "./at-commands.mjs"; +import {element, fragment, property, attribute, className, style, focus, render} from "./vrender.mjs"; +import { + AssertionResultMap, + CommonResultMap, + createEnumMap, + HasUnexpectedBehaviorMap, + TestRun, + UserActionMap, + userCloseWindow, + userOpenWindow, + WhitespaceStyleMap, +} from "./aria-at-test-run.mjs"; + +const UNEXPECTED_BEHAVIORS = [ "Output is excessively verbose, e.g., includes redundant and/or irrelevant speech", "Reading cursor position changed in an unexpected manner", "Screen reader became extremely sluggish", "Screen reader crashed", - "Browser crashed" + "Browser crashed", ]; -const TEST_HTML_OUTLINE = ` -
- -
-
-
-`; const PAGE_STYLES = ` table { border-collapse: collapse; @@ -72,362 +77,171 @@ const PAGE_STYLES = ` } `; -let behavior; -let behaviorResults; -let overallStatus; +/** @type {string[]} */ const errors = []; -let testPageUri; -let testPageWindow; let showResults = true; let showSubmitButton = true; +/** @type {AT} */ let at; -let commandsData; +/** @type {CommandsAPI} */ let commapi; -let support; +/** @type {Behavior} */ +let behavior; +/** @type {TestRunState} */ +let firstState; +/** + * @param {Support} newSupport + * @param {Commands} newCommandsData + */ export function initialize(newSupport, newCommandsData) { - support = newSupport; - commandsData = newCommandsData; - commapi = new commandsAPI(commandsData, support); + commapi = new commandsAPI(newCommandsData, newSupport); // Get the AT under test from the URL search params // set the showResults flag from the URL search params - let params = (new URL(document.location)).searchParams; - at = support.ats[0]; + let params = new URL(document.location).searchParams; + at = newSupport.ats[0]; for (const [key, value] of params) { - if (key === 'at') { + if (key === "at") { let requestedAT = value; if (commapi.isKnownAT(requestedAT)) { at = commapi.isKnownAT(requestedAT); - } - else { - errors.push(`Harness does not have commands for the requested assistive technology ('${requestedAT}'), showing commands for assistive technology '${at.name}' instead. To test '${requestedAT}', please contribute command mappings to this project.`); + } else { + errors.push( + `Harness does not have commands for the requested assistive technology ('${requestedAT}'), showing commands for assistive technology '${at.name}' instead. To test '${requestedAT}', please contribute command mappings to this project.` + ); } } - if (key === 'showResults') { - if (value === 'true') { + if (key === "showResults") { + if (value === "true") { showResults = true; - } else if (value === 'false') { + } else if (value === "false") { showResults = false; } } - if (key === 'showSubmitButton') { - if (value === 'true') { + if (key === "showSubmitButton") { + if (value === "true") { showSubmitButton = true; - } else if (value === 'false') { + } else if (value === "false") { showSubmitButton = false; } } } } -function openTestPagePopup() { - testPageWindow = window.open(testPageUri, '_blank', 'toolbar=0,location=0,menubar=0,width=400,height=400'); - - document.getElementById('open-test-page').disabled = true; - - // If the window is closed, re-enable open popup button - testPageWindow.onunload = function(event) { - window.setTimeout(() => { - if (testPageWindow.closed) { - testPageWindow = undefined; - document.getElementById('open-test-page').disabled = false; - } - }, 100); - - }; - - executeScriptInTestPage(); -} - -function putTestPageWindowIntoCorrectState() { - // testPageWindow.location.reload(); // TODO: Address the race condition this causes with script execution. - executeScriptInTestPage(); -} - -function executeScriptInTestPage() { - let setupTestPage = behavior.setupTestPage; - if (setupTestPage) { - if (testPageWindow.location.origin !== window.location.origin // make sure the origin is the same, and prevent this from firing on an 'about' page - || testPageWindow.document.readyState !== 'complete' - ) { - window.setTimeout(() => { - executeScriptInTestPage(); - }, 100); - return; - } - - scripts[behavior.setupTestPage](testPageWindow.document); - } -} - +/** + * @param {BehaviorJSON} atBehavior + */ export function verifyATBehavior(atBehavior) { // This is temporary until transition is complete from multiple modes to one mode - let mode = typeof atBehavior.mode === 'string' ? atBehavior.mode : atBehavior.mode[0]; - - let newBehavior = Object.assign({}, atBehavior, { mode: mode }); - newBehavior.commands = commapi.getATCommands(mode, atBehavior.task, at); + let mode = typeof atBehavior.mode === "string" ? atBehavior.mode : atBehavior.mode[0]; + + /** @type {Behavior} */ + behavior = { + description: document.title, + task: atBehavior.task, + mode, + modeInstructions: commapi.getModeInstructions(mode, at), + appliesTo: atBehavior.applies_to, + specificUserInstruction: atBehavior.specific_user_instruction, + setupScriptDescription: atBehavior.setup_script_description, + setupTestPage: atBehavior.setupTestPage, + commands: commapi.getATCommands(mode, atBehavior.task, at), + outputAssertions: atBehavior.output_assertions ? atBehavior.output_assertions : [], + additionalAssertions: atBehavior.additional_assertions ? atBehavior.additional_assertions[at.key] || [] : [], + unexpectedBehaviors: [ + ...UNEXPECTED_BEHAVIORS.map(content => ({content})), + {content: "Other", requireExplanation: true}, + ], + }; - newBehavior.output_assertions = newBehavior.output_assertions ? newBehavior.output_assertions : []; - newBehavior.additional_assertions = newBehavior.additional_assertions - ? atBehavior.additional_assertions[at.key] || [] - : []; - if (!behavior && newBehavior.commands.length) { - behavior = newBehavior; + if (!firstState && behavior.commands.length) { + firstState = initializeTestRunState({ + errors: errors.length ? errors : null, + test: behavior, + config: {at, displaySubmitButton: showSubmitButton, renderResultsAfterSubmit: showResults}, + }); } else { - throw new Error('Test files should only contain one verifyATBehavior call.'); + throw new Error("Test files should only contain one verifyATBehavior call."); } } export function displayTestPageAndInstructions(testPage) { - testPageUri = testPage; - - if (document.readyState !== 'complete') { + if (document.readyState !== "complete") { window.setTimeout(() => { displayTestPageAndInstructions(testPage); }, 100); return; } - document.querySelector('html').setAttribute('lang', 'en'); - document.body.innerHTML = (TEST_HTML_OUTLINE); - var style = document.createElement('style'); + document.querySelector("html").setAttribute("lang", "en"); + var style = document.createElement("style"); style.innerHTML = PAGE_STYLES; document.head.appendChild(style); - showUserError(); - - displayInstructionsForBehaviorTest(); + displayInstructionsForBehaviorTest(testPage, behavior); } -function displayInstructionsForBehaviorTest() { - - function getSetupInstructions() { - let html = ''; - for (let i = 0; i < (userInstructions.length - 1); i++) { - html += `
  • ${userInstructions[i]}
  • `; - } - return html; - } +/** + * @param {string} testPage + * @param {Behavior} behavior + */ +function displayInstructionsForBehaviorTest(testPage, behavior) { + const windowManager = new TestWindow({ + pageUri: testPage, + setupScriptName: behavior.setupTestPage, + scripts: typeof scripts === "object" ? scripts : {}, + hooks: { + windowOpened() { + app.dispatch(userOpenWindow()); + }, + windowClosed() { + app.dispatch(userCloseWindow()); + }, + }, + }); // First, execute necesary set up script in test page if the test page is open from a previous behavior test - if (testPageWindow) { - putTestPageWindowIntoCorrectState(); - } - - const mode = behavior.mode; - const modeInstructions = commapi.getModeInstructions(mode, at); - const userInstructions = behavior.specific_user_instruction.split('|'); - const lastInstruction = userInstructions[userInstructions.length-1]; - const commands = behavior.commands; - const assertions = behavior.output_assertions.map((a) => a[1]); - const additionalBehaviorAssertions = behavior.additional_assertions; - const setupScriptDescription = behavior.setup_script_description ? ` and runs a script that ${behavior.setup_script_description}.` : behavior.setup_script_description; - // As a hack, special case mode instructions for VoiceOver for macOS until we support modeless tests. - // ToDo: remove this when resolving issue #194 - const modePhrase = at.name === "VoiceOver for macOS" ? "Describe " : `With ${at.name} in ${mode} mode, describe `; - - let instructionsEl = document.getElementById('instructions'); - instructionsEl.innerHTML = ` -

    Testing task: ${document.title}

    -

    ${modePhrase} how ${at.name} behaves when performing task "${lastInstruction}"

    -

    Test instructions

    -
      -
    1. Restore default settings for ${at.name}. For help, read Configuring Screen Readers for Testing.
    2. -
    3. Activate the "Open test page" button below, which opens the example to test in a new window${setupScriptDescription}
    4. -
    5. ${modeInstructions}
    6. - ${getSetupInstructions()} -
    7. Using the following commands, ${lastInstruction} - -
    8. -
    -

    Success Criteria

    -

    To pass this test, ${at.name} needs to meet all the following assertions when each specified command is executed:

    - -`; - - // Hack to remove mode instructions for VoiceOver for macOS to get us by until we support modeless screen readers. - // ToDo: remove this when resolving issue #194 - if (at.name === "VoiceOver for macOS") { - let modeInstructionsEl= document.getElementById('mode-instructions-li'); - modeInstructionsEl.parentNode.removeChild(modeInstructionsEl); - } - - for (let command of commands) { - let commandEl = document.createElement('li'); - commandEl.innerHTML = `${command}`; - document.getElementById('at_controls').append(commandEl); - } - - for (let assertion of assertions) { - let el = document.createElement('li'); - el.innerHTML = `${assertion}`; - document.getElementById('assertions').append(el); - } - - for (let additional of additionalBehaviorAssertions) { - let el = document.createElement('li'); - el.innerHTML = `${additional[1]}`; - document.getElementById('assertions').append(el); - } - - let openButton = document.createElement('button'); - openButton.id = 'open-test-page'; - openButton.innerText = "Open Test Page"; - openButton.addEventListener('click', openTestPagePopup); - if (testPageWindow) { - openButton.disabled = true; - } - document.getElementById('instructions').append(openButton); - - let recordResults = `

    Record Results

    ${document.title}

    `; - - for (let c = 0; c < commands.length; c++) { - recordResults += `

    After '${commands[c]}'

    `; - recordResults += ` -

    - - -

    -`; - - recordResults += ` - - - - - -`; - - for (let a = 0; a < assertions.length; a++) { - recordResults += ` - - - - - -`; - } - - for (let n = 0; n < additionalBehaviorAssertions.length; n++) { - let a = assertions.length + n; - recordResults += ` - - - - - -`; - } - - recordResults += '
    Assertion - Success case - - Failure cases -
    ${assertions[a]}
    (required: mark output)
    - - - - - - - -
    ${additionalBehaviorAssertions[n][1]}
    (required: mark support)
    - - - - - -
    '; - - recordResults += ` -
    -Were there additional undesirable behaviors? (required) -
    - - -
    -
    - - -
    -
    - Undesirable behaviors (required) -`; - - for (let undesirable of UNDESIRABLES) { - const string = ` - - -
    - `; - recordResults += string; - } - - recordResults += ` - - -
    -
    - - -
    - -
    -`; - - recordResults += `
    `; - } - - let recordEl = document.getElementById('record-results'); - recordEl.innerHTML = recordResults; - - let radios = document.querySelectorAll('input[type="radio"]'); - for (let radio of radios) { - radio.onclick = handleRadioClick; - } - - let checkboxes = document.querySelectorAll('input[type=checkbox]'); - for (let checkbox of checkboxes) { - checkbox.onchange = handleUndesirableSelect; - checkbox.addEventListener('keydown', handleUndesirableKeydown); - } - - let otherUndesirableInput = document.querySelectorAll('.undesirable-other-input'); - for (let otherInput of otherUndesirableInput) { - otherInput.addEventListener('change', handleOtherUndesirableInput); - } - - if (showSubmitButton) { - // Submit button - let el = document.createElement('button'); - el.id = 'submit-results'; - el.innerText = 'Submit Results'; - el.addEventListener('click', submitResult); - recordEl.append(el); - } - - document.querySelector('#behavior-header').focus(); + windowManager.prepare(); + + const app = new TestRunExport({ + behavior, + hooks: { + openTestPage() { + windowManager.open(); + }, + closeTestPage() { + windowManager.close(); + }, + postResults: () => postResults(app), + }, + state: firstState, + }); + app.observe(() => { + render(document.body, renderVirtualTestPage(app.testPageAndResults())); + }); + render(document.body, renderVirtualTestPage(app.testPageAndResults())); // if test is loaded in iFrame if (window.parent && window.parent.postMessage) { // results can be submitted by parent posting a message to the // iFrame with a data.type property of 'submit' - window.addEventListener('message', function(message) { - if (!validateMessage(message, 'submit')) return; - submitResult(); + window.addEventListener("message", function (message) { + if (!validateMessage(message, "submit")) return; + app.hooks.submit(); }); // send message to parent that test has loaded - window.parent.postMessage({ - type: 'loaded', - data: { - testPageUri: testPageUri - } - }, '*'); + window.parent.postMessage( + { + type: "loaded", + data: { + testPageUri: windowManager.pageUri, + }, + }, + "*" + ); } } @@ -435,7 +249,7 @@ function validateMessage(message, type) { if (window.location.origin !== message.origin) { return false; } - if (!message.data || typeof message.data !== 'object') { + if (!message.data || typeof message.data !== "object") { return false; } if (message.data.type !== type) { @@ -444,417 +258,850 @@ function validateMessage(message, type) { return true; } -function handleUndesirableSelect(event) { - let radioId = event.target.id; - let cmdId, otherSelected; - if (radioId) { - cmdId = Number(radioId.split('-')[1]); - otherSelected = document.querySelector(`#undesirable-${cmdId}-other`); - if (otherSelected && otherSelected.checked == true) { - document.querySelector(`#undesirable-${cmdId}-other-input`).disabled = false; - } else { - document.querySelector(`#undesirable-${cmdId}-other-input`).disabled = true; - document.querySelector(`#undesirable-${cmdId}-other-input`).value = ''; - } - } - - // Handle any checkbox selected - let radioName = event.target.name; - if (radioName) { - cmdId = Number(radioName.split('-')[1]); - document.querySelector(`#problem-${cmdId}-true`).checked = true; +/** + * @param {TestRunExport} app + */ +function postResults(app) { + // send message to parent if test is loaded in iFrame + if (window.parent && window.parent.postMessage) { + window.parent.postMessage( + { + type: "results", + data: app.resultsJSON(), + }, + "*" + ); } } -function handleOtherUndesirableInput(event) { - let inputId = event.target.id; - let cmd = inputId.split('-')[1]; +/** @typedef {ConstructorParameters[0]} TestRunOptions */ +/** + * @typedef TestRunExportOptions + * @property {Behavior} behavior + */ - let otherCheckbox = document.querySelector(`#undesirable-${cmd}-other`); - if (event.target.value) { - otherCheckbox.checked = true; - } - else { - otherCheckbox.checked = false; - } -} +class TestRunExport extends TestRun { + /** + * @param {TestRunOptions & TestRunExportOptions} options + */ + constructor({behavior, ...parentOptions}) { + super(parentOptions); -function handleRadioClick(event) { - let radioId = event.target.id; - let cmdId = Number(radioId.split('-')[1]); - - let markedAs = radioId.split('-')[2]; - let checkboxes = document.querySelectorAll(`.undesirable-${cmdId}`); - let otherInput = document.querySelector(`#undesirable-${cmdId}-other-input`); - if (markedAs === 'true') { - checkboxes[0].tabIndex = 0; - for (let checkbox of checkboxes) { - checkbox.disabled = false; - } - otherInput.disabled = false; - } else { - for (let checkbox of checkboxes) { - checkbox.disabled = true; - checkbox.checked = false; - } - otherInput.disabled = true; - otherInput.value = ''; + this.behavior = behavior; } -} - -function handleUndesirableKeydown(event) { - var checkbox = event.currentTarget, - flag = false; - - switch (event.key) { - case 'Up': - case 'ArrowUp': - case 'Left': - case 'ArrowLeft': - setFocusToPreviousItem(checkbox); - flag = true; - break; - case 'Down': - case 'ArrowDown': - case 'Right': - case 'ArrowRight': - setFocusToNextItem(checkbox); - flag = true; - break; - - default: - break; - } - - if (flag) { - event.stopPropagation(); - event.preventDefault(); + testPageAndResults() { + const testPage = this.testPage(); + if ("results" in testPage) { + return { + ...testPage, + resultsJSON: this.resultsJSON(), + }; + } + return { + ...testPage, + resultsJSON: this.state.currentUserAction === UserActionMap.CLOSE_TEST_WINDOW ? this.resultsJSON() : null, + }; } -} - -function setFocusToPreviousItem(checkbox) { - let cmd = checkbox.parentElement.id.split('-')[1]; - let checkboxNodes = document.querySelectorAll(`#cmd-${cmd}-problem input[type=checkbox]`); - let checkboxes = Array.from(checkboxNodes); - - let checkboxIds = checkboxes.map(c => c.id); - let index = checkboxIds.indexOf(checkbox.id); - checkboxNodes[index].tabIndex = -1; - - if (index === 0) { - checkboxNodes[checkboxes.length - 1].tabIndex = 0; - checkboxNodes[checkboxes.length - 1].focus(); - } - else { - checkboxNodes[index - 1].tabIndex = 0; - checkboxNodes[index - 1].focus(); + resultsJSON() { + return toSubmitResultJSON(this.state, this.behavior); } } -function setFocusToNextItem(checkbox) { - let cmd = checkbox.parentElement.id.split('-')[1]; - let checkboxNodes = document.querySelectorAll(`#cmd-${cmd}-problem input[type=checkbox]`); - let checkboxes = Array.from(checkboxNodes); - - let checkboxIds = checkboxes.map(c => c.id); - let index = checkboxIds.indexOf(checkbox.id); - - checkboxNodes[index].tabIndex = -1; - index++; - - if (index === checkboxes.length) { - checkboxNodes[0].tabIndex = 0; - checkboxNodes[0].focus(); +class TestWindow { + /** + * @param {object} options + * @param {Window | null} [options.window] + * @param {string} options.pageUri + * @param {string} [options.setupScriptName] + * @param {TestWindowHooks} [options.hooks] + * @param {SetupScripts} [options.scripts] + */ + constructor({window = null, pageUri, setupScriptName, hooks, scripts = {}}) { + /** @type {Window | null} */ + this.window = window; + + /** @type {string} */ + this.pageUri = pageUri; + + /** @type {string} */ + this.setupScriptName = setupScriptName; + + /** @type {TestWindowHooks} */ + this.hooks = { + windowOpened: () => {}, + windowClosed: () => {}, + ...hooks, + }; + + /** @type {SetupScripts} */ + this.scripts = scripts; } - else { - checkboxNodes[index].tabIndex = 0; - checkboxNodes[index].focus(); - } -} -function validateResults() { + open() { + this.window = window.open(this.pageUri, "_blank", "toolbar=0,location=0,menubar=0,width=400,height=400"); - let focusEl; - for (let c = 0; c < behavior.commands.length; c++) { + this.hooks.windowOpened(); - // If there is no output recorded, mark the screen reader output as required - let outputParagraph = document.getElementById(`cmd-${c}-output`); - let cmdInput = outputParagraph.querySelector('textarea'); - if (!cmdInput.value) { - focusEl = focusEl || cmdInput; - outputParagraph.querySelector('.required').classList.add('highlight-required'); - } else { - outputParagraph.querySelector('.required').classList.remove('highlight-required'); - } + // If the window is closed, re-enable open popup button + this.window.onunload = () => { + window.setTimeout(() => { + if (this.window.closed) { + this.window = undefined; - // If "all pass" is selected, remove "required" mark any remaining assertions (because they will - // all have been marked as passing, now) and move to the next command + this.hooks.windowClosed(); + } + }, 100); + }; - let numAssertions = document.getElementById(`cmd-${c}`).rows.length - 1; - let undesirableFieldset = document.getElementById(`cmd-${c}-problem`); + this.prepare(); + } - // Otherwise, we must go though each assertion and add or remove the "required" mark - for (let a = 0; a < numAssertions; a++) { - let selectedRadio = document.querySelector(`input[name="result-${c}-${a}"]:checked`); - if (!selectedRadio) { - document.querySelector(`#assertion-${c}-${a} .required`).classList.add('highlight-required'); - focusEl = focusEl || document.getElementById(`pass-${c}-${a}`); - } - else { - document.querySelector(`#assertion-${c}-${a} .required`).classList.remove('highlight-required'); - } + prepare() { + if (!this.window) { + return; } - - // Check that the "unexpected/additional problems" fieldset is filled out - let problemRadio = document.querySelector(`input[name="problem-${c}"]:checked`); - let problemSelected = document.querySelectorAll(`.undesirable-${c}:checked`); - let otherSelected = document.querySelector(`#undesirable-${c}-other:checked`); - let otherText = document.querySelector(`#undesirable-${c}-other-input`).value; - if (!problemRadio || (problemRadio.classList.contains('fail') && problemSelected.length === 0 && !otherSelected)) { - undesirableFieldset.classList.add('highlight-required'); - } - if (!problemRadio || (problemRadio.classList.contains('fail') && problemSelected.length === 0 && !otherSelected)) { - document.querySelector(`#cmd-${c}-problem legend .required`).classList.add('highlight-required'); - focusEl = focusEl || document.querySelector(`#cmd-${c}-problem input[type="checkbox"]`); + let setupScriptName = this.setupScriptName; + if (!setupScriptName) { + return; } - else if (document.querySelector(`input#problem-${c}-false:checked`) || (problemRadio && problemSelected.length > 0) || (otherSelected && otherText)) { - document.querySelector(`#cmd-${c}-problem legend .required`).classList.remove('highlight-required'); - undesirableFieldset.classList.remove('highlight-required'); + if ( + this.window.location.origin !== window.location.origin || // make sure the origin is the same, and prevent this from firing on an 'about' page + this.window.document.readyState !== "complete" + ) { + window.setTimeout(() => { + this.prepare(); + }, 100); + return; } - if (otherSelected) { - if (!otherText) { - document.querySelector(`#cmd-${c}-problem .required-other`).classList.add('highlight-required'); - undesirableFieldset.classList.add('highlight-required'); - focusEl = focusEl || document.querySelector(`#undesirable-${c}-other-input`); - } - else { - document.querySelector(`#cmd-${c}-problem .required-other`).classList.remove('highlight-required'); - undesirableFieldset.classList.remove('highlight-required'); - } - } + this.scripts[setupScriptName](this.window.document); } - if (focusEl) { - focusEl.focus(); - return false; + close() { + if (this.window) { + this.window.close(); + } } - return true; } +function bind(fn, ...args) { + return (...moreArgs) => fn(...args, ...moreArgs); +} -function submitResult(event) { - if (!validateResults()) { - return; - } +/** + * @param {object} options + * @param {string[] | null} [options.errors] + * @param {Behavior} options.test + * @param {object} options.config + * @param {AT} options.config.at + * @param {boolean} [options.config.displaySubmitButton] + * @param {boolean} [options.config.renderResultsAfterSubmit] + * @returns {TestRunState} + */ +function initializeTestRunState({ + errors = null, + test, + config: {at, displaySubmitButton = true, renderResultsAfterSubmit = true}, +}) { + return { + errors, + info: { + description: test.description, + task: test.task, + mode: test.mode, + modeInstructions: test.modeInstructions, + userInstructions: test.specificUserInstruction.split("|"), + setupScriptDescription: test.setupScriptDescription, + }, + config: { + at, + displaySubmitButton, + renderResultsAfterSubmit, + }, + currentUserAction: UserActionMap.LOAD_PAGE, + openTest: { + enabled: true, + }, + commands: test.commands.map( + command => + /** @type {TestRunCommand} */ ({ + description: command, + atOutput: { + highlightRequired: false, + value: "", + }, + assertions: test.outputAssertions.map(assertion => ({ + description: assertion[1], + highlightRequired: false, + priority: Number(assertion[0]), + result: CommonResultMap.NOT_SET, + })), + additionalAssertions: test.additionalAssertions.map(assertion => ({ + description: assertion[1], + highlightRequired: false, + priority: Number(assertion[0]), + result: CommonResultMap.NOT_SET, + })), + unexpected: { + highlightRequired: false, + hasUnexpected: HasUnexpectedBehaviorMap.NOT_SET, + tabbedBehavior: 0, + behaviors: test.unexpectedBehaviors.map(({content: description, requireExplanation}) => ({ + description, + checked: false, + more: requireExplanation ? {highlightRequired: false, value: ""} : null, + })), + }, + }) + ), + }; +} - const assertionPriority = {}; - for (let a = 0; a < behavior.output_assertions.length; a++) { - const assertion = behavior.output_assertions[a]; - assertionPriority[assertion[1]] = assertion[0]; +const a = bind(element, "a"); +const br = bind(element, "br"); +const button = bind(element, "button"); +const div = bind(element, "div"); +const em = bind(element, "em"); +const fieldset = bind(element, "fieldset"); +const h1 = bind(element, "h1"); +const h2 = bind(element, "h2"); +const h3 = bind(element, "h3"); +const hr = bind(element, "hr"); +const input = bind(element, "input"); +const label = bind(element, "label"); +const legend = bind(element, "legend"); +const li = bind(element, "li"); +const ol = bind(element, "ol"); +const p = bind(element, "p"); +const script = bind(element, "script"); +const section = bind(element, "section"); +const span = bind(element, "span"); +const table = bind(element, "table"); +const td = bind(element, "td"); +const textarea = bind(element, "textarea"); +const th = bind(element, "th"); +const tr = bind(element, "tr"); +const ul = bind(element, "ul"); + +const forInput = bind(attribute, "for"); +const href = bind(attribute, "href"); +const id = bind(attribute, "id"); +const name = bind(attribute, "name"); +const tabIndex = bind(attribute, "tabindex"); +const textContent = bind(attribute, "textContent"); +const type = bind(attribute, "type"); + +const value = bind(property, "value"); +const checked = bind(property, "checked"); +const disabled = bind(property, "disabled"); + +/** @type {(cb: (ev: MouseEvent) => void) => any} */ +const onclick = bind(property, "onclick"); +/** @type {(cb: (ev: InputEvent) => void) => any} */ +const onchange = bind(property, "onchange"); +/** @type {(cb: (ev: KeyboardEvent) => void) => any} */ +const onkeydown = bind(property, "onkeydown"); + +/** + * @param {Description} value + */ +function rich(value) { + if (typeof value === "string") { + return value; + } else if (Array.isArray(value)) { + return fragment(...value.map(rich)); + } else { + if ("whitespace" in value) { + if (value.whitespace === WhitespaceStyleMap.LINE_BREAK) { + return br(); + } + return null; + } + return (value.href ? a.bind(null, href(value.href)) : span)( + className([ + value.offScreen ? "off-screen" : "", + value.required ? "required" : "", + value.highlightRequired ? "highlight-required" : "", + ]), + rich(value.description) + ); } +} - for (let a = 0; a < behavior.additional_assertions.length; a++) { - const assertion = behavior.additional_assertions[a]; - assertionPriority[assertion[1]] = assertion[0]; - } +/** + * @param {TestPageAndResultsDocument} doc + */ +function renderVirtualTestPage(doc) { + return fragment( + "instructions" in doc + ? div( + section( + id("errors"), + style({display: doc.errors ? "block" : "none"}), + h2("Test cannot be performed due to error(s)!"), + ul(...(doc.errors ? doc.errors.map(error => li(error)) : [])), + hr() + ), + section(id("instructions"), renderVirtualInstructionDocument(doc.instructions)), + section(id("record-results")) + ) + : null, + "results" in doc ? renderVirtualResultsTable(doc.results) : null, + doc.resultsJSON + ? script(type("text/json"), id("__ariaatharness__results__"), textContent(JSON.stringify(doc.resultsJSON))) + : null + ); +} - const summary = { - 1: {pass: 0, fail: 0}, - 2: {pass: 0, fail: 0}, - unexpectedCount: 0 - }; +/** + * @param doc {InstructionDocument} + */ +function renderVirtualInstructionDocument(doc) { + function compose(...fns) { + return around => fns.reduceRight((carry, fn) => fn(carry), around); + } - overallStatus = 'PASS'; + const map = (ary, el) => ary.map(item => el(item)); - const commandResults = []; + return div( + instructionHeader(doc.instructions), - for (let c = 0; c < behavior.commands.length; c++) { + instructCommands(doc.instructions.instructions), - let assertions = []; - let support = 'FULL'; - let totalAssertions = document.querySelectorAll(`#cmd-${c} tr`).length - 1; + instructAssertions(doc.instructions.assertions), - for (let a = 0; a < totalAssertions; a++) { - const assertion = document.querySelector(`#assertion-${c}-${a} .assertion`).innerHTML; - const resultEl = document.querySelector(`input[name="result-${c}-${a}"]:checked`); - const resultId = resultEl.id; - const pass = resultEl.classList.contains('pass'); - const result = document.querySelector(`#${resultId}-label`).innerHTML.split('1 assertion fails, then this test meets the all required pass case - else if (support !== 'FAILING') { - support = 'ALL REQUIRED'; - } - } + section(...doc.results.commands.map(commandResult)), - assertions.push(assertionResult); - } + doc.submit ? button(onclick(doc.submit.click), rich(doc.submit.button)) : null + ); - const unexpected = []; - for (let problemEl of document.querySelectorAll(`#cmd-${c}-problem fieldset input:checked`)) { - support = 'FAILING'; - overallStatus = 'FAIL'; - summary.unexpectedCount++; - if (problemEl.value === 'Other') { - unexpected.push(document.querySelector(`#undesirable-${c}-other-input`).value); - } - else { - unexpected.push(problemEl.value); - } - } + /** + * @param {InstructionDocumentResultsHeader} param0 + */ + function resultHeader({header, description}) { + return fragment(h2(rich(header)), p(rich(description))); + } - commandResults.push({ - command: behavior.commands[c], - output: document.querySelector(`#speechoutput-${c}`).value, - unexpected_behaviors: unexpected, - support, - assertions - }); + /** + * @param {InstructionDocumentResultsCommand} command + * @param {number} commandIndex + */ + function commandResult(command, commandIndex) { + return fragment( + h3(rich(command.header)), + p( + label(rich(command.atOutput.description)), + textarea( + value(command.atOutput.value), + focus(command.atOutput.focus), + onchange(ev => command.atOutput.change(/** @type {HTMLInputElement} */ (ev.currentTarget).value)) + ) + ), + table( + tr( + th(rich(command.assertionsHeader.descriptionHeader)), + th(rich(command.assertionsHeader.passHeader)), + th(rich(command.assertionsHeader.failHeader)) + ), + ...command.assertions.map(bind(commandResultAssertion, commandIndex)) + ), + ...[command.unexpectedBehaviors].map(bind(commandResultUnexpectedBehavior, commandIndex)) + ); } - behaviorResults = { - name: document.title, - specific_user_instruction: behavior.specific_user_instruction, - task: behavior.task, - commands: commandResults, - summary - }; + /** + * @param {number} commandIndex + * @param {InstructionDocumentResultsCommandsUnexpected} unexpected + */ + function commandResultUnexpectedBehavior(commandIndex, unexpected) { + return fieldset( + id(`cmd-${commandIndex}-problem`), + rich(unexpected.description), + div(radioChoice(`problem-${commandIndex}-true`, `problem-${commandIndex}`, unexpected.passChoice)), + div(radioChoice(`problem-${commandIndex}-false`, `problem-${commandIndex}`, unexpected.failChoice)), + fieldset( + className(["problem-select"]), + id(`cmd-${commandIndex}-problem-checkboxes`), + legend(rich(unexpected.failChoice.options.header)), + ...unexpected.failChoice.options.options.map(failOption => + fragment( + input( + type("checkbox"), + value(failOption.description), + id(`${failOption.description}-${commandIndex}`), + className([`undesirable-${commandIndex}`]), + tabIndex(failOption.tabbable ? "0" : "-1"), + disabled(!failOption.enabled), + checked(failOption.checked), + focus(failOption.focus), + onchange(ev => failOption.change(/** @type {HTMLInputElement} */ (ev.currentTarget).checked)), + onkeydown(ev => { + if (failOption.keydown(ev.key)) { + ev.stopPropagation(); + ev.preventDefault(); + } + }) + ), + label(forInput(`${failOption.description}-${commandIndex}`), rich(failOption.description)), + br(), + failOption.more + ? div( + label(forInput(`${failOption.description}-${commandIndex}-input`), rich(failOption.more.description)), + input( + type("text"), + id(`${failOption.description}-${commandIndex}-input`), + name(`${failOption.description}-${commandIndex}-input`), + className(["undesirable-other-input"]), + disabled(!failOption.more.enabled), + value(failOption.more.value), + onchange(ev => failOption.more.change(/** @type {HTMLInputElement} */ (ev.currentTarget).value)) + ) + ) + : fragment() + ) + ) + ) + ); + } - let data = { - test: document.title, - details: behaviorResults, - status: overallStatus - }; + /** + * @param {number} commandIndex + * @param {InstructionDocumentResultsCommandsAssertion} assertion + * @param {number} assertionIndex + */ + function commandResultAssertion(commandIndex, assertion, assertionIndex) { + return tr( + td(rich(assertion.description)), + td( + ...[assertion.passChoice].map(choice => + radioChoice(`pass-${commandIndex}-${assertionIndex}`, `result-${commandIndex}-${assertionIndex}`, choice) + ) + ), + td( + ...assertion.failChoices.map((choice, failIndex) => + radioChoice( + `${failIndex === 0 ? "missing" : "fail"}-${commandIndex}-${assertionIndex}`, + `result-${commandIndex}-${assertionIndex}`, + choice + ) + ) + ) + ); + } - // send message to parent if test is loaded in iFrame - if (window.parent && window.parent.postMessage) { - window.parent.postMessage({ - type: 'results', - data: data - }, '*'); + /** + * @param {string} idKey + * @param {string} nameKey + * @param {InstructionDocumentAssertionChoice} choice + */ + function radioChoice(idKey, nameKey, choice) { + return fragment( + input( + type("radio"), + id(idKey), + name(nameKey), + checked(choice.checked), + focus(choice.focus), + onclick(choice.click) + ), + label(id(`${idKey}-label`), forInput(`${idKey}`), rich(choice.label)) + ); } - endTest(); + /** + * @param {InstructionDocumentInstructionsInstructions} param0 + * @returns + */ + function instructCommands({header, instructions, strongInstructions: boldInstructions, commands}) { + return fragment( + h2(rich(header)), + ol( + ...map(instructions, compose(li, rich)), + ...map(boldInstructions, compose(li, em, rich)), + li(rich(commands.description), ul(...map(commands.commands, compose(li, em, rich)))) + ) + ); + } - if (showResults) { - showResultsTable(); + /** + * @param {InstructionDocumentInstructions} param0 + */ + function instructionHeader({header, description}) { + return fragment( + h1(id("behavior-header"), tabIndex("0"), focus(header.focus), rich(header.header)), + p(rich(description)) + ); } - appendJSONResults(data); + /** + * @param {InstructionDocumentInstructionsAssertions} param0 + */ + function instructAssertions({header, description, assertions}) { + return fragment(h2(rich(header)), p(rich(description)), ol(...map(assertions, compose(li, em, rich)))); + } } +/** + * @param {ResultsTableDocument} results + */ +function renderVirtualResultsTable(results) { + return fragment( + h1(rich(results.header)), + h2(id("overallstatus"), rich(results.status.header)), + + table( + (({description, support, details}) => tr(th(description), th(support), th(details)))(results.table.headers), + results.table.commands.map( + ({description, support, details: {output, passingAssertions, failingAssertions, unexpectedBehaviors}}) => + fragment( + tr( + td(rich(description)), + td(rich(support)), + td( + p(rich(output)), + commandDetailsList(passingAssertions), + commandDetailsList(failingAssertions), + commandDetailsList(unexpectedBehaviors) + ) + ) + ) + ) + ) + ); + + /** + * @param {object} list + * @param {Description} list.description + * @param {Description[]} list.items + */ + function commandDetailsList({description, items}) { + return div(description, ul(...items.map(description => li(rich(description))))); + } +} -function showResultsTable() { - let resulthtml = `

    ${document.title}

    `; - - resulthtml += ` - - - - - - `; - - for (let command of behaviorResults.commands) { - - let passingAssertions = ''; - let failingAssertions = ''; - for (let assertion of command.assertions) { - if (assertion.pass) { - passingAssertions += `
  • ${assertion.assertion}
  • `; - } - if (assertion.fail) { - failingAssertions += `
  • ${assertion.assertion}
  • `; - } - } - let unexpectedBehaviors = ''; - for (let unexpected of command.unexpected_behaviors) { - unexpectedBehaviors += `
  • ${unexpected}
  • `; - } - passingAssertions = passingAssertions === '' ? '
  • No passing assertions.
  • ' : passingAssertions; - failingAssertions = failingAssertions === '' ? '
  • No failing assertions.
  • ' : failingAssertions; - unexpectedBehaviors = unexpectedBehaviors === '' ? '
  • No unexpect behaviors.
  • ' : unexpectedBehaviors; - - - resulthtml+= ` - - - - - -`; - - } +/** + * @typedef SubmitResultDetailsCommandsAssertionsPass + * @property {string} assertion + * @property {string} priority + * @property {EnumValues} pass + */ + +const AssertionPassJSONMap = createEnumMap({ + GOOD_OUTPUT: "Good Output", +}); + +/** + * @typedef SubmitResultDetailsCommandsAssertionsFail + * @property {string} assertion + * @property {string} priority + * @property {EnumValues} fail + */ + +const AssertionFailJSONMap = createEnumMap({ + NO_OUTPUT: "No Output", + INCORRECT_OUTPUT: "Incorrect Output", + NO_SUPPORT: "No Support", +}); + +/** @typedef {SubmitResultDetailsCommandsAssertionsPass | SubmitResultDetailsCommandsAssertionsFail} SubmitResultAssertionsJSON */ + +/** @typedef {EnumValues} CommandSupportJSON */ + +const CommandSupportJSONMap = createEnumMap({ + FULL: "FULL", + FAILING: "FAILING", + ALL_REQUIRED: "ALL REQUIRED", +}); + +/** + * @typedef {EnumValues} SubmitResultStatusJSON + */ + +const StatusJSONMap = createEnumMap({ + PASS: "PASS", + FAIL: "FAIL", +}); + +/** + * @param {TestRunState} state + * @param {Behavior} behavior + * @returns {SubmitResultJSON} + */ +function toSubmitResultJSON(state, behavior) { + /** @type {SubmitResultDetailsJSON} */ + const details = { + name: state.info.description, + task: state.info.task, + specific_user_instruction: behavior.specificUserInstruction, + summary: { + 1: { + pass: countAssertions(({priority, result}) => priority === 1 && result === CommonResultMap.PASS), + fail: countAssertions(({priority, result}) => priority === 1 && result !== CommonResultMap.PASS), + }, + 2: { + pass: countAssertions(({priority, result}) => priority === 2 && result === CommonResultMap.PASS), + fail: countAssertions(({priority, result}) => priority === 2 && result !== CommonResultMap.PASS), + }, + unexpectedCount: countUnexpectedBehaviors(({checked}) => checked), + }, + commands: state.commands.map(command => ({ + command: command.description, + output: command.atOutput.value, + support: commandSupport(command), + assertions: [...command.assertions, ...command.additionalAssertions].map(assertionToAssertion), + unexpected_behaviors: command.unexpected.behaviors + .filter(({checked}) => checked) + .map(({description, more}) => (more ? more.value : description)), + })), + }; + /** @type {SubmitResultStatusJSON} */ + const status = state.commands.map(commandSupport).some(support => support === CommandSupportJSONMap.FAILING) + ? StatusJSONMap.FAIL + : StatusJSONMap.PASS; + return { + test: state.info.description, + details, + status, + }; - resulthtml += `
    CommandSupportDetails
    ${command.command}${command.support} -

    ${at.name} output:
    "${command.output.replace(/(?:\r\n|\r|\n)/g, '
    ')}"

    -
    Passing Assertions: -
      - ${passingAssertions} -
    -
    -
    Failing Assertions: -
      - ${failingAssertions} -
    -
    -
    Unexpected Behavior: -
      - ${unexpectedBehaviors} -
    -
    -
    `; + function commandSupport(command) { + const allAssertions = [...command.assertions, ...command.additionalAssertions]; + return allAssertions.some(({priority, result}) => priority === 1 && result !== CommonResultMap.PASS) || + command.unexpected.behaviors.some(({checked}) => checked) + ? CommandSupportJSONMap.FAILING + : allAssertions.some(({priority, result}) => priority === 2 && result !== CommonResultMap.PASS) + ? CommandSupportJSONMap.ALL_REQUIRED + : CommandSupportJSONMap.FULL; + } - document.body.innerHTML = resulthtml; - document.querySelector('#overallstatus').innerHTML = `Test result: ${overallStatus}`; -} + /** + * @param {(assertion: TestRunAssertion | TestRunAdditionalAssertion) => boolean} filter + * @returns {number} + */ + function countAssertions(filter) { + return state.commands.reduce( + (carry, command) => carry + [...command.assertions, ...command.additionalAssertions].filter(filter).length, + 0 + ); + } -function endTest() { - if (typeof testPageWindow !== 'undefined') { - testPageWindow.close(); + /** + * @param {(behavior: TestRunUnexpected) => boolean} filter + * @returns {number} + */ + function countUnexpectedBehaviors(filter) { + return state.commands.reduce((carry, command) => carry + command.unexpected.behaviors.filter(filter).length, 0); } -} -function showUserError() { - if (errors.length) { - document.getElementById('errors').style.display = "block"; - let errorListEl = document.querySelector('#errors ul'); - for (let error of errors) { - let errorMsgEl = document.createElement('li'); - errorMsgEl.innerText = error; - errorListEl.append(errorMsgEl); - } + /** + * @param {TestRunAssertion | TestRunAdditionalAssertion} assertion + * @returns {SubmitResultAssertionsJSON} + */ + function assertionToAssertion(assertion) { + return assertion.result === CommonResultMap.PASS + ? { + assertion: assertion.description, + priority: assertion.priority.toString(), + pass: AssertionPassJSONMap.GOOD_OUTPUT, + } + : { + assertion: assertion.description, + priority: assertion.priority.toString(), + fail: + assertion.result === AssertionResultMap.FAIL_MISSING + ? AssertionFailJSONMap.NO_OUTPUT + : assertion.result === AssertionResultMap.FAIL_INCORRECT + ? AssertionFailJSONMap.INCORRECT_OUTPUT + : AssertionFailJSONMap.NO_SUPPORT, + }; } } -function appendJSONResults(data) { - var results_element = document.createElement("script"); - results_element.type = "text/json"; - results_element.id = "__ariaatharness__results__"; - results_element.textContent = JSON.stringify(data); - - document.body.appendChild(results_element); -} +/** + * @typedef AT + * @property {string} name + * @property {string} key + */ + +/** + * @typedef Support + * @property {AT[]} ats + * @property {{system: string[]}} applies_to + * @property {{directory: string, name: string}[]} examples + */ + +/** + * @typedef {{[mode in ATMode]: {[atName: string]: string;};}} CommandsAPI_ModeInstructions + */ + +/** + * @typedef {([string] | [string, string])[]} CommandAT + */ + +/** + * @typedef {{[atMode: string]: CommandAT}} CommandMode + */ + +/** + * @typedef Command + * @property {CommandMode} [reading] + * @property {CommandMode} [interaction] + */ + +/** + * @typedef {{[commandDescription: string]: Command}} Commands + */ + +/** + * @callback CommandsAPI_getATCommands + * @param {ATMode} mode + * @param {string} task + * @param {AT} assistiveTech + * @returns {string[]} + */ + +/** @typedef {"reading" | "interaction"} ATMode */ + +/** + * @callback CommandsAPI_getModeInstructions + * @param {ATMode} mode + * @param {AT} assistiveTech + * @returns {string} + */ + +/** + * @callback CommandsAPI_isKnownAT + * @param {string} assistiveTech + * @returns {AT} + */ + +/** + * @typedef CommandsAPI + * @property {Commands} AT_COMMAND_MAP + * @property {CommandsAPI_ModeInstructions} MODE_INSTRUCTIONS + * @property {Support} support + * @property {CommandsAPI_getATCommands} getATCommands + * @property {CommandsAPI_getModeInstructions} getModeInstructions + * @property {CommandsAPI_isKnownAT} isKnownAT + */ + +/** + * @typedef BehaviorJSON + * @property {string} setup_script_description + * @property {string} setupTestPage + * @property {string[]} applies_to + * @property {ATMode | ATMode[]} mode + * @property {string} task + * @property {string} specific_user_instruction + * @property {[string, string][]} [output_assertions] + * @property {{[atKey: string]: [number, string][]}} [additional_assertions] + */ + +/** + * @typedef Behavior + * @property {string} description + * @property {string} task + * @property {ATMode} mode + * @property {string} modeInstructions + * @property {string[]} appliesTo + * @property {string} specificUserInstruction + * @property {string} setupScriptDescription + * @property {string} setupTestPage + * @property {string[]} commands + * @property {[string, string][]} outputAssertions + * @property {[number, string][]} additionalAssertions + * @property {object[]} unexpectedBehaviors + * @property {string} unexpectedBehaviors[].content + * @property {boolean} [unexpectedBehaviors[].requireExplanation] + */ + +/** + * @typedef SubmitResultJSON + * @property {string} test + * @property {SubmitResultDetailsJSON} details + * @property {SubmitResultStatusJSON} status + */ + +/** + * @typedef SubmitResultSummaryPriorityJSON + * @property {number} pass + * @property {number} fail + */ + +/** + * @typedef {{[key in "1" | "2"]: SubmitResultSummaryPriorityJSON}} SubmitResultSummaryPriorityMapJSON + */ + +/** + * @typedef SubmitResultSummaryPropsJSON + * @property {number} unexpectedCount + */ + +/** + * @typedef {SubmitResultSummaryPriorityMapJSON & SubmitResultSummaryPropsJSON} SubmitResultSummaryJSON + */ + +/** + * @typedef SubmitResultDetailsJSON + * @property {string} name + * @property {string} specific_user_instruction + * @property {string} task + * @property {object[]} commands + * @property {string} commands[].command + * @property {string} commands[].output + * @property {string[]} commands[].unexpected_behaviors + * @property {CommandSupportJSON} commands[].support + * @property {SubmitResultAssertionsJSON[]} commands[].assertions + * @property {SubmitResultSummaryJSON} summary + */ + +/** + * @typedef TestWindowHooks + * @property {() => void} windowOpened + * @property {() => void} windowClosed + */ + +/** @typedef {{[key: string]: (document: Document) => void}} SetupScripts */ + +/** + * @typedef ResultJSONDocument + * @property {SubmitResultJSON | null} resultsJSON + */ + +/** + * @typedef {TestPageDocument & ResultJSONDocument} TestPageAndResultsDocument + */ + +/** + * @typedef {import('./aria-at-test-run.js').EnumValues} EnumValues + * @template T + */ + +/** @typedef {import('./aria-at-test-run.js').TestRunState} TestRunState */ +/** @typedef {import('./aria-at-test-run.js').TestRunAssertion} TestRunAssertion */ +/** @typedef {import('./aria-at-test-run.js').TestRunAdditionalAssertion} TestRunAdditionalAssertion */ +/** @typedef {import('./aria-at-test-run.js').TestRunCommand} TestRunCommand */ +/** @typedef {import("./aria-at-test-run.js").TestRunUnexpectedBehavior} TestRunUnexpected */ + +/** @typedef {import('./aria-at-test-run.js').Description} Description */ + +/** @typedef {import('./aria-at-test-run.js').TestPageDocument} TestPageDocument */ + +/** @typedef {import('./aria-at-test-run.js').InstructionDocument} InstructionDocument */ +/** @typedef {import('./aria-at-test-run.js').InstructionDocumentInstructions} InstructionDocumentInstructions */ +/** @typedef {import('./aria-at-test-run.js').InstructionDocumentInstructionsAssertions} InstructionDocumentInstructionsAssertions */ +/** @typedef {import('./aria-at-test-run.js').InstructionDocumentResultsHeader} InstructionDocumentResultsHeader */ +/** @typedef {import('./aria-at-test-run.js').InstructionDocumentResultsCommand} InstructionDocumentResultsCommand */ +/** @typedef {import('./aria-at-test-run.js').InstructionDocumentResultsCommandsUnexpected} InstructionDocumentResultsCommandsUnexpected */ +/** @typedef {import("./aria-at-test-run.js").InstructionDocumentResultsCommandsAssertion} InstructionDocumentResultsCommandsAssertion */ +/** @typedef {import("./aria-at-test-run.js").InstructionDocumentAssertionChoice} InstructionDocumentAssertionChoice */ +/** @typedef {import("./aria-at-test-run.js").InstructionDocumentInstructionsInstructions} InstructionDocumentInstructionsInstructions */ + +/** @typedef {import('./aria-at-test-run.js').ResultsTableDocument} ResultsTableDocument */ diff --git a/tests/resources/aria-at-test-run.mjs b/tests/resources/aria-at-test-run.mjs new file mode 100644 index 000000000..44624864d --- /dev/null +++ b/tests/resources/aria-at-test-run.mjs @@ -0,0 +1,1252 @@ +export class TestRun { + /** + * @param {object} param0 + * @param {Partial} [param0.hooks] + * @param {TestRunState} param0.state + */ + constructor({hooks, state}) { + /** @type {TestRunState} */ + this.state = state; + + const bindDispatch = transform => arg => this.dispatch(transform(arg)); + /** @type {TestRunHooks} */ + this.hooks = { + closeTestPage: bindDispatch(userCloseWindow), + focusCommandUnexpectedBehavior: bindDispatch(userFocusCommandUnexpectedBehavior), + openTestPage: bindDispatch(userOpenWindow), + postResults: () => {}, + setCommandAdditionalAssertion: bindDispatch(userChangeCommandAdditionalAssertion), + setCommandAssertion: bindDispatch(userChangeCommandAssertion), + setCommandHasUnexpectedBehavior: bindDispatch(userChangeCommandHasUnexpectedBehavior), + setCommandUnexpectedBehavior: bindDispatch(userChangeCommandUnexpectedBehavior), + setCommandUnexpectedBehaviorMore: bindDispatch(userChangeCommandUnexpectedBehaviorMore), + setCommandOutput: bindDispatch(userChangeCommandOutput), + submit: () => submitResult(this), + ...hooks, + }; + + this.observers = []; + + this.dispatch = this.dispatch.bind(this); + } + + /** + * @param {(state: TestRunState) => TestRunState} updateMethod + */ + dispatch(updateMethod) { + this.state = updateMethod(this.state); + this.observers.forEach(subscriber => subscriber(this)); + } + + /** + * @param {(app: TestRun) => void} subscriber + * @returns {() => void} + */ + observe(subscriber) { + this.observers.push(subscriber); + return () => { + const index = this.observers.indexOf(subscriber); + if (index > -1) { + this.observers.splice(index, 1); + } + }; + } + + testPage() { + return testPageDocument(this.state, this.hooks); + } + + instructions() { + return instructionDocument(this.state, this.hooks); + } + + resultsTable() { + return resultsTableDocument(this.state); + } +} + +/** + * @param {U} map + * @returns {Readonly} + * @template {string} T + * @template {{[key: string]: T}} U + */ +export function createEnumMap(map) { + return Object.freeze(map); +} + +export const WhitespaceStyleMap = createEnumMap({ + LINE_BREAK: "lineBreak", +}); + +function bind(fn, ...args) { + return (...moreArgs) => fn(...args, ...moreArgs); +} + +/** + * @param {TestRunState} resultState + * @param {TestRunHooks} hooks + * @returns {InstructionDocument} + */ +export function instructionDocument(resultState, hooks) { + const mode = resultState.info.mode; + const modeInstructions = resultState.info.modeInstructions; + const userInstructions = resultState.info.userInstructions; + const lastInstruction = userInstructions[userInstructions.length - 1]; + const setupScriptDescription = resultState.info.setupScriptDescription + ? ` and runs a script that ${resultState.info.setupScriptDescription}.` + : resultState.info.setupScriptDescription; + // As a hack, special case mode instructions for VoiceOver for macOS until we + // support modeless tests. ToDo: remove this when resolving issue #194 + const modePhrase = + resultState.config.at.name === "VoiceOver for macOS" + ? "Describe " + : `With ${resultState.config.at.name} in ${mode} mode, describe `; + + const commands = resultState.commands.map(({description}) => description); + const assertions = resultState.commands[0].assertions.map(({description}) => description); + const additionalAssertions = resultState.commands[0].additionalAssertions.map(({description}) => description); + + let firstRequired = true; + function focusFirstRequired() { + if (firstRequired) { + firstRequired = false; + return true; + } + return false; + } + + return { + errors: { + visible: false, + header: "", + errors: [], + }, + instructions: { + header: { + header: `Testing task: ${resultState.info.description}`, + focus: resultState.currentUserAction === UserActionMap.LOAD_PAGE, + }, + description: `${modePhrase} how ${resultState.config.at.name} behaves when performing task "${lastInstruction}"`, + instructions: { + header: "Test instructions", + instructions: [ + [ + `Restore default settings for ${resultState.config.at.name}. For help, read `, + { + href: "https://github.com/w3c/aria-at/wiki/Configuring-Screen-Readers-for-Testing", + description: "Configuring Screen Readers for Testing", + }, + `.`, + ], + `Activate the "Open test page" button below, which opens the example to test in a new window${setupScriptDescription}`, + ], + strongInstructions: [modeInstructions, ...userInstructions], + commands: { + description: `Using the following commands, ${lastInstruction}`, + commands, + }, + }, + assertions: { + header: "Success Criteria", + description: `To pass this test, ${resultState.config.at.name} needs to meet all the following assertions when each specified command is executed:`, + assertions, + }, + openTestPage: { + button: "Open Test Page", + enabled: resultState.openTest.enabled, + click: hooks.openTestPage, + }, + }, + results: { + header: { + header: "Record Results", + description: `${resultState.info.description}`, + }, + commands: commands.map(commandResult), + }, + submit: resultState.config.displaySubmitButton + ? { + button: "Submit Results", + click: hooks.submit, + } + : null, + }; + + /** + * @param {T} resultAssertion + * @param {T["result"]} resultValue + * @param {Omit} partialChoice + * @returns {InstructionDocumentAssertionChoice} + * @template {TestRunAssertion | TestRunAdditionalAssertion} T + */ + function assertionChoice(resultAssertion, resultValue, partialChoice) { + return { + ...partialChoice, + checked: resultAssertion.result === resultValue, + focus: + resultState.currentUserAction === "validateResults" && + resultAssertion.highlightRequired && + focusFirstRequired(), + }; + } + + /** + * @param {string} command + * @param {number} commandIndex + * @returns {InstructionDocumentResultsCommand} + */ + function commandResult(command, commandIndex) { + const resultStateCommand = resultState.commands[commandIndex]; + const resultUnexpectedBehavior = resultStateCommand.unexpected; + return { + header: `After '${command}'`, + atOutput: { + description: [ + `${resultState.config.at.name} output after ${command}`, + { + required: true, + highlightRequired: resultStateCommand.atOutput.highlightRequired, + description: "(required)", + }, + ], + value: resultStateCommand.atOutput.value, + focus: + resultState.currentUserAction === "validateResults" && + resultStateCommand.atOutput.highlightRequired && + focusFirstRequired(), + change: atOutput => hooks.setCommandOutput({commandIndex, atOutput}), + }, + assertionsHeader: { + descriptionHeader: "", + passHeader: "", + failHeader: "", + }, + assertions: [ + ...assertions.map(bind(assertionResult, commandIndex)), + ...additionalAssertions.map(bind(additionalAssertionResult, commandIndex)), + ], + unexpectedBehaviors: { + description: [ + "Were there additional undesirable behaviors?", + { + required: true, + highlightRequired: resultStateCommand.unexpected.highlightRequired, + description: "(required)", + }, + ], + passChoice: { + label: "No, there were no additional undesirable behaviors.", + checked: resultUnexpectedBehavior.hasUnexpected === HasUnexpectedBehaviorMap.DOES_NOT_HAVE_UNEXPECTED, + focus: + resultState.currentUserAction === "validateResults" && + resultUnexpectedBehavior.highlightRequired && + resultUnexpectedBehavior.hasUnexpected === HasUnexpectedBehaviorMap.NOT_SET && + focusFirstRequired(), + click: () => + hooks.setCommandHasUnexpectedBehavior({ + commandIndex, + hasUnexpected: HasUnexpectedBehaviorMap.DOES_NOT_HAVE_UNEXPECTED, + }), + }, + failChoice: { + label: "Yes, there were additional undesirable behaviors", + checked: resultUnexpectedBehavior.hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED, + focus: + resultState.currentUserAction === "validateResults" && + resultUnexpectedBehavior.highlightRequired && + resultUnexpectedBehavior.hasUnexpected === HasUnexpectedBehaviorMap.NOT_SET && + focusFirstRequired(), + click: () => + hooks.setCommandHasUnexpectedBehavior({ + commandIndex, + hasUnexpected: HasUnexpectedBehaviorMap.HAS_UNEXPECTED, + }), + options: { + header: "Undesirable behaviors", + options: resultUnexpectedBehavior.behaviors.map((behavior, unexpectedIndex) => { + return { + description: behavior.description, + enabled: resultUnexpectedBehavior.hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED, + tabbable: resultUnexpectedBehavior.tabbedBehavior === unexpectedIndex, + checked: behavior.checked, + focus: + typeof resultState.currentUserAction === "object" && + resultState.currentUserAction.action === UserObjectActionMap.FOCUS_UNDESIRABLE + ? resultState.currentUserAction.commandIndex === commandIndex && + resultUnexpectedBehavior.tabbedBehavior === unexpectedIndex + : resultState.currentUserAction === UserActionMap.VALIDATE_RESULTS && + resultUnexpectedBehavior.hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED && + resultUnexpectedBehavior.behaviors.every(({checked}) => !checked) && + focusFirstRequired(), + change: checked => hooks.setCommandUnexpectedBehavior({commandIndex, unexpectedIndex, checked}), + keydown: key => { + const increment = keyToFocusIncrement(key); + if (increment) { + hooks.focusCommandUnexpectedBehavior({commandIndex, unexpectedIndex, increment}); + return true; + } + return false; + }, + more: behavior.more + ? { + description: /** @type {Description[]} */ ([ + `If "other" selected, explain`, + { + required: true, + highlightRequired: behavior.more.highlightRequired, + description: "(required)", + }, + ]), + enabled: behavior.checked, + value: behavior.more.value, + focus: + resultState.currentUserAction === "validateResults" && + behavior.more.highlightRequired && + focusFirstRequired(), + change: value => + hooks.setCommandUnexpectedBehaviorMore({commandIndex, unexpectedIndex, more: value}), + } + : null, + }; + }), + }, + }, + }, + }; + } + + /** + * @param {number} commandIndex + * @param {string} assertion + * @param {number} assertionIndex + */ + function assertionResult(commandIndex, assertion, assertionIndex) { + const resultAssertion = resultState.commands[commandIndex].assertions[assertionIndex]; + return /** @type {InstructionDocumentResultsCommandsAssertion} */ ({ + description: [ + assertion, + { + required: true, + highlightRequired: resultAssertion.highlightRequired, + description: "(required: mark output)", + }, + ], + passChoice: assertionChoice(resultAssertion, CommonResultMap.PASS, { + label: [ + `Good Output `, + { + offScreen: true, + description: "for assertion", + }, + ], + click: () => hooks.setCommandAssertion({commandIndex, assertionIndex, result: CommonResultMap.PASS}), + }), + failChoices: [ + assertionChoice(resultAssertion, AssertionResultMap.FAIL_MISSING, { + label: [ + `No Output `, + { + offScreen: true, + description: "for assertion", + }, + ], + click: () => + hooks.setCommandAssertion({commandIndex, assertionIndex, result: AssertionResultMap.FAIL_MISSING}), + }), + assertionChoice(resultAssertion, AssertionResultMap.FAIL_INCORRECT, { + label: [ + `Incorrect Output `, + { + offScreen: true, + description: "for assertion", + }, + ], + click: () => + hooks.setCommandAssertion({commandIndex, assertionIndex, result: AssertionResultMap.FAIL_MISSING}), + }), + ], + }); + } + + /** + * @param {number} commandIndex + * @param {string} assertion + * @param {number} assertionIndex + */ + function additionalAssertionResult(commandIndex, assertion, assertionIndex) { + const resultAdditionalAssertion = resultState.commands[commandIndex].additionalAssertions[assertionIndex]; + return /** @type {InstructionDocumentResultsCommandsAssertion} */ ({ + description: [ + assertion, + { + required: true, + highlightRequired: resultAdditionalAssertion.highlightRequired, + description: "(required: mark support)", + }, + ], + passChoice: assertionChoice(resultAdditionalAssertion, AdditionalAssertionResultMap.PASS, { + label: ["Good Support ", {offScreen: true, description: "for assertion"}], + click: () => + hooks.setCommandAdditionalAssertion({ + commandIndex, + additionalAssertionIndex: assertionIndex, + result: AdditionalAssertionResultMap.PASS, + }), + }), + failChoices: [ + assertionChoice(resultAdditionalAssertion, AdditionalAssertionResultMap.FAIL_SUPPORT, { + label: ["No Support ", {offScreen: true, description: "for assertion"}], + click: () => + hooks.setCommandAdditionalAssertion({ + commandIndex, + additionalAssertionIndex: assertionIndex, + result: AdditionalAssertionResultMap.FAIL_SUPPORT, + }), + }), + ], + }); + } +} + +/** + * @typedef {typeof UserActionMap[keyof typeof UserActionMap]} UserAction + */ + +export const UserActionMap = createEnumMap({ + LOAD_PAGE: "loadPage", + OPEN_TEST_WINDOW: "openTestWindow", + CLOSE_TEST_WINDOW: "closeTestWindow", + VALIDATE_RESULTS: "validateResults", + CHANGE_TEXT: "changeText", + CHANGE_SELECTION: "changeSelection", + SHOW_RESULTS: "showResults", +}); + +/** + * @typedef {typeof UserObjectActionMap[keyof typeof UserObjectActionMap]} UserObjectAction + */ + +export const UserObjectActionMap = createEnumMap({ + FOCUS_UNDESIRABLE: "focusUndesirable", +}); + +/** + * @typedef {UserAction | UserActionFocusUnexpected} TestRunUserAction + */ + +/** + * @typedef {EnumValues} HasUnexpectedBehavior + */ + +export const HasUnexpectedBehaviorMap = createEnumMap({ + NOT_SET: "notSet", + HAS_UNEXPECTED: "hasUnexpected", + DOES_NOT_HAVE_UNEXPECTED: "doesNotHaveUnexpected", +}); + +export const CommonResultMap = createEnumMap({ + NOT_SET: "notSet", + PASS: "pass", +}); + +/** + * @typedef {EnumValues} AdditionalAssertionResult + */ + +export const AdditionalAssertionResultMap = createEnumMap({ + ...CommonResultMap, + FAIL_SUPPORT: "failSupport", +}); + +/** + * @typedef {EnumValues} AssertionResult + */ + +export const AssertionResultMap = createEnumMap({ + ...CommonResultMap, + FAIL_MISSING: "failMissing", + FAIL_INCORRECT: "failIncorrect", +}); + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {string} props.atOutput + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandOutput({commandIndex, atOutput}) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_TEXT, + commands: state.commands.map((commandState, index) => + index !== commandIndex + ? commandState + : { + ...commandState, + atOutput: { + ...commandState.atOutput, + value: atOutput, + }, + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.assertionIndex + * @param {AssertionResult} props.result + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandAssertion({commandIndex, assertionIndex, result}) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_SELECTION, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : { + ...command, + assertions: command.assertions.map((assertion, assertionI) => + assertionI !== assertionIndex ? assertion : {...assertion, result} + ), + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.additionalAssertionIndex + * @param {AdditionalAssertionResult} props.result + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandAdditionalAssertion({commandIndex, additionalAssertionIndex, result}) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_SELECTION, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : { + ...command, + additionalAssertions: command.additionalAssertions.map((assertion, assertionI) => + assertionI !== additionalAssertionIndex ? assertion : {...assertion, result} + ), + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {HasUnexpectedBehavior} props.hasUnexpected + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandHasUnexpectedBehavior({commandIndex, hasUnexpected}) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_SELECTION, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : { + ...command, + unexpected: { + ...command.unexpected, + hasUnexpected: hasUnexpected, + tabbedBehavior: hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED ? 0 : -1, + behaviors: command.unexpected.behaviors.map(behavior => ({ + ...behavior, + checked: false, + more: behavior.more ? {...behavior.more, value: ""} : null, + })), + }, + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.unexpectedIndex + * @param {boolean} props.checked + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandUnexpectedBehavior({commandIndex, unexpectedIndex, checked}) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_SELECTION, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : { + ...command, + unexpected: { + ...command.unexpected, + behaviors: command.unexpected.behaviors.map((unexpected, unexpectedI) => + unexpectedI !== unexpectedIndex + ? unexpected + : { + ...unexpected, + checked, + } + ), + }, + } + ), + }; + }; +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.unexpectedIndex + * @param {string} props.more + * @returns {(state: TestRunState) => TestRunState} + */ +export function userChangeCommandUnexpectedBehaviorMore({commandIndex, unexpectedIndex, more}) { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.CHANGE_TEXT, + commands: state.commands.map((command, commandI) => + commandI !== commandIndex + ? command + : /** @type {TestRunCommand} */ ({ + ...command, + unexpected: { + ...command.unexpected, + behaviors: command.unexpected.behaviors.map((unexpected, unexpectedI) => + unexpectedI !== unexpectedIndex + ? unexpected + : /** @type {TestRunUnexpectedBehavior} */ ({ + ...unexpected, + more: { + ...unexpected.more, + value: more, + }, + }) + ), + }, + }) + ), + }; + }; +} + +/** + * @param {string} key + * @returns {TestRunFocusIncrement} + */ +function keyToFocusIncrement(key) { + switch (key) { + case "Up": + case "ArrowUp": + case "Left": + case "ArrowLeft": + return "previous"; + + case "Down": + case "ArrowDown": + case "Right": + case "ArrowRight": + return "next"; + } +} + +/** + * @param {TestRunState} state + * @param {TestRunHooks} hooks + * @returns {TestPageDocument} + */ +function testPageDocument(state, hooks) { + if (state.currentUserAction === UserActionMap.SHOW_RESULTS) { + return { + results: resultsTableDocument(state), + }; + } + return { + errors: null, + instructions: instructionDocument(state, hooks), + }; +} + +/** + * @param {TestRun} app + */ +function submitResult(app) { + app.dispatch(userValidateState()); + + if (isSomeFieldRequired(app.state)) { + return; + } + + app.hooks.postResults(); + + app.hooks.closeTestPage(); + + if (app.state.config.renderResultsAfterSubmit) { + app.dispatch(userShowResults()); + } +} + +export function userShowResults() { + return function (/** @type {TestRunState} */ state) { + return /** @type {TestRunState} */ ({...state, currentUserAction: UserActionMap.SHOW_RESULTS}); + }; +} + +/** + * @param {TestRunState} state + * @returns + */ +function isSomeFieldRequired(state) { + return state.commands.some( + command => + command.atOutput.value.trim() === "" || + command.assertions.some(assertion => assertion.result === CommonResultMap.NOT_SET) || + command.additionalAssertions.some(assertion => assertion.result === CommonResultMap.NOT_SET) || + command.unexpected.hasUnexpected === HasUnexpectedBehaviorMap.NOT_SET || + (command.unexpected.hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED && + (command.unexpected.behaviors.every(({checked}) => !checked) || + command.unexpected.behaviors.some( + behavior => behavior.checked && behavior.more && behavior.more.value.trim() === "" + ))) + ); +} + +/** + * @param {TestRunState} state + * @returns {ResultsTableDocument} + */ +function resultsTableDocument(state) { + return { + header: state.info.description, + status: { + header: [ + "Test result: ", + state.commands.some( + ({assertions, additionalAssertions, unexpected}) => + [...assertions, ...additionalAssertions].some( + ({priority, result}) => priority === 1 && result !== CommonResultMap.PASS + ) || unexpected.behaviors.some(({checked}) => checked) + ) + ? "FAIL" + : "PASS", + ], + }, + table: { + headers: { + description: "Command", + support: "Support", + details: "Details", + }, + commands: state.commands.map(command => { + const allAssertions = [...command.assertions, ...command.additionalAssertions]; + + let passingAssertions = ["No passing assertions."]; + let failingAssertions = ["No failing assertions."]; + let unexpectedBehaviors = ["No unexpect behaviors."]; + + if (allAssertions.some(({result}) => result === CommonResultMap.PASS)) { + passingAssertions = allAssertions + .filter(({result}) => result === CommonResultMap.PASS) + .map(({description}) => description); + } + if (allAssertions.some(({result}) => result !== CommonResultMap.PASS)) { + failingAssertions = allAssertions + .filter(({result}) => result !== CommonResultMap.PASS) + .map(({description}) => description); + } + if (command.unexpected.behaviors.some(({checked}) => checked)) { + unexpectedBehaviors = command.unexpected.behaviors + .filter(({checked}) => checked) + .map(({description, more}) => (more ? more.value : description)); + } + + return { + description: command.description, + support: + allAssertions.some(({priority, result}) => priority === 1 && result !== CommonResultMap.PASS) || + command.unexpected.behaviors.some(({checked}) => checked) + ? "FAILING" + : allAssertions.some(({priority, result}) => priority === 2 && result !== CommonResultMap.PASS) + ? "ALL_REQUIRED" + : "FULL", + details: { + output: /** @type {Description} */ [ + "output:", + /** @type {DescriptionWhitespace} */ ({whitespace: WhitespaceStyleMap.LINE_BREAK}), + " ", + ...command.atOutput.value + .split(/(\r\n|\r|\n)/g) + .map(output => + /\r\n|\r|\n/.test(output) + ? /** @type {DescriptionWhitespace} */ ({whitespace: WhitespaceStyleMap.LINE_BREAK}) + : output + ), + ], + passingAssertions: { + description: "Passing Assertions:", + items: passingAssertions, + }, + failingAssertions: { + description: "Failing Assertions:", + items: failingAssertions, + }, + unexpectedBehaviors: { + description: "Unexpected Behavior", + items: unexpectedBehaviors, + }, + }, + }; + }), + }, + }; +} + +export function userOpenWindow() { + return (/** @type {TestRunState} */ state) => + /** @type {TestRunState} */ ({ + ...state, + currentUserAction: UserActionMap.OPEN_TEST_WINDOW, + openTest: {...state.openTest, enabled: false}, + }); +} + +export function userCloseWindow() { + return (/** @type {TestRunState} */ state) => + /** @type {TestRunState} */ ({ + ...state, + currentUserAction: UserActionMap.CLOSE_TEST_WINDOW, + openTest: {...state.openTest, enabled: true}, + }); +} + +/** + * @param {object} props + * @param {number} props.commandIndex + * @param {number} props.unexpectedIndex + * @param {TestRunFocusIncrement} props.increment + * @returns {(state: TestRunState) => TestRunState} + */ +export function userFocusCommandUnexpectedBehavior({commandIndex, unexpectedIndex, increment}) { + return function (state) { + const unexpectedLength = state.commands[commandIndex].unexpected.behaviors.length; + const incrementValue = increment === "next" ? 1 : -1; + const newUnexpectedIndex = (unexpectedIndex + incrementValue + unexpectedLength) % unexpectedLength; + + return { + ...state, + currentUserAction: { + action: UserObjectActionMap.FOCUS_UNDESIRABLE, + commandIndex, + unexpectedIndex: newUnexpectedIndex, + }, + commands: state.commands.map((command, commandI) => { + const tabbed = command.unexpected.tabbedBehavior; + const unexpectedLength = command.unexpected.behaviors.length; + const newTabbed = (tabbed + (increment === "next" ? 1 : -1) + unexpectedLength) % unexpectedLength; + return commandI !== commandIndex + ? command + : { + ...command, + unexpected: { + ...command.unexpected, + tabbedBehavior: newTabbed, + }, + }; + }), + }; + }; +} + +/** + * @returns {(state: TestRunState) => TestRunState} + */ +export function userValidateState() { + return function (state) { + return { + ...state, + currentUserAction: UserActionMap.VALIDATE_RESULTS, + commands: state.commands.map(command => { + return { + ...command, + atOutput: { + ...command.atOutput, + highlightRequired: !command.atOutput.value.trim(), + }, + assertions: command.assertions.map(assertion => ({ + ...assertion, + highlightRequired: assertion.result === CommonResultMap.NOT_SET, + })), + additionalAssertions: command.additionalAssertions.map(assertion => ({ + ...assertion, + highlightRequired: assertion.result === CommonResultMap.NOT_SET, + })), + unexpected: { + ...command.unexpected, + highlightRequired: + command.unexpected.hasUnexpected === HasUnexpectedBehaviorMap.NOT_SET || + (command.unexpected.hasUnexpected === HasUnexpectedBehaviorMap.HAS_UNEXPECTED && + command.unexpected.behaviors.every(({checked}) => !checked)), + behaviors: command.unexpected.behaviors.map(unexpected => { + return unexpected.more + ? { + ...unexpected, + more: { + ...unexpected.more, + highlightRequired: unexpected.checked && !unexpected.more.value.trim(), + }, + } + : unexpected; + }), + }, + }; + }), + }; + }; +} + +/** + * @typedef AT + * @property {string} name + * @property {string} key + */ + +/** + * @typedef Behavior + * @property {string} description + * @property {string} task + * @property {string} mode + * @property {string} modeInstructions + * @property {string[]} appliesTo + * @property {string} specificUserInstruction + * @property {string} setupScriptDescription + * @property {string} setupTestPage + * @property {string[]} commands + * @property {[string, string][]} outputAssertions + * @property {[number, string][]} additionalAssertions + */ + +/** + * @typedef {"previous" | "next"} TestRunFocusIncrement + */ + +/** + * @typedef {(action: (state: TestRunState) => TestRunState) => void} Dispatcher + */ + +/** + * @typedef InstructionDocumentButton + * @property {Description} button + * @property {boolean} [enabled] + * @property {() => void} click + */ + +/** + * @typedef InstructionDocumentAssertionChoiceOptionsOptionsMore + * @property {Description} description + * @property {string} value + * @property {boolean} enabled + * @property {boolean} [focus] + * @property {(value: string) => void} change + */ + +/** + * @typedef InstructionDocumentAssertionChoiceOptionsOption + * @property {Description} description + * @property {boolean} checked + * @property {boolean} enabled + * @property {boolean} tabbable + * @property {boolean} [focus] + * @property {(checked: boolean) => void} change + * @property {(key: string) => boolean} keydown + * @property {InstructionDocumentAssertionChoiceOptionsOptionsMore} [more] + */ + +/** + * @typedef InstructionDocumentAssertionChoiceOptions + * @property {Description} header + * @property {InstructionDocumentAssertionChoiceOptionsOption[]} options + */ + +/** + * @typedef InstructionDocumentAssertionChoice + * @property {Description} label + * @property {boolean} checked + * @property {boolean} [focus] + * @property {() => void} click + * @property {InstructionDocumentAssertionChoiceOptions} [options] + */ + +/** + * @typedef DescriptionRich + * @property {string} [href] + * @property {boolean} [required] + * @property {boolean} [highlightRequired] + * @property {boolean} [offScreen] + * @property {Description} description + */ + +/** + * @typedef DescriptionWhitespace + * @property {typeof WhitespaceStyleMap["LINE_BREAK"]} whitespace + */ + +/** @typedef {string | DescriptionRich | DescriptionWhitespace | DescriptionArray} Description */ + +/** @typedef {Description[]} DescriptionArray */ + +/** + * @typedef InstructionDocumentResultsCommandsAssertion + * @property {Description} description + * @property {InstructionDocumentAssertionChoice} passChoice + * @property {InstructionDocumentAssertionChoice[]} failChoices + */ + +/** + * @typedef InstructionDocumentResultsCommandsAssertionsHeader + * @property {Description} descriptionHeader + * @property {Description} passHeader + * @property {Description} failHeader + */ + +/** + * @typedef InstructionDocumentResultsCommandsATOutput + * @property {Description} description + * @property {string} value + * @property {boolean} focus + * @property {(value: string) => void} change + */ + +/** + * @typedef InstructionDocumentResultsCommandsUnexpected + * @property {Description} description + * @property {InstructionDocumentAssertionChoice} passChoice + * @property {InstructionDocumentAssertionChoice} failChoice + */ + +/** + * @typedef InstructionDocumentResultsCommand + * @property {Description} header + * @property {InstructionDocumentResultsCommandsATOutput} atOutput + * @property {InstructionDocumentResultsCommandsAssertionsHeader} assertionsHeader + * @property {InstructionDocumentResultsCommandsAssertion[]} assertions + * @property {InstructionDocumentResultsCommandsUnexpected} unexpectedBehaviors + */ + +/** + * @typedef InstructionDocumentResultsHeader + * @property {Description} header + * @property {Description} description + */ + +/** + * @typedef InstructionDocumentResults + * @property {InstructionDocumentResultsHeader} header + * @property {InstructionDocumentResultsCommand[]} commands + */ + +/** + * @typedef InstructionDocumentInstructionsInstructionsCommands + * @property {Description} description + * @property {Description[]} commands + */ + +/** + * @typedef InstructionDocumentInstructionsInstructions + * @property {Description} header + * @property {Description[]} instructions + * @property {Description[]} strongInstructions + * @property {InstructionDocumentInstructionsInstructionsCommands} commands + */ + +/** + * @typedef InstructionDocumentErrors + * @property {boolean} visible + * @property {Description} header + * @property {Description[]} errors + */ + +/** + * @typedef InstructionDocumentInstructionsHeader + * @property {Description} header + * @property {boolean} focus + */ + +/** + * @typedef InstructionDocumentInstructionsAssertions + * @property {Description} header + * @property {Description} description + * @property {Description[]} assertions + */ + +/** + * @typedef InstructionDocumentInstructions + * @property {InstructionDocumentInstructionsHeader} header + * @property {Description} description + * @property {InstructionDocumentInstructionsInstructions} instructions + * @property {InstructionDocumentInstructionsAssertions} assertions + * @property {InstructionDocumentButton} openTestPage + */ + +/** + * @typedef InstructionDocument + * @property {InstructionDocumentErrors} errors + * @property {InstructionDocumentInstructions} instructions + * @property {InstructionDocumentResults} results + * @property {InstructionDocumentButton} submit + */ + +/** + * @typedef TestRunHooks + * @property {() => void} closeTestPage + * @property {(options: {commandIndex: number, unexpectedIndex: number, increment: TestRunFocusIncrement}) => void} focusCommandUnexpectedBehavior + * @property {() => void} openTestPage + * @property {() => void} postResults + * @property {(options: {commandIndex: number, additionalAssertionIndex: number, result: AdditionalAssertionResult}) => void} setCommandAdditionalAssertion + * @property {(options: {commandIndex: number, assertionIndex: number, result: AssertionResult}) => void} setCommandAssertion + * @property {(options: {commandIndex: number, hasUnexpected: HasUnexpectedBehavior}) => void } setCommandHasUnexpectedBehavior + * @property {(options: {commandIndex: number, atOutput: string}) => void} setCommandOutput + * @property {(options: {commandIndex: number, unexpectedIndex: number, checked}) => void } setCommandUnexpectedBehavior + * @property {(options: {commandIndex: number, unexpectedIndex: number, more: string}) => void } setCommandUnexpectedBehaviorMore + * @property {() => void} submit + */ + +/** + * @typedef UserActionFocusUnexpected + * @property {typeof UserObjectActionMap["FOCUS_UNDESIRABLE"]} action + * @property {number} commandIndex + * @property {number} unexpectedIndex + */ + +/** + * @typedef {T[keyof T]} EnumValues + * @template {{[key: string]: string}} T + */ + +/** + * @typedef TestRunAssertion + * @property {string} description + * @property {boolean} highlightRequired + * @property {number} priority + * @property {AssertionResult} result + */ + +/** + * @typedef TestRunAdditionalAssertion + * @property {string} description + * @property {boolean} highlightRequired + * @property {number} priority + * @property {AdditionalAssertionResult} result + */ + +/** + * @typedef TestRunUnexpectedBehavior + * @property {string} description + * @property {boolean} checked + * @property {object} [more] + * @property {boolean} more.highlightRequired + * @property {string} more.value + */ + +/** + * @typedef TestRunUnexpectedGroup + * @property {boolean} highlightRequired + * @property {HasUnexpectedBehavior} hasUnexpected + * @property {number} tabbedBehavior + * @property {TestRunUnexpectedBehavior[]} behaviors + */ + +/** + * @typedef TestRunCommand + * @property {string} description + * @property {object} atOutput + * @property {boolean} atOutput.highlightRequired + * @property {string} atOutput.value + * @property {TestRunAssertion[]} assertions + * @property {TestRunAdditionalAssertion[]} additionalAssertions + * @property {TestRunUnexpectedGroup} unexpected + */ + +/** + * @typedef TestRunState + * This state contains all the serializable values that are needed to render any + * of the documents (InstructionDocument, ResultsTableDocument, and + * TestPageDocuement) from this module. + * + * @property {string[] | null} errors + * @property {object} info + * @property {string} info.description + * @property {string} info.task + * @property {ATMode} info.mode + * @property {string} info.modeInstructions + * @property {string[]} info.userInstructions + * @property {string} info.setupScriptDescription + * @property {object} config + * @property {AT} config.at + * @property {boolean} config.renderResultsAfterSubmit + * @property {boolean} config.displaySubmitButton + * @property {TestRunUserAction} currentUserAction + * @property {TestRunCommand[]} commands + * @property {object} openTest + * @property {boolean} openTest.enabled + */ + +/** + * @typedef ResultsTableDetailsList + * @property {Description} description + * @property {Description[]} items + */ + +/** + * @typedef ResultsTableDocument + * @property {string} header + * @property {object} status + * @property {Description} status.header + * @property {object} table + * @property {object} table.headers + * @property {string} table.headers.description + * @property {string} table.headers.support + * @property {string} table.headers.details + * @property {object[]} table.commands + * @property {string} table.commands[].description + * @property {Description} table.commands[].support + * @property {object} table.commands[].details + * @property {Description} table.commands[].details.output + * @property {ResultsTableDetailsList} table.commands[].details.passingAssertions + * @property {ResultsTableDetailsList} table.commands[].details.failingAssertions + * @property {ResultsTableDetailsList} table.commands[].details.unexpectedBehaviors + */ + +/** + * @typedef TestPageDocumentResults + * @property {ResultsTableDocument} results + */ + +/** + * @typedef TestPageDocumentInstructions + * @property {string[] | null} errors + * @property {InstructionDocument} instructions + */ + +/** @typedef {TestPageDocumentInstructions | TestPageDocumentResults} TestPageDocument */ + +/** @typedef {"reading" | "interaction"} ATMode */ diff --git a/tests/resources/vrender.mjs b/tests/resources/vrender.mjs new file mode 100644 index 000000000..270c44983 --- /dev/null +++ b/tests/resources/vrender.mjs @@ -0,0 +1,980 @@ +const mounts = new WeakMap(); + +/** + * @param {HTMLElement} mount + * @param {NodeNode} newValue + */ +export function render(mount, newValue) { + let lastValue = mounts.get(mount); + if (!lastValue) { + lastValue = ElementType.init(newQueue(), null, null, element(mount.tagName)); + lastValue.ref = mount; + mounts.set(mount, lastValue); + } + const queue = newQueue(); + lastValue.type.diff(queue, lastValue, element(mount.tagName, newValue)); + runQueue(queue); + if (!newValue) { + mounts.delete(mount); + } +} + +/** + * @param {string} shape + * @param {...NodeNode} content + * @returns {ElementNode} + */ +export function element(shape, ...content) { + return { + type: ELEMENT_TYPE_NAME, + shape, + content: content.map(asNode), + }; +} + +/** + * @param {...NodeNode} content + * @returns {FragmentNode} + */ +export function fragment(...content) { + return { + type: FRAGMENT_TYPE_NAME, + shape: FRAGMENT_TYPE_NAME, + content: content.map(asNode), + }; +} + +/** + * @param {string} content + * @returns {TextNode} + */ +export function text(content) { + return { + type: TEXT_TYPE_NAME, + shape: TEXT_TYPE_NAME, + content, + }; +} + +/** + * @param {function} shape + * @param {...NodeNode} content + * @returns {ComponentNode} + */ +export function component(shape, ...content) { + return { + type: COMPONENT_TYPE_NAME, + shape, + content, + }; +} + +/** + * @param {{[key: string]: string}} styleMap + * @returns {MemberNode} + */ +export function style(styleMap) { + return attribute( + "style", + Object.keys(styleMap) + .map(key => `${key}: ${styleMap[key]};`) + .join(" ") + ); +} + +/** + * @param {string[]} names + * @returns {MemberNode} + */ +export function className(names) { + return attribute("class", names.filter(Boolean).join(" ")); +} + +/** + * @param {string} name + * @param {string | boolean} value + * @returns {MemberNode} + */ +export function attribute(name, value) { + return { + type: ATTRIBUTE_TYPE_NAME, + name, + value, + }; +} + +/** + * @param {string} name + * @param {any} value + * @returns {MemberNode} + */ +export function property(name, value) { + return { + type: PROPERTY_TYPE_NAME, + name, + value, + }; +} + +/** + * @param {string} name + * @param {any} value + * @returns {MemberNode} + */ +export function meta(name, value) { + return { + type: META_TYPE_NAME, + name, + value, + }; +} + +const refMap = new WeakMap(); + +/** + * @param {{ref: HTMLElement | null}} value + * @returns {MemberNode} + */ +export function ref(value) { + let refHook = refMap.get(value); + if (!refHook) { + refHook = (/** @type {HTMLElement} */ element) => { + value.ref = element; + }; + refMap.set(value, refHook); + } + return { + type: REF_TYPE_NAME, + name: "ref", + value: refHook, + }; +} + +const noop = function () {}; + +/** + * @param {boolean} shouldFocus + */ +export function focus(shouldFocus) { + return { + type: REF_TYPE_NAME, + name: "focus", + value: shouldFocus ? element => element.focus() : noop, + }; +} + +function asNode(item) { + if (typeof item === "string") { + return text(item); + } else if (Array.isArray(item)) { + return fragment(...item); + } else if (item === null || item === undefined) { + return fragment(); + } + return item; +} + +const ELEMENT_TYPE_NAME = "element"; +const FRAGMENT_TYPE_NAME = "fragment"; +const COMPONENT_TYPE_NAME = "component"; +const TEXT_TYPE_NAME = "text"; +const ATTRIBUTE_TYPE_NAME = "attribute"; +const PROPERTY_TYPE_NAME = "property"; +const REF_TYPE_NAME = "ref"; +const META_TYPE_NAME = "meta"; + +/** @type ElementStateType */ +const ElementType = { + name: ELEMENT_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffChildEntry), + init: /** @type {InitNodeFunction} */ ( + function (queue, parent, after, /** @type {ElementNode} */ node) { + const state = { + type: ElementType, + parent, + after, + shape: node.shape, + content: null, + ref: null, + refHooks: null, + rewriteChildIndex: 0, + children: null, + rewriteMemberIndex: 0, + members: null, + }; + enqueueChange(queue, addElement, state); + return state; + } + ), + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {ElementState} */ lastValue, /** @type {ElementNode} */ newValue) { + lastValue.rewriteMemberIndex = 0; + diffFragment(queue, lastValue, newValue); + if (lastValue.members !== null) { + const group = lastValue.members; + let index; + for (index = lastValue.rewriteMemberIndex; index < group.length; index++) { + const node = group[index]; + node.type.teardown(queue, node); + } + if (lastValue.rewriteMemberIndex === 0) { + lastValue.members = null; + } else { + group.length = lastValue.rewriteMemberIndex; + } + } + } + ), + teardown: /** @type {TeardownFunction} */ ( + function (queue, /** @type {ElementState} */ state) { + enqueueChange(queue, removeElement, state); + const {children} = state; + if (children !== null) { + for (let i = 0; i < children.length; i++) { + children[i].type.softTeardown(children[i]); + } + } + } + ), + softTeardown: /** @type {SoftTeardownFunction} */ (softTeardownElement), +}; + +/** @type {FragmentStateType} */ +const FragmentType = { + name: FRAGMENT_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffChildEntry), + init(queue, parent, after, node) { + return { + type: FragmentType, + parent, + after, + shape: FRAGMENT_TYPE_NAME, + content: null, + rewriteChildIndex: 0, + children: null, + }; + }, + diff: /** @type {DiffFunction} */ (diffFragment), + teardown: /** @type {TeardownFunction} */ ( + function (queue, /** @type {FragmentState} */ state) { + const {children} = state; + if (children !== null) { + for (let i = 0; i < children.length; i++) { + children[i].type.teardown(queue, children[i]); + } + } + } + ), + softTeardown: /** @type {SoftTeardownFunction} */ (softTeardownElement), +}; + +/** @type ComponentStateType */ +const ComponentType = { + name: COMPONENT_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffChildEntry), + init: /** @type {InitNodeFunction} */ ( + function (queue, parent, after, /** @type {ComponentNode} */ node) { + return { + type: ComponentType, + parent, + after, + shape: node.shape, + content: null, + rendered: null, + }; + } + ), + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {ComponentState} */ lastChild, /** @type {ComponentNode} */ node) { + /** @type {MemberNode} */ + const componentOptionsMeta = node.content.find(isOptions) || null; + const componentOptions = componentOptionsMeta ? componentOptionsMeta.value : null; + if (shallowEquals(lastChild.content, componentOptions) === false) { + lastChild.content = componentOptions; + queue.prepare.push(lastChild); + } + } + ), + teardown: /** @type {TeardownFunction} */ ( + function (queue, /** @type {ComponentState} */ state) { + if (state.rendered !== null) { + state.rendered.type.teardown(queue, state.rendered); + } + } + ), + softTeardown: /** @type {SoftTeardownFunction} */ ( + function (/** @type {ComponentState} */ state) { + if (state.rendered !== null) { + state.rendered.type.softTeardown(state.rendered); + } + } + ), +}; + +/** @type TextStateType */ +const TextType = { + name: TEXT_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffChildEntry), + init: /** @type {InitNodeFunction} */ ( + function (queue, parent, after, /** @type {TextNode} */ node) { + /** @type {TextState} */ + const state = { + type: TextType, + parent, + after, + shape: TEXT_TYPE_NAME, + content: node.content, + ref: null, + }; + enqueueChange(queue, addText, state); + return state; + } + ), + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {TextState} */ lastChild, /** @type {TextNode} */ node) { + if (lastChild.content !== node.content) { + lastChild.content = node.content; + enqueueChange(queue, changeText, lastChild); + } + } + ), + teardown: /** @type {TeardownFunction} */ ( + function (queue, /** @type {TextState} */ state) { + enqueueChange(queue, removeText, state); + } + ), + softTeardown() {}, +}; + +/** @type {MemberStateType} */ +const AttributeType = { + name: ATTRIBUTE_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffMemberEntry), + init(parent, name) { + return { + type: AttributeType, + parent, + name, + value: null, + }; + }, + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {MemberState} */ old, /** @type {MemberNode} */ memberNode) { + if (old.value !== memberNode.value) { + old.value = memberNode.value; + if (old.value === false) { + enqueueChange(queue, removeAttribute, old); + } else { + enqueueChange(queue, changeAttribute, old); + } + } + } + ), + teardown(queue, state) { + enqueueChange(queue, removeAttribute, state); + }, +}; + +/** @type {MemberStateType} */ +const PropertyType = { + name: PROPERTY_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffMemberEntry), + init(parent, name) { + return { + type: PropertyType, + parent, + name, + value: null, + }; + }, + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {MemberState} */ old, /** @type {MemberNode} */ memberNode) { + if (old.value !== memberNode.value) { + old.value = memberNode.value; + enqueueChange(queue, changeProperty, old); + } + } + ), + teardown(queue, state) { + state.value = undefined; + enqueueChange(queue, changeProperty, state); + }, +}; + +/** @type {MemberStateType} */ +const RefType = { + name: REF_TYPE_NAME, + diffEntry: /** @type {DiffEntryFunction} */ (diffMemberEntry), + init(parent, name) { + return { + type: RefType, + parent, + name, + value: null, + }; + }, + diff: /** @type {DiffFunction} */ ( + function (queue, /** @type {MemberState} */ state, /** @type {MemberNode} */ node) { + if (state.value !== node.value) { + state.value = node.value; + if (state.parent.refHooks === null) { + state.parent.refHooks = []; + } + if (state.parent.refHooks.indexOf(state) === -1) { + state.parent.refHooks.push(state); + } + enqueuePost(queue, updateRef, state); + } + } + ), + teardown(queue, state) { + const index = state.parent.refHooks.indexOf(state); + state.parent.refHooks.splice(index, 1); + if (state.parent.refHooks.length === 0) { + state.parent.refHooks = null; + } + enqueuePost(queue, unsetRef, state); + }, +}; + +const typeMap = { + [ELEMENT_TYPE_NAME]: ElementType, + [FRAGMENT_TYPE_NAME]: FragmentType, + [COMPONENT_TYPE_NAME]: ComponentType, + [TEXT_TYPE_NAME]: TextType, + [ATTRIBUTE_TYPE_NAME]: AttributeType, + [PROPERTY_TYPE_NAME]: PropertyType, + [REF_TYPE_NAME]: RefType, +}; + +/** @type DiffEntryFunction */ +function diffChildEntry(queue, parent, /** @type NodeStateType */ metaType, /** @type NodeNode */ element) { + if (!parent.children) { + parent.children = []; + } + const index = parent.rewriteChildIndex++; + let state = parent.children[index]; + if (!state || state.shape !== element.shape) { + if (state) { + state.type.teardown(queue, state); + } + state = /** @type {NodeState} */ (metaType.init(queue, parent, parent.children[index - 1] || null, element)); + parent.children[index] = state; + + const sibling = parent.children[index + 1]; + if (sibling) { + sibling.after = state; + } + } + state.type.diff(queue, state, element); +} + +/** @type {DiffFunction} */ +function diffFragment( + queue, + /** @type {ElementState | FragmentState} */ lastValue, + /** @type {ElementNode | FragmentNode} */ newValue +) { + lastValue.rewriteChildIndex = 0; + const {content} = newValue; + for (let i = 0; i < content.length; i++) { + const node = content[i]; + const metaType = typeMap[node.type]; + metaType.diffEntry(queue, lastValue, metaType, node); + } + const children = lastValue.children; + if (children !== null) { + const childIndex = lastValue.rewriteChildIndex; + for (let i = childIndex; i < children.length; i++) { + const node = children[i]; + node.type.teardown(queue, node); + } + if (childIndex === 0) { + lastValue.children = null; + } else { + children.length = childIndex; + } + } +} + +/** @type {DiffEntryFunction} */ +function diffMemberEntry( + queue, + /** @type {ElementState} */ element, + /** @type {MemberStateType} */ nodeType, + /** @type {MemberNode} */ node +) { + if (element.members === null) { + element.members = []; + } + const group = element.members; + + const writeIndex = element.rewriteMemberIndex++; + let index; + for (index = writeIndex; index < group.length; index++) { + const item = group[index]; + if (item.type.name === node.type && item.name === node.name) { + break; + } + } + + let old = group[index]; + if (index !== writeIndex) { + group[index] = group[writeIndex]; + } + if (!old) { + old = nodeType.init(element, node.name); + group[writeIndex] = old; + } + old.type.diff(queue, old, node); +} + +/** @type {SoftTeardownFunction} */ +function softTeardownElement(/** @type {ElementState} */ state) { + const {children} = state; + if (children !== null) { + for (let i = 0; i < children.length; i++) { + children[i].type.softTeardown(children[i]); + } + } +} + +/** + * @param {Data} node + * @returns {node is MemberNode} + */ +function isOptions(node) { + return node.type === META_TYPE_NAME && node.name === "options"; +} + +/** + * @param {Queue} queue + */ +function runQueue(queue) { + const {prepare, changes: apply, post} = queue; + for (let i = 0; i < prepare.length; i++) { + changeViewRender(prepare[i], queue); + } + for (let i = 0; i < apply.length; i += 2) { + apply[i](apply[i + 1]); + } + for (let i = 0; i < post.length; i += 2) { + post[i](post[i + 1]); + } +} + +/** + * @param {ComponentState} componentState + * @param {Queue} queue + */ +function changeViewRender(componentState, queue) { + const newRender = componentState.shape(componentState.content) || null; + if (newRender) { + const metaType = typeMap[newRender.type]; + let lastRender = componentState.rendered; + if (!lastRender || lastRender.shape !== newRender.shape) { + if (lastRender) { + lastRender.type.teardown(queue, lastRender); + } + lastRender = metaType.init(queue, componentState, null, newRender); + componentState.rendered = lastRender; + } + lastRender.type.diff(queue, lastRender, newRender); + } else if (componentState.rendered) { + componentState.rendered.type.teardown(queue, componentState.rendered); + componentState.rendered = null; + } +} + +/** + * @param {MemberState} state + */ +function changeProperty(state) { + state.parent.ref[state.name] = state.value; +} + +/** + * @param {MemberState} state + */ +function removeAttribute(state) { + state.parent.ref.removeAttribute(state.name); +} + +/** + * @param {MemberState} state + */ +function changeAttribute(state) { + state.parent.ref.setAttribute(state.name, state.value); +} + +/** + * @param {TextState} state + */ +function removeText(state) { + state.ref.parentNode.removeChild(state.ref); +} + +/** + * @param {TextState} state + */ +function changeText(state) { + state.ref.textContent = state.content; +} + +/** + * @param {TextState} state + */ +function addText(state) { + state.ref = document.createTextNode(state.content); + state.ref.textContent = state.content; + insertAfterSibling(state); +} + +/** + * @param {NodeState} after + * @returns {NodeState} + */ +function deepestDescendant(after) { + if (after === null) { + return after; + } else if (after.type === FragmentType) { + const {children} = /** @type {FragmentState} */ (after); + if (children !== null) { + return deepestDescendant(children[children.length - 1]); + } + } else if (after.type === ComponentType) { + const {rendered} = /** @type {ComponentState} */ (after); + if (rendered !== null) { + return deepestDescendant(rendered); + } + } + return after; +} + +/** + * @param {ElementState | TextState} element + */ +function insertAfterSibling(element) { + /** @type {NodeState} */ + let state = element; + let after = deepestDescendant(state.after); + do { + if (after === null) { + if (state.parent.type === ElementType) { + break; + } + state = state.parent; + after = state; + } + if (after.type !== ElementType && after.type !== TextType) { + after = deepestDescendant(after.after); + } + } while (after === null || (after.type !== ElementType && after.type !== TextType)); + + if (after !== null) { + const {ref} = /** @type {ElementState | TextState} */ (after); + ref.parentNode.insertBefore(element.ref, ref.nextSibling); + } else { + const {ref} = /** @type {ElementState} */ (state.parent); + if (ref.childNodes.length) { + ref.insertBefore(element.ref, ref.childNodes[0]); + } else { + ref.appendChild(element.ref); + } + } +} + +/** + * @param {ElementState} state + */ +function removeElement(state) { + state.ref.parentNode.removeChild(state.ref); + state.ref = null; +} + +/** + * @param {ElementState} state + */ +function addElement(state) { + state.ref = document.createElement(state.shape); + insertAfterSibling(state); +} + +/** + * @param {MemberState} state + */ +function updateRef(state) { + state.value(state.parent.ref); +} + +/** + * @param {MemberState} state + */ +function unsetRef(state) { + state.value(null); +} + +function shallowEquals(a, b) { + if (a === null || b === null) { + return a === b; + } + for (const key of Object.keys(a)) { + if (key in b) { + if (a[key] !== b[key]) { + return false; + } + } else { + return false; + } + } + for (const key of Object.keys(b)) { + if (!(key in a)) { + return false; + } + } + return true; +} + +/** + * @callback StateAction + * @param {S} state + * @template {State} S + */ + +/** + * @returns {Queue} + */ +function newQueue() { + return {prepare: [], changes: [], post: []}; +} + +/** + * @param {Queue} queue + * @param {StateAction} fn + * @param {S} state + * @template {State} S + */ +function enqueueChange(queue, fn, state) { + queue.changes.push(fn, state); +} + +/** + * @param {Queue} queue + * @param {StateAction} fn + * @param {S} state + * @template {State} S + */ +function enqueuePost(queue, fn, state) { + queue.post.push(fn, state); +} + +/** + * @typedef Queue + * @property {Array} prepare + * @property {Array} changes + * @property {Array} post + */ + +/** + * @callback DiffEntryFunction + * @param {Queue} queue + * @param {ElementState | FragmentState} parent + * @param {StateType} nodeType + * @param {Data} node + * @returns {void} + */ + +/** + * @callback InitNodeFunction + * @param {Queue} queue + * @param {NodeState} parent + * @param {NodeState | null} after + * @param {NodeNode} node + * @returns {NodeState} + */ + +/** + * @callback DiffFunction + * @param {Queue} queue + * @param {State} state + * @param {Data} node + * @returns {void} + */ + +/** + * @callback TeardownFunction + * @param {Queue} queue + * @param {NodeState} state + * @returns {void} + */ + +/** + * @callback SoftTeardownFunction + * @param {NodeState} state + * @returns {void} + */ + +/** + * @typedef NodeStateTypeGeneric + * @property {Name} name + * @property {DiffEntryFunction} diffEntry + * @property {DiffFunction} diff + * @property {InitNodeFunction} init + * @property {TeardownFunction} teardown + * @property {SoftTeardownFunction} softTeardown + * @template {string | symbol} Name + */ + +/** + * @typedef NodeStateTypeCreate + * @property {Name} name + * @property {(queue: Queue, parent: ElementState | FragmentState, meta: StateType, node: Node) => void} diffEntry + * @property {(queue: Queue, state: S, node: Node) => void} diff + * @property {(queue: Queue, parent: ElementState | FragmentState, after: NodeState, node: Node) => void} init + * @property {(queue: Queue, state: S) => void} teardown + * @property {(state: S) => void} softTeardown + * @template {string | symbol} Name + * @template {State} S + * @template {Data} Node + */ + +/** @typedef {NodeStateTypeGeneric} ElementStateType */ +/** @typedef {NodeStateTypeGeneric} FragmentStateType */ +/** @typedef {NodeStateTypeGeneric} ComponentStateType */ +/** @typedef {NodeStateTypeGeneric} TextStateType */ + +/** @typedef {ElementStateType | FragmentStateType| ComponentStateType | TextStateType} NodeStateType */ + +/** + * @callback InitMemberFunction + * @param {ElementState} parent + * @param {string} name + * @returns {MemberState} + */ + +/** + * @callback TeardownMemberFunction + * @param {Queue} queue + * @param {MemberState} member + * @returns {void} + */ + +/** + * @typedef MemberStateType + * @property {string | symbol} name + * @property {DiffEntryFunction} diffEntry + * @property {DiffFunction} diff + * @property {InitMemberFunction} init + * @property {TeardownMemberFunction} teardown + */ + +/** + * @typedef {NodeStateType | MemberStateType} StateType + */ + +/** + * @typedef ElementState + * @property {ElementStateType} type + * @property {NodeState} parent + * @property {NodeState | null} after + * @property {string} shape + * @property {null} content + * @property {HTMLElement | null} ref + * @property {MemberState[] | null} refHooks + * @property {number} rewriteChildIndex + * @property {NodeState[] | null} children + * @property {number} rewriteMemberIndex + * @property {MemberState[] | null} members + */ + +/** + * @typedef FragmentState + * @property {FragmentStateType} type + * @property {NodeState} parent + * @property {NodeState | null} after + * @property {typeof FRAGMENT_TYPE_NAME} shape + * @property {null} content + * @property {number} rewriteChildIndex + * @property {NodeState[] | null} children + */ + +/** @typedef {ElementState | FragmentState} ParentState */ + +/** + * @typedef ComponentState + * @property {ComponentStateType} type + * @property {NodeState} parent + * @property {NodeState | null} after + * @property {function} shape + * @property {object} content + * @property {NodeState | null} rendered + */ + +/** + * @typedef TextState + * @property {TextStateType} type + * @property {NodeState} parent + * @property {NodeState | null} after + * @property {typeof TEXT_TYPE_NAME} shape + * @property {string} content + * @property {Text | null} ref + */ + +/** + * @typedef {ElementState | FragmentState | ComponentState | TextState} NodeState + */ + +/** + * @typedef MemberState + * @property {MemberStateType} type + * @property {ElementState} parent + * @property {string} name + * @property {*} value + */ + +/** @typedef {NodeState | MemberState} State */ + +/** @typedef {typeof ELEMENT_TYPE_NAME | typeof FRAGMENT_TYPE_NAME | typeof COMPONENT_TYPE_NAME | typeof TEXT_TYPE_NAME} NodeNodeType */ + +/** @typedef {typeof ATTRIBUTE_TYPE_NAME | typeof PROPERTY_TYPE_NAME | typeof REF_TYPE_NAME | typeof META_TYPE_NAME} MemberNodeType */ + +/** + * @typedef ElementNode + * @property {typeof ELEMENT_TYPE_NAME} type + * @property {string} shape + * @property {Data[]} content + */ + +/** + * @typedef FragmentNode + * @property {typeof FRAGMENT_TYPE_NAME} type + * @property {typeof FRAGMENT_TYPE_NAME} shape + * @property {Data[]} content + */ + +/** + * @typedef TextNode + * @property {typeof TEXT_TYPE_NAME} type + * @property {typeof TEXT_TYPE_NAME} shape + * @property {string} content + */ + +/** + * @typedef ComponentNode + * @property {typeof COMPONENT_TYPE_NAME} type + * @property {function} shape + * @property {Data[]} content + */ + +/** + * @typedef {ElementNode | FragmentNode | TextNode | ComponentNode} NodeNode + */ + +/** + * @typedef MemberNode + * @property {MemberNodeType} type + * @property {string} name + * @property {*} value + */ + +/** @typedef {NodeNode | MemberNode} Data */