From dd89d3c738c9bf31a1303752f8b557fc4f6d36b3 Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:56:59 -0400 Subject: [PATCH 1/6] screen reader accessibility enhancements and regression tests --- ActiveLogic.css | 3 +- Default.css | 7 +- Style1.css | 8 +- accessibleQuestionTextBuilder.js | 249 +++++- buildGrid.js | 11 +- common.js | 4 +- customMathJSImplementation.js | 11 +- docs/accessibilityManualTest.md | 25 +- eventHandlers.js | 3 +- i18n/en.js | 3 +- i18n/es.js | 3 +- initSurvey.js | 4 +- main.js | 17 +- quest.js | 15 +- questionProcessor.js | 413 ++++++++-- questionnaire.js | 28 +- restoreResponses.js | 23 +- stateManager.js | 188 ++++- ...odule4ConditionalCompoundStructure.spec.js | 211 +++++ .../corpus/productionMarkupEdgeCases.spec.js | 181 +++++ tests/corpus/structuralCatalog.json | 240 +++--- tests/corpus/structuralCatalog.spec.js | 32 + tests/e2e/accessibility.spec.js | 281 ++++++- ...accessibilityLifecycleKnownDefects.spec.js | 43 +- tests/e2e/authoring.spec.js | 70 ++ tests/e2e/authoringKnownDefects.spec.js | 42 - tests/e2e/backDeletion.spec.js | 11 +- tests/e2e/constructs.spec.js | 31 +- tests/e2e/gridDeepCoverage.spec.js | 57 +- tests/e2e/keyboardNavigation.spec.js | 48 ++ tests/e2e/knownDefects.spec.js | 98 --- tests/e2e/module4AddressPaths.spec.js | 59 ++ tests/e2e/participantNavigationState.spec.js | 53 +- tests/e2e/productionCompoundForms.spec.js | 726 +++++++++++++++--- tests/e2e/productionMarkupFidelity.spec.js | 130 ++++ tests/e2e/responsive.spec.js | 15 +- tests/e2e/support/authoring.js | 33 +- tests/e2e/visual.spec.js | 86 +++ ...compound-radio-layout-chromium-desktop.png | Bin 0 -> 10501 bytes ...compound-radio-layout-chromium-desktop.png | Bin 0 -> 6250 bytes .../canonical/compoundRadioGroups.txt | 15 + .../canonical/compoundRadioGroupsSpanish.txt | 15 + .../conditionalCompoundRadioGroups.txt | 19 + .../conditionalCompoundRadioGroupsSpanish.txt | 19 + .../canonical/gridResponsiveSpanish.txt | 8 + .../integration/accessibilityBuilder.spec.js | 217 ++++++ .../covidDurationNavigation.spec.js | 137 ++++ tests/integration/eventHandlers.spec.js | 60 ++ tests/integration/gridDeepCoverage.spec.js | 14 +- tests/integration/hostFailures.spec.js | 4 +- tests/integration/mainRender.spec.js | 592 +++++++++++++- tests/integration/questionProcessor.spec.js | 275 ++++++- tests/integration/restoreResponses.spec.js | 27 + tests/knownDefects/registry.js | 63 +- tests/knownDefects/runtime.spec.js | 250 +++--- tests/unit/buildGrid.spec.js | 39 +- tests/unit/customMath.spec.js | 8 + tests/unit/stateManager.spec.js | 98 ++- validate.js | 2 - 59 files changed, 4469 insertions(+), 855 deletions(-) create mode 100644 tests/corpus/module4ConditionalCompoundStructure.spec.js delete mode 100644 tests/e2e/authoringKnownDefects.spec.js delete mode 100644 tests/e2e/knownDefects.spec.js create mode 100644 tests/e2e/visual.spec.js-snapshots/participant-compound-radio-layout-chromium-desktop.png create mode 100644 tests/e2e/visual.spec.js-snapshots/participant-conditional-compound-radio-layout-chromium-desktop.png create mode 100644 tests/fixtures/canonical/compoundRadioGroups.txt create mode 100644 tests/fixtures/canonical/compoundRadioGroupsSpanish.txt create mode 100644 tests/fixtures/canonical/conditionalCompoundRadioGroups.txt create mode 100644 tests/fixtures/canonical/conditionalCompoundRadioGroupsSpanish.txt create mode 100644 tests/fixtures/canonical/gridResponsiveSpanish.txt create mode 100644 tests/integration/covidDurationNavigation.spec.js diff --git a/ActiveLogic.css b/ActiveLogic.css index 64f9e50..5cbf3e3 100644 --- a/ActiveLogic.css +++ b/ActiveLogic.css @@ -126,7 +126,8 @@ input[type="text"] { word-wrap: break-word; } - .quest-grid.table-layout th.nr { + .quest-grid.table-layout th.nr, + .quest-grid.table-layout td.grid-corner-spacer { padding: clamp(5px, 1vw, 10px); text-align: center; vertical-align: middle; diff --git a/Default.css b/Default.css index 7793a7d..9531d91 100644 --- a/Default.css +++ b/Default.css @@ -3,6 +3,10 @@ input[type="checkbox"] + label { margin: 5px; } +.validation-container > span { + color: rgb(193, 18, 31); +} + .question-text { font-size: 1rem; display: block; @@ -59,7 +63,8 @@ input[type="checkbox"] + label { word-wrap: break-word; } - .quest-grid.table-layout th.nr { + .quest-grid.table-layout th.nr, + .quest-grid.table-layout td.grid-corner-spacer { padding: clamp(5px, 1vw, 10px); text-align: center; vertical-align: middle; diff --git a/Style1.css b/Style1.css index df50b08..4c4ef82 100644 --- a/Style1.css +++ b/Style1.css @@ -165,8 +165,12 @@ input[type="checkbox"]:checked + label { .next:hover, .reset:hover, .previous:hover { - background-color: rgb(55, 133, 203); - border: solid 3px rgb(55, 133, 203); + background-color: rgb(44, 109, 168); + border: solid 3px rgb(44, 109, 168); +} + +.validation-container > span { + color: rgb(193, 18, 31); } .next:focus, diff --git a/accessibleQuestionTextBuilder.js b/accessibleQuestionTextBuilder.js index 935d0fd..621260e 100644 --- a/accessibleQuestionTextBuilder.js +++ b/accessibleQuestionTextBuilder.js @@ -3,6 +3,7 @@ import { handleForIDAttributes, moduleParams } from './questionnaire.js'; const QUESTION_TRANSITION_FOCUS_DELAY_MS = 500; const MODAL_RETURN_FOCUS_DELAY_MS = 100; +let selectionAnnouncementTimeout = null; /** * Initialize the question text and focus management for screen readers. @@ -59,7 +60,7 @@ function buildQuestionText(fieldsetEle) { const textNodeConditional = (node) => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && - !['INPUT', 'BR', 'LABEL', 'LEGEND', 'TABLE'].includes(node.tagName) && + !['INPUT', 'TEXTAREA', 'SELECT', 'BR', 'LABEL', 'LEGEND', 'TABLE'].includes(node.tagName) && !node.classList.contains('response')); const isTerminalText = (text) => { @@ -161,7 +162,9 @@ function buildQuestionText(fieldsetEle) { // Create the tag for screen readers and move the question text into it. const updatedFieldset = manageAccessibleFieldset(fieldsetEle, questionElements); // Create and return the hidden, focusable element for screen reader focus management. - return createFocusableElement(updatedFieldset, focusNode); + const focusableEle = createFocusableElement(updatedFieldset, focusNode); + manageCompoundRadioGroups(updatedFieldset); + return focusableEle; } // Find additional questions (e.g. QoL multi-question surveys). @@ -179,8 +182,8 @@ function handleMultiQuestionSurveyAccessibility(childNodes, fieldsetEle, startIn for (let i = startIndex; i < childNodes.length; i++) { const node = childNodes[i]; - // Stop at the first input/Table/Label node. Multi-question surveys don't have these nodes. - if (['INPUT', 'TABLE', 'LABEL'].includes(node.tagName)) { + // Stop at the first response control/Table/Label node. Multi-question surveys don't have these nodes. + if (['INPUT', 'TEXTAREA', 'SELECT', 'TABLE', 'LABEL'].includes(node.tagName)) { break; } @@ -241,6 +244,201 @@ function handleMultiQuestionSurveyAccessibility(childNodes, fieldsetEle, startIn }); } +/** + * Give each named radio subgroup in a compound question its own accessible group label. + * @param {HTMLElement} fieldsetEle - The fieldset containing the compound form. + */ +function manageCompoundRadioGroups(fieldsetEle) { + const radioResponses = Array.from( + fieldsetEle.querySelectorAll(':scope > .response'), + ).map((response) => ({ + response, + input: response.querySelector(':scope > input[type="radio"][name]'), + })).filter(({ input }) => input); + + const radioNames = new Set(radioResponses.map(({ input }) => input.name)); + if (radioNames.size <= 1) return; + + const responseGroups = []; + radioResponses.forEach(({ response, input }) => { + const currentGroup = responseGroups.at(-1); + const previousResponse = currentGroup?.responses.at(-1); + if (currentGroup?.name === input.name && responsesSharePrompt(previousResponse, response)) { + currentGroup.responses.push(response); + } else { + responseGroups.push({ name: input.name, responses: [response] }); + } + }); + + // Resolve every prompt before changing the DOM (prevents a partially grouped fieldset). + if (new Set(responseGroups.map(({ name }) => name)).size !== responseGroups.length) return; + + const labelledGroups = responseGroups.map(({ name, responses }, groupIndex) => { + const prompt = findCompoundRadioPrompt(fieldsetEle, responses[0], groupIndex); + return { + name, + responses, + prompt, + configuration: prompt + ? getCompoundRadioGroupConfiguration(prompt, responses) + : null, + }; + }); + if (labelledGroups.some(({ prompt, configuration }) => !prompt || !configuration)) return; + if (new Set(labelledGroups.map(({ prompt }) => prompt)).size !== labelledGroups.length) return; + + const groupKinds = new Set(labelledGroups.map(({ configuration }) => configuration.kind)); + if (groupKinds.size !== 1) return; + if (groupKinds.has('static') && fieldsetEle.querySelector('.displayif, [displayif]')) return; + if (groupKinds.has('conditional')) { + const inputs = labelledGroups.flatMap(({ configuration }) => configuration.inputs); + const inputIds = inputs.map(({ id }) => id); + if (new Set(inputIds).size !== inputIds.length) return; + + // A connected question must not resolve to another host element with the same ID. + if (fieldsetEle.isConnected) { + const idCounts = new Map(); + fieldsetEle.ownerDocument.querySelectorAll('[id]').forEach(({ id }) => { + idCounts.set(id, (idCounts.get(id) ?? 0) + 1); + }); + if (inputs.some((input) => ( + idCounts.get(input.id) !== 1 + || fieldsetEle.ownerDocument.getElementById(input.id) !== input + ))) return; + } + } + + const questionId = fieldsetEle.closest('.question')?.id || 'question'; + labelledGroups.forEach(({ name, responses, prompt, configuration }) => { + if (configuration.kind === 'conditional') { + // Conditional response rows must remain direct fieldset children for Quest's display logic and layout. + // ARIA ownership provides the group relationship without moving those rows. + prompt.setAttribute('role', 'radiogroup'); + prompt.setAttribute('aria-label', configuration.label); + prompt.setAttribute('aria-owns', configuration.inputs.map(({ id }) => id).join(' ')); + prompt.removeAttribute('aria-labelledby'); + prompt.removeAttribute('tabindex'); + return; + } + + const labelId = ensureCompoundRadioPromptId(prompt, questionId, name); + if (prompt.getAttribute('role') === 'alert') { + prompt.removeAttribute('role'); + if (prompt.getAttribute('tabindex') === '0') { + prompt.removeAttribute('tabindex'); + } + } + + const radioGroup = document.createElement('div'); + radioGroup.classList.add('compound-radio-group'); + radioGroup.setAttribute('role', 'radiogroup'); + radioGroup.setAttribute('aria-labelledby', labelId); + fieldsetEle.insertBefore(radioGroup, responses[0]); + responses.forEach((response) => radioGroup.appendChild(response)); + }); +} + +function getCompoundRadioGroupConfiguration(prompt, responses) { + const promptHasCondition = prompt.hasAttribute('displayif'); + const conditionedResponses = responses.filter((response) => response.hasAttribute('displayif')); + + if (promptHasCondition || conditionedResponses.length > 0) { + if (!promptHasCondition || conditionedResponses.length !== responses.length) return null; + + const promptCondition = normalizeCompoundRadioCondition(prompt.getAttribute('displayif')); + const responseConditions = responses.map((response) => ( + normalizeCompoundRadioCondition(response.getAttribute('displayif')) + )); + if (!promptCondition || responseConditions.some((condition) => condition !== promptCondition)) { + return null; + } + + const inputs = responses.map((response) => ( + response.querySelector(':scope > input[type="radio"][name]') + )); + const inputIds = inputs.map((input) => input?.id).filter(Boolean); + if (inputs.length < 2 || inputIds.length !== inputs.length || new Set(inputIds).size !== inputs.length) { + return null; + } + + const label = prompt.textContent.replace(/\s+/g, ' ').trim(); + if (!label) return null; + + return { kind: 'conditional', inputs, label }; + } + + if (responses.some(({ hidden, style }) => hidden || style.display === 'none')) return null; + return { kind: 'static' }; +} + +function normalizeCompoundRadioCondition(condition) { + if (!condition) return ''; + try { + return decodeURIComponent(condition).replace(/\s+/g, ' ').trim(); + } catch { + return condition.replace(/\s+/g, ' ').trim(); + } +} + +function responsesSharePrompt(previousResponse, currentResponse) { + if (!previousResponse) return false; + + for (let node = previousResponse.nextSibling; node && node !== currentResponse; node = node.nextSibling) { + if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') continue; + if (node.nodeType === Node.ELEMENT_NODE && ( + node.tagName === 'BR' || node.classList.contains('screen-reader-focus') + )) continue; + return false; + } + return true; +} + +function findCompoundRadioPrompt(fieldsetEle, firstResponse, groupIndex) { + let previousNode = firstResponse.previousSibling; + while (previousNode) { + if (previousNode.nodeType === Node.TEXT_NODE && previousNode.textContent.trim() === '') { + previousNode = previousNode.previousSibling; + continue; + } + + if (previousNode.nodeType === Node.ELEMENT_NODE) { + if (previousNode.tagName === 'BR' || previousNode.classList.contains('screen-reader-focus')) { + previousNode = previousNode.previousSibling; + continue; + } + if ( + previousNode.getAttribute('role') === 'alert' + || previousNode.matches('.displayif[displayif]') + ) { + return previousNode; + } + if (previousNode.matches('.response, .compound-radio-group')) { + break; + } + } + break; + } + + return groupIndex === 0 + ? fieldsetEle.querySelector(':scope > legend') + : null; +} + +function ensureCompoundRadioPromptId(prompt, questionId, radioName) { + if (prompt.id) return prompt.id; + + const safeIdPart = (value) => String(value).replace(/[^A-Za-z0-9_-]/g, '-'); + const baseId = `${safeIdPart(questionId)}-compound-radio-${safeIdPart(radioName)}-label`; + let promptId = baseId; + let suffix = 2; + while (document.getElementById(promptId) && document.getElementById(promptId) !== prompt) { + promptId = `${baseId}-${suffix}`; + suffix += 1; + } + prompt.id = promptId; + return promptId; +} + /** * Insert the tag for the question text. This is the accessible question text for screen readers. * Check for an existing tag since the user can navigate back and forth between questions. @@ -588,9 +786,28 @@ export function closeModalAndFocusQuestion() { }, MODAL_RETURN_FOCUS_DELAY_MS); } +function scheduleSelectionAnnouncement(liveRegion, announcementText, delay) { + // The selection announcer is shared by every question & control. Only + // the latest request can remain valid. Navigation & sequential renders + // use the same clear operation to cancel current announcement work. + clearSelectionAnnouncement(); + + const timeoutId = setTimeout(() => { + if (selectionAnnouncementTimeout !== timeoutId) return; + selectionAnnouncementTimeout = null; + + const currentLiveRegion = moduleParams.questDiv?.querySelector('#ariaLiveSelectionAnnouncer'); + if (liveRegion.isConnected && liveRegion === currentLiveRegion) { + liveRegion.textContent = announcementText; + } + }, delay); + + selectionAnnouncementTimeout = timeoutId; +} + // Update the aria-live region with the current selection announcement in a list (for screen readers). export function updateAriaLiveSelectionAnnouncer(responseDiv) { - const liveRegion = moduleParams.questDiv.querySelector('#ariaLiveSelectionAnnouncer'); + const liveRegion = moduleParams.questDiv?.querySelector('#ariaLiveSelectionAnnouncer'); const label = responseDiv.querySelector('label'); const input = responseDiv.querySelector('input[type="checkbox"], input[type="radio"]'); @@ -604,17 +821,13 @@ export function updateAriaLiveSelectionAnnouncer(responseDiv) { ? `${actionText}` : `${label.textContent} ${actionText}`; - liveRegion.textContent = ''; - - setTimeout(() => { - liveRegion.textContent = announcementText; - }, 100); + scheduleSelectionAnnouncement(liveRegion, announcementText, 100); } // Update the aria-live region with the current selection announcement in a table (for screen readers). // Note: cell-specific targeting is required for dependable selection announcements. export function updateAriaLiveSelectionAnnouncerTable(responseDiv) { - const liveRegion = moduleParams.questDiv.querySelector('#ariaLiveSelectionAnnouncer'); + const liveRegion = moduleParams.questDiv?.querySelector('#ariaLiveSelectionAnnouncer'); const cell = responseDiv.closest('td'); // Get the closest table cell (td) const label = cell?.querySelector('label'); // Find the label within the cell const input = cell?.querySelector('input[type="checkbox"], input[type="radio"]'); @@ -626,15 +839,17 @@ export function updateAriaLiveSelectionAnnouncerTable(responseDiv) { const actionText = input.checked ? 'Selected.' : 'Unselected.'; const announcementText = `${label.textContent} ${actionText}`; - liveRegion.textContent = ''; - setTimeout(() => { - liveRegion.textContent = announcementText; - }, 250); + scheduleSelectionAnnouncement(liveRegion, announcementText, 250); } -// Clear the selection accnouncer when a user is navigating between questions (next/back buttons) +// Clear the selection announcer and cancel current announcement work. export function clearSelectionAnnouncement() { - const liveRegion = moduleParams.questDiv.querySelector('#ariaLiveSelectionAnnouncer'); + if (selectionAnnouncementTimeout !== null) { + clearTimeout(selectionAnnouncementTimeout); + selectionAnnouncementTimeout = null; + } + + const liveRegion = moduleParams.questDiv?.querySelector('#ariaLiveSelectionAnnouncer'); if (liveRegion) { liveRegion.textContent = ''; } diff --git a/buildGrid.js b/buildGrid.js index cc0ac44..731601f 100644 --- a/buildGrid.js +++ b/buildGrid.js @@ -42,8 +42,9 @@ function buildHtmlTable(grid_obj, gridButtonDiv) {
${grid_text_displayif(shared_text)}
`; - // Build the table header row with the question text and response headers. Start with a placeholder for the row header. - grid_html += ''; + // Build the table header row with the response headers. The first cell is a + // visual spacer above the row-header column, not a header of its own. + grid_html += ''; grid_obj.responses.forEach((resp) => { const header_text = resp.text; grid_html += ``; @@ -68,7 +69,7 @@ function buildHtmlTable(grid_obj, gridButtonDiv) { grid_obj.responses.forEach((resp, resp_index) => { grid_html += ` `; }); @@ -127,7 +128,9 @@ export function parseGrid(text, ...args) { // the value, then evaluate the markdown. question_text = grid_replace_piped_variables(question_text) - let question_obj = { id: match[1], question_text: question_text, displayif: encodeURIComponent(displayIf) }; + // Keep the expression raw in the parsed model. The HTML + // boundary in buildHtmlTable encodes it once for the data attribute. + let question_obj = { id: match[1], question_text: question_text, displayif: displayIf }; grid_obj.questions.push(question_obj); } diff --git a/common.js b/common.js index 346570b..c67c5c2 100644 --- a/common.js +++ b/common.js @@ -23,7 +23,7 @@ export const ariaLiveAnnouncementRegions = () => { export const progressBar = () => { return moduleParams.showProgressBarInQuest ? `
-
+
0% Complete
@@ -160,4 +160,4 @@ export function hideLoadingIndicator() { if (loadingIndicator) { document.body.removeChild(loadingIndicator); } -} \ No newline at end of file +} diff --git a/customMathJSImplementation.js b/customMathJSImplementation.js index a214dce..4938153 100644 --- a/customMathJSImplementation.js +++ b/customMathJSImplementation.js @@ -241,7 +241,16 @@ export const customMathJSFunctions = { }, dateCompare: function (month1, year1, month2, year2) { - if ([month1, month2].some((m) => { let m1 = parseInt(m); m1 < 0 || m1 > 11 })) { + const invalidMonth = [month1, month2].some((month) => { + if ( + month == null + || !['number', 'string'].includes(typeof month) + || (typeof month === 'string' && month.trim() === '') + ) return true; + const numericMonth = Number(month); + return !Number.isInteger(numericMonth) || numericMonth < 0 || numericMonth > 11; + }); + if (invalidMonth) { throw 'DateCompareError:months need to be from 0 (Jan) to 11 (Dec)' } diff --git a/docs/accessibilityManualTest.md b/docs/accessibilityManualTest.md index bc98786..1620589 100644 --- a/docs/accessibilityManualTest.md +++ b/docs/accessibilityManualTest.md @@ -21,7 +21,11 @@ For the keyboard-only command guide, see `fixture=gridResponsive.txt`; for checkbox-grid focus branches use `fixture=gridCheckboxFocus.txt`; for validation use `fixture=validation.txt`; for navigation/restoration use - `fixture=navigationState.txt`. + `fixture=navigationState.txt`; for compound radio subgroups use + `fixture=compoundRadioGroups.txt` and repeat with + `fixture=compoundRadioGroupsSpanish.txt`; for conditionally displayed + radio subgroups use `fixture=conditionalCompoundRadioGroups.txt` and + repeat with `fixture=conditionalCompoundRadioGroupsSpanish.txt`. - Keep Quest inside `#root > .row > #questionnaireRoot`. - Start each scenario from a new browser page. Quest owns module-level state and a second render in the same page is not a supported isolation boundary. - Do not enable browser extensions other than the screen reader under test. @@ -93,12 +97,14 @@ First run with Quick Nav off. Repeat response navigation and activation with Qui 2. Confirm the survey boundary and the first question are announced once. The question must have a meaningful group/legend name. 3. Navigate by form controls and by VoiceOver cursor. Each response must expose role, name, and selected/checked state; styled labels alone are insufficient. 4. Activate a response with the VoiceOver default action (normally VO+Space). Confirm the visual selection, DOM checked state, and polite live announcement all agree. -5. Navigate forward. Focus must land at the newly active question context after the deliberate delay; it must not remain on a removed button or move to the PWA header/footer. -6. Use Back. Confirm the earlier response is restored and announced with the correct checked state. -7. Exercise text, number, select, date, and time controls. Confirm each control has a useful name and browser-native editing still works. +5. Immediately navigate forward after selecting a response. Focus must land at the newly active question context after the deliberate delay; it must not remain on a removed button or move to the PWA header/footer. The previous response's delayed “Selected” or “Unselected” message must not interrupt or replay over the new question. +6. Use Back immediately after another selection. Confirm the earlier response is restored and announced with the correct checked state, and that the response announcement from the question being left does not replay. +7. Exercise text, number, select, date, and time controls. Confirm each control has a useful name and browser-native editing still works. In Module 1 weight history, verify that each field includes its distinct age and unit context; in Spanish question `D_912857732`, type with the physical keyboard and confirm whole numbers are accepted without a browser error while decimal punctuation is filtered. In a Module 4 backup-address form, verify that City, State/Province, Zip code, and Country remain distinguishable. 8. Trigger required and range validation. Confirm the error is announced, focus remains in the question, and correction clears the error. 9. Exercise the response grid. Confirm row prompt, column option, and selected state are available together. 10. Open and close soft-validation and submit dialogs. Confirm dialog name, focus entry, keyboard containment, close action, and return focus. +11. Open each compound-radio fixture. Enter both activity groups and traverse all answers. Confirm VoiceOver announces the visible activity prompt as the radio-group name, each answer as the radio name, and its checked state. Confirm the activity prompt is not a separate empty or alert stop. +12. Open each conditional compound-radio fixture. Select both travel modes and continue. Confirm each mode prompt is announced once when entering its group; each answer name and checked state is announced once; and exiting does not repeat the full answer list. Use Back, remove one mode, and continue again. Confirm the removed group is absent, the retained group is still named correctly, and no duplicate, empty, or separate repeated-prompt stop is announced. ## JAWS with Chrome and Edge — issue #1079 @@ -112,8 +118,17 @@ Run each browser once. State explicitly whether each observation occurred in Vir 6. For radio groups, verify native group exclusivity and arrow behavior while in the appropriate mode. 7. For “Other” text responses, confirm Up/Down remains native text-editing navigation and Tab/Shift+Tab leaves the field in document order. Quest must not turn editing arrows into response-navigation commands. 8. Exercise both grid fixtures. In Forms Mode, radio arrows must move only within the current row's native group, and Tab must reach the next row. Selecting a radio or checkbox must not automatically move focus to another row or to Next. Confirm JAWS announces the row prompt, column option, and checked state together. -9. Repeat Next, Back, validation, state restoration, async success/error, and modal focus scenarios from the VoiceOver matrix. +9. Repeat Next, Back, validation, state restoration, async success/error, and modal focus scenarios from the VoiceOver matrix, including its scalar-control checks and the immediate-navigation check that an old response announcement never replays on the newly active question. 10. Exit Forms Mode and confirm the Virtual Cursor resumes at a sensible location in the active question. +11. Open each compound-radio fixture. In Virtual Cursor and Forms Mode, enter both activity groups and traverse all answers. Confirm JAWS announces the visible activity prompt when entering its group, preserves the concise answer name and checked state, and does not expose the activity prompt as a separate alert or form control. +12. Open each conditional compound-radio fixture. Select both travel modes and continue. In Virtual Cursor and Forms Mode, confirm each mode prompt is announced once on group entry; each answer name and checked state is announced once; and exiting does not repeat the full answer list. Use Back, remove one mode, and continue again. Confirm the removed group is absent, the retained group remains correctly named, and no duplicate, empty, or separate repeated-prompt stop is exposed. + +The conditional fixture follows the WAI-ARIA radio-group contract: the visible +mode prompt names a `radiogroup`, and `aria-owns` associates native radios that +cannot safely be moved under that prompt in Quest's established conditional +layout. See the [radio role](https://www.w3.org/TR/wai-aria/#radio), +[`aria-owns`](https://www.w3.org/TR/wai-aria/#aria-owns), and the +[ARIA radio-group pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radio/). ## Evidence to capture diff --git a/eventHandlers.js b/eventHandlers.js index 1f8b5fb..b692ae6 100644 --- a/eventHandlers.js +++ b/eventHandlers.js @@ -255,8 +255,9 @@ export function resetChildren(target) { for (let node of nodes) { if (node.type === "radio" || node.type === "checkbox") { node.checked = false; - } else if (node.type === "text" || node.type === "time" || node.type === "date" || node.type === "month" || node.type === "number") { + } else if (node.type === "text" || node.type === "textarea" || node.type === "time" || node.type === "date" || node.type === "month" || node.type === "number") { node.value = ""; + delete node.dataset.lastValue; clearValidationError(node) } } diff --git a/i18n/en.js b/i18n/en.js index a8daddd..7f63a26 100644 --- a/i18n/en.js +++ b/i18n/en.js @@ -20,6 +20,7 @@ const en = { "submitButton": "Submit", "cancelButton": "Cancel", "closeButton": "Close", + "progressBarLabel": "Survey progress", "yes": "Yes", "no": "No", @@ -63,4 +64,4 @@ const en = { "loading": "Loading...", } -export default en; \ No newline at end of file +export default en; diff --git a/i18n/es.js b/i18n/es.js index 15d6450..82e49e6 100644 --- a/i18n/es.js +++ b/i18n/es.js @@ -20,6 +20,7 @@ const es = { "submitButton": "Enviar", "cancelButton": "Cancelar", "closeButton": "Cerrar", + "progressBarLabel": "Progreso de la encuesta", "yes": "Sí", "no": "No", @@ -63,4 +64,4 @@ const es = { "loading": "Cargando...", } -export default es; \ No newline at end of file +export default es; diff --git a/initSurvey.js b/initSurvey.js index 87deff4..76e348b 100644 --- a/initSurvey.js +++ b/initSurvey.js @@ -1,6 +1,5 @@ import { moduleParams } from './questionnaire.js'; import { initializeCustomMathJSFunctions, math } from './customMathJSImplementation.js'; -import { initializeStateManager } from './stateManager.js'; import { QuestionProcessor } from './questionProcessor.js'; import { getStateManager } from './stateManager.js'; @@ -14,7 +13,6 @@ import { getStateManager } from './stateManager.js'; * @returns {Array} - An array containing the transformed contents, questName, and retrievedData. */ export async function initSurvey(markdown) { - initializeStateManager(moduleParams.store); initializeCustomMathJSFunctions(); const precalculated_values = getPreCalculatedValues(markdown); @@ -111,4 +109,4 @@ function getPreCalculatedValues(contents) { }); return precalculated_values; -} \ No newline at end of file +} diff --git a/main.js b/main.js index a9259a0..4638a2d 100644 --- a/main.js +++ b/main.js @@ -3,7 +3,8 @@ import { restoreResponses } from "./restoreResponses.js"; import { addEventListeners } from "./eventHandlers.js"; import { ariaLiveAnnouncementRegions, progressBar, responseRequestedModal, responseRequiredModal, responseErrorModal, storeErrorModal, submitModal } from "./common.js"; import { initSurvey } from "./initSurvey.js"; -import { getStateManager } from "./stateManager.js"; +import { getStateManager, initializeStateManager } from "./stateManager.js"; +import { clearSelectionAnnouncement } from "./accessibleQuestionTextBuilder.js"; import en from "./i18n/en.js"; import es from "./i18n/es.js"; @@ -15,9 +16,15 @@ transform.rbAndCbClick = rbAndCbClick; transform.render = async (obj, divID, previousResults = {}) => { try { + // Cancel potential delayed announcement updates from the prior render. + clearSelectionAnnouncement(); + // Set the global moduleParams object with data needed for different parts of the app. setModuleParams(obj, divID, previousResults); + // Rebind render-owned state before the first possible await. + initializeStateManager(moduleParams.store); + // if the object has a 'text' field, the contents have been prefetched and passed in. Else, fetch the survey contents. const markdown = moduleParams.text || await fetch(moduleParams.url).then(response => response.text()); @@ -129,7 +136,13 @@ function setInitialQuestionOnStartup(questionProcessor, activeQuestionID, initia ? showAllQuestions(questionProcessor.getAllProcessedQuestions()) : swapVisibleQuestion(questionEle); - initialUserData[activeQuestionID] && restoreResponses(initialUserData, activeQuestionID); + const persistedResponse = initialUserData[questionEle.id]; + const hasRestorableResponse = typeof persistedResponse === 'string' + || Array.isArray(persistedResponse) + || (persistedResponse != null && typeof persistedResponse === 'object'); + if (hasRestorableResponse) { + restoreResponses(initialUserData, activeQuestionID); + } return questionEle; } diff --git a/quest.js b/quest.js index c372cae..de0435e 100644 --- a/quest.js +++ b/quest.js @@ -134,6 +134,10 @@ class QuestRenderer { console.warn("Using memory for setItem (LF fallback):", key); return Promise.resolve(value); }, + removeItem: (key) => { + console.warn("Using memory for removeItem (LF fallback):", key); + return Promise.resolve(); + }, clear: () => { console.warn("Using memory for clear (LF fallback)"); return Promise.resolve(); @@ -418,7 +422,16 @@ class QuestRenderer { async clearLocalForage() { try { - await localforage.clear(); + // Clear the default LocalForage store when it is available, but + // do not let that optional cleanup block the active Quest adapter. This + // matters when initialization fell back because browser storage failed. + if (typeof localforage !== "undefined" && typeof localforage.clear === "function") { + try { + await localforage.clear(); + } catch (err) { + console.warn("Unable to clear global LocalForage; continuing with Quest storage:", err); + } + } await this.questLocalForage.removeItem("previousResults"); // Clear the PreviousResults data diff --git a/questionProcessor.js b/questionProcessor.js index 3ba5f13..7aa299e 100644 --- a/questionProcessor.js +++ b/questionProcessor.js @@ -12,6 +12,231 @@ const elementIdRegex = /id=([^\s]+)/; const embeddedHTMLQuestionIDRegex = /id="([^"]+)"/; const displayIfRegex = /displayif\s*=\s*.*/; const endMatchRegex = /end\s*=\s*(.*)?/; +const accessibleNameRegex = /(?:^|\s)aria-(?:label|labelledby)\s*=/i; +const captionBoundaryRegex = /(?:\r?\n)?|\r?\n/i; + +function decodeBasicHTMLEntities(value) { + return value + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/"/gi, '"') + .replace(/&#(?:39|x27);/gi, "'") + .replace(/</gi, '<') + .replace(/>/gi, '>'); +} + +function normalizeQuestCondition(condition) { + if (!condition) return ''; + let normalized; + try { + normalized = decodeURIComponent(condition); + } catch { + normalized = condition; + } + normalized = normalized.trim().replace(/"/g, "'"); + + let removedOuterParentheses = true; + while (removedOuterParentheses && normalized.startsWith('(') && normalized.endsWith(')')) { + removedOuterParentheses = false; + let depth = 0; + for (let index = 0; index < normalized.length; index += 1) { + if (normalized[index] === '(') depth += 1; + if (normalized[index] === ')') depth -= 1; + if (depth === 0 && index < normalized.length - 1) break; + if (depth === 0 && index === normalized.length - 1) { + normalized = normalized.slice(1, -1).trim(); + removedOuterParentheses = true; + } + } + } + + return normalized.replace(/\s+/g, ''); +} + +function controlWrapperCondition(before) { + const currentLine = String(before ?? '').split(captionBoundaryRegex).at(-1) ?? ''; + return currentLine.match(/\|displayif=([^|]+)\|\s*$/i)?.[1] ?? ''; +} + +function effectiveControlCondition(fullmatch, before) { + const optionCondition = fullmatch.match( + /\bdisplayif\s*=\s*(.+?)(?=\s+[A-Za-z][\w-]*\s*=|\|?$)/i, + )?.[1]; + if (optionCondition) return normalizeQuestCondition(optionCondition); + + return normalizeQuestCondition(controlWrapperCondition(before)); +} + +function normalizeCaptionCandidate(fragment, effectiveCondition = '') { + let candidate = String(fragment ?? ''); + + // Before choice parsing, remove the numeric choice token. After choice parsing, retain only the + // still-open label's visible text. Never serialize the surrounding response HTML into the nested control's name. + if (/<(?:\/?label\b|div\b[^>]*\bclass\s*=\s*(?:"[^"]*\bresponse\b[^"]*"|'[^']*\bresponse\b[^']*'))/i.test(candidate)) { + const openLabelText = candidate.match(/]*>([^<]*)$/i)?.[1]; + const closingLabelText = candidate.match(/^([^<]*)<\/label>/i)?.[1]; + if (!openLabelText && !closingLabelText) return ''; + candidate = openLabelText ?? closingLabelText; + } + candidate = candidate.replace(/^\s*(?:\(\d+[^)]*\)|\[\d+[^\]]*\])\s*/, ''); + + // A piped response is live participant data, not part of the control's name. + // Remove the generated forid subtree while retaining any stable caption text + // on the same line (e.g., "City:"). + candidate = candidate.replace( + /]*\bforid\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)[^>]*>[\s\S]*?<\/span>/gi, + '', + ); + + // Include fixed conditional wording only when the control itself has the + // same effective condition. Otherwise discard the conditional phrase while + // retaining stable text around it. This avoids freezing a hidden alternate + // phrase into the accessible name. + let rejectedConditionalCaption = false; + candidate = candidate.replace( + /\|displayif=([^|]+)\|([^|]*)\|/gi, + (_, condition, text) => { + if (effectiveCondition && normalizeQuestCondition(condition) === effectiveCondition) { + return text; + } + if (text.replace(/<[^>]*>/g, '').trim()) rejectedConditionalCaption = true; + return ''; + }, + ); + if (rejectedConditionalCaption) return ''; + + // Reject unmatched or generated dynamic markup (avoid leaking Quest + // syntax/attributes into an ARIA attribute). + if (/\b(?:displayif|forid|data-[\w-]+)\s*=/i.test(candidate) || candidate.includes('|')) return ''; + + return decodeBasicHTMLEntities(candidate) + .replace(/<[^>]*>/g, '') + // A leading # is commonly an authoring marker. Preserve "# of …" because + // it is meaningful visible wording (number of), but remove it elsewhere. + .replace(/^\s*#(?!\s*of\b)\s*/i, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeAdjacentCaption(fragment, edge, effectiveCondition = '') { + const segments = String(fragment ?? '').split(captionBoundaryRegex); + const candidate = edge === 'before' ? segments.at(-1) : segments[0]; + const normalized = normalizeCaptionCandidate(candidate, effectiveCondition); + + return normalized.startsWith('->') ? '' : normalized; +} + +function normalizePreviousCaption(fragment, effectiveCondition = '') { + const segments = String(fragment ?? '').split(captionBoundaryRegex); + if (segments.length < 2) return ''; + const candidate = segments.at(-2); + // Crossing a line boundary is only safe for Quest's conditional row + // pattern. Otherwise the previous line may be the overall question prompt, + // not a local control caption. + if (!/\b(?:displayif|forid)\s*=/i.test(candidate ?? '')) return ''; + return normalizeCaptionCandidate(candidate, effectiveCondition); +} + +function generatedControlName(before, after, fallback, effectiveCondition = '') { + const afterCaption = normalizeAdjacentCaption(after, 'after', effectiveCondition); + const beforeCaption = normalizeAdjacentCaption(before, 'before', effectiveCondition); + const previousCaption = beforeCaption + ? '' + : normalizePreviousCaption(before, effectiveCondition); + + if (beforeCaption && afterCaption) return `${beforeCaption}, ${afterCaption}`; + if (previousCaption && afterCaption) return `${previousCaption}, ${afterCaption}`; + return afterCaption || beforeCaption || previousCaption || fallback; +} + +function generatedControlNameFromReplace(fullmatch, offset, source, fallback) { + const before = source.slice(0, offset); + return generatedControlName( + before, + source.slice(offset + fullmatch.length), + fallback, + effectiveControlCondition(fullmatch, before), + ); +} + +function escapeChoiceDelimiters(value) { + return String(value) + .replace(/\(/g, '(') + .replace(/\)/g, ')') + .replace(/\[/g, '[') + .replace(/\]/g, ']'); +} + +function escapeAttribute(value) { + const escapedValue = String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + + // Choice parsing runs after several scalar expansions. Encode numeric + // choice delimiters so later `(1)` / `[2]` parsers cannot rewrite text + // inside an accessible-name attribute. Browsers decode the original text. + return escapeChoiceDelimiters(escapedValue); +} + +function addGeneratedAccessibleName(options, name) { + if (accessibleNameRegex.test(options)) return options; + return `${options} aria-label="${escapeAttribute(name)}"`.trim(); +} + +function extractAuthoredAccessibleNameOptions(options) { + const accessibleNameOptions = []; + const parseableOptions = String(options ?? '').replace( + /(?:^|\s)(aria-(?:label|labelledby)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s]+))/gi, + (_, option) => { + accessibleNameOptions.push(option.trim()); + return ' '; + }, + ).trim(); + + return { + parseableOptions, + accessibleNameOptions: accessibleNameOptions.join(' '), + }; +} + +function tokenizeAccessibleLabelValues(source) { + const originalSource = String(source ?? ''); + const replacements = []; + let tokenIndex = 0; + + const text = originalSource.replace( + /(^|[\s|])(aria-label\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s|>]+))/gi, + (_, boundary, assignment, doubleQuotedValue, singleQuotedValue, unquotedValue) => { + let token; + do { + // Hyphens make this impossible under Quest's question-ID grammar, so + // restoring the token cannot accidentally rewrite a generated name. + token = `QUEST-A11Y-OPTION-${tokenIndex}-END`; + tokenIndex += 1; + } while (originalSource.includes(token)); + + const value = doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? ''; + const quote = doubleQuotedValue !== undefined + ? '"' + : singleQuotedValue !== undefined + ? "'" + : ''; + replacements.push({ token, value }); + + return `${boundary}${assignment}${quote}${token}${quote}`; + }, + ); + + return { + text, + restore: (processedText) => replacements.reduce( + (restoredText, { token, value }) => restoredText.replaceAll(token, () => value), + processedText, + ), + }; +} export class QuestionProcessor { constructor(markdown, precalculated_values, i18n) { @@ -706,6 +931,12 @@ export class QuestionProcessor { .replace(/(?:\r\n|\r|\n)/g, "
") .replace(/\[_#\]/g, ""); + // Quest's legacy choice transforms scan the question as a string and can + // mistake `(1)` or `[2]` inside an authored aria-label for real responses. + // Hide only those label values during parsing, then restore their exact text. + const authoredAccessibleLabels = tokenizeAccessibleLabelValues(questText); + questText = authoredAccessibleLabels.text; + let counter = 1; questText = questText.replace(/\[\]/g, function () { let t = "[" + counter.toString() + "]"; @@ -812,8 +1043,12 @@ export class QuestionProcessor { // replace |@| with an email input questText = questText.replace(/\|@\|(?:([^\|\<]+[^\|]+)\|)?/g, fEmail); - function fEmail(fullmatch, opts) { - const { options } = guaranteeIdSet(opts, "email"); + function fEmail(fullmatch, opts, offset, source) { + let { options } = guaranteeIdSet(opts, "email"); + options = addGeneratedAccessibleName( + options, + generatedControlNameFromReplace(fullmatch, offset, source, i18n.enterValue), + ); return ``; } @@ -821,16 +1056,20 @@ export class QuestionProcessor { questText = questText.replace(/\|date\|(?:([^\|\<]+[^\|]+)\|)?/g, fDate); questText = questText.replace(/\|month\|(?:([^\|]+)\|)?/g, fMonth); - function fDate(fullmatch, opts) { + function fDate(fullmatch, opts, offset, source) { let type = fullmatch.match(/[^|]+/); let { options, elementId } = guaranteeIdSet(opts, type); - let optionObj = paramSplit(options); + const { + parseableOptions, + accessibleNameOptions, + } = extractAuthoredAccessibleNameOptions(options); + let optionObj = paramSplit(parseableOptions); // can't have the value uri encoded... if (Object.prototype.hasOwnProperty.call(optionObj, "value")) { optionObj.value = decodeURIComponent(optionObj.value); } - options = reduceObj(optionObj); + options = `${reduceObj(optionObj)} ${accessibleNameOptions}`.trim(); if (Object.prototype.hasOwnProperty.call(optionObj, "min")) { options = options + ` data-min-date-uneval=${optionObj.min}` @@ -839,14 +1078,18 @@ export class QuestionProcessor { options = options + ` data-max-date-uneval=${optionObj.max}` } - const descText = type === 'month' ? "Type month and four-digit year" : type === 'date' ? "Select a date" : "Enter the month and year in format: four digit year - two digit month. YYYY-MM"; + const descText = i18n.enterValue; // Adding placeholders and aria-describedby attributes in one line - options += ` placeholder='Select ${type}' aria-describedby='${elementId}-desc' aria-label='Select ${type}'`; + options += ` placeholder='Select ${type}' aria-describedby='${elementId}-desc'`; + options = addGeneratedAccessibleName( + options, + generatedControlNameFromReplace(fullmatch, offset, source, i18n.enterValue), + ); return `${descText}`; } - function fMonth(fullmatch, opts) { + function fMonth(fullmatch, opts, offset, source) { const type = fullmatch.match(/[^|]+/); const { options, elementId } = guaranteeIdSet(opts, type); const questionIDPrefix = questionID.match(idWithLoopSuffixRegex)[1]; @@ -870,8 +1113,11 @@ export class QuestionProcessor { unevaluatedDates.push(`data-max-date-uneval=${optionObj.max}`); } - const descText = "Enter the month and year in format: four digit year - two digit month. YYYY-MM"; - const finalOptions = `${updatedOptions} ${unevaluatedDates.join(' ')} placeholder='Select month' aria-describedby='${elementId}-desc' aria-label='Select month'`; + const descText = i18n.validationMonthFormat; + const finalOptions = addGeneratedAccessibleName( + `${updatedOptions} ${unevaluatedDates.join(' ')} placeholder='Select month' aria-describedby='${elementId}-desc'`, + generatedControlNameFromReplace(fullmatch, offset, source, i18n.enterValue), + ); return `${descText}`; } @@ -879,36 +1125,56 @@ export class QuestionProcessor { // replace |tel| with phone input questText = questText.replace(/\|tel\|(?:([^\|\<]+[^\|]+)\|)?/g, fPhone); - function fPhone(fullmatch, opts) { - const { options } = guaranteeIdSet(opts, "tel"); + function fPhone(fullmatch, opts, offset, source) { + let { options } = guaranteeIdSet(opts, "tel"); + options = addGeneratedAccessibleName( + options, + generatedControlNameFromReplace(fullmatch, offset, source, i18n.enterValue), + ); return ``; } // replace |SSN| with SSN input questText = questText.replace(/\|SSN\|(?:([^\|\<]+[^\|]+)\|)?/g, fSSN); - function fSSN(fullmatch, opts) { - const { options } = guaranteeIdSet(opts, "SSN"); + function fSSN(fullmatch, opts, offset, source) { + let { options } = guaranteeIdSet(opts, "SSN"); + options = addGeneratedAccessibleName( + options, + generatedControlNameFromReplace(fullmatch, offset, source, i18n.enterValue), + ); return ``; } // replace |SSNsm| with SSN input questText = questText.replace(/\|SSNsm\|(?:([^\|\<]+[^\|]+)\|)?/g, fSSNsm); - function fSSNsm(fullmatch, opts) { - const { options } = guaranteeIdSet(opts, "SSNsm"); + function fSSNsm(fullmatch, opts, offset, source) { + let { options } = guaranteeIdSet(opts, "SSNsm"); + options = addGeneratedAccessibleName( + options, + generatedControlNameFromReplace(fullmatch, offset, source, i18n.enterValue), + ); return ``; } // replace |zip| with text input questText = questText.replace(/\|zip\|(?:([^\|\<]+[^\|]+)\|)?/g, fzip); - function fzip(fullmatch, opts) { - const { options, elementId } = guaranteeIdSet(opts, "zip"); + function fzip(fullmatch, opts, offset, source) { + let { options, elementId } = guaranteeIdSet(opts, "zip"); + options = addGeneratedAccessibleName( + options, + generatedControlNameFromReplace(fullmatch, offset, source, i18n.enterValue), + ); return ``; } // replace |state| with state dropdown questText = questText.replace(/\|state\|(?:([^\|\<]+[^\|]+)\|)?/g, fState); - function fState(fullmatch, opts) { - const { options } = guaranteeIdSet(opts, "state"); + function fState(fullmatch, opts, offset, source) { + let { options } = guaranteeIdSet(opts, "state"); + options = addGeneratedAccessibleName( + options, + generatedControlNameFromReplace(fullmatch, offset, source, i18n.chooseState), + ); return ` + ${generatedLabel} + `; } @@ -1091,17 +1367,21 @@ export class QuestionProcessor { // replace |__|__| with a number box... questText = questText.replace(/\|(?:__\|){2,}(?:([^\|\<]+[^\|]+)\|)?/g, fNum); - function fNum(fullmatch, opts) { - const value = questText.startsWith('
') ? questText.split('
')[0] : ''; + function fNum(fullmatch, opts, offset, source) { // make sure that the element id is set... let { options, elementId } = guaranteeIdSet(opts, "num"); - - options = options.replaceAll('"', "'"); + const hasDisplayIfWrapper = Boolean(controlWrapperCondition(source.slice(0, offset))); + const opensFollowingDisplayIf = source + .slice(offset + fullmatch.length) + .startsWith('|displayif='); + + const extractedAccessibleNames = extractAuthoredAccessibleNameOptions(options); + options = extractedAccessibleNames.parseableOptions.replaceAll('"', "'"); //instead of replacing max and min with data-min and data-max, they need to be added, as the up down buttons are needed for input type number let optionObj = paramSplit(options) //replace options with split values (uri encoded) - options = reduceObj(optionObj) + options = `${reduceObj(optionObj)} ${extractedAccessibleNames.accessibleNameOptions}`.trim(); if (Object.prototype.hasOwnProperty.call(optionObj, "min")) { options = options + ` data-min="${optionObj.min}"` } @@ -1126,8 +1406,15 @@ export class QuestionProcessor { let max = evaluateMinMax(optionObj.max); // Build the description text - const descriptionText = `This field accepts numbers. Please enter a whole number ${min && max ? `between ${min} and ${max}` : ''}.`; - const defaultPlaceholder = `placeholder="${moduleParams.i18n.enterValue}"`; + const numberConstraints = []; + if (min) { + numberConstraints.push(i18n.validationNumberGreaterThan.replace('{0}', min)); + } + if (max) { + numberConstraints.push(i18n.validationNumberLessThan.replace('{0}', max)); + } + const descriptionText = numberConstraints.join('. ') || i18n.enterValue; + const defaultPlaceholder = `placeholder="${i18n.enterValue}"`; // Use default placeholder when min to max range is a large distribution, e.g. max weight (999) and max age (125), max pills (100). // Same for min == 0. Show default placeholder for those cases. @@ -1142,10 +1429,27 @@ export class QuestionProcessor { } options += ` ${placeholder} aria-describedby="${elementId}-desc"`; + options = addGeneratedAccessibleName( + options, + generatedControlNameFromReplace(fullmatch, offset, source, i18n.enterValue), + ); - //onkeypress forces whole numbers - return ` -
${descriptionText}

`; + // onkeypress forces whole numbers. Keep the OR operators encoded until + // the browser parses the HTML: a later Quest pass uses raw pipes as + // display-condition delimiters and must not consume generated script. + const input = ``; + const description = `
${descriptionText}

`; + + // A number macro can itself be the content of an authored displayif. + // In the compact `...number||displayif=...` form, the number parser + // consumes the wrapper's closing pipe. Restore it only at that exact + // boundary. Ordinary, already-closed wrappers must not gain a literal pipe. + if (hasDisplayIfWrapper) { + const restoredClosingDelimiter = opensFollowingDisplayIf ? '|' : ''; + return `${input}${description}${restoredClosingDelimiter}`; + } + return `${input} + ${description}`; } // replace |__| or [text box:xxx] with an input box... @@ -1156,12 +1460,27 @@ export class QuestionProcessor { } questText = questText.replace(/(.*)?\|(?:__\|)(?:([^\s<][^|<]+[^\s<])\|)?(.*)?/g, fText); - function fText(fullmatch, value1, opts, value2) { + function fText(fullmatch, value1, opts, value2, offset, source) { let { options } = guaranteeIdSet(opts, "txt"); options = options.replaceAll(/(min|max)len\s*=\s*(\d+)/g,'data-$1len=$2') - - const ariaLabel = i18n.enterValue; - const inputElement = ``; + + const before = source.slice(0, offset); + const inlineCaption = generatedControlName( + value1, + value2, + '', + effectiveControlCondition(fullmatch, before), + ); + options = addGeneratedAccessibleName( + options, + inlineCaption || generatedControlNameFromReplace( + fullmatch, + offset, + source, + i18n.enterValue, + ), + ); + const inputElement = ``; if (value1 && value1.includes('div')) { return `${value1}${inputElement}${value2 || ''}`; @@ -1175,7 +1494,7 @@ export class QuestionProcessor { // replace |___| with a textarea... questText = questText.replace(/\|___\|((\w+)\|)?/g, fTextArea); - function fTextArea(x1, y1, z1) { + function fTextArea(fullmatch, y1, z1, offset, source) { let elId = ""; if (z1 == undefined) { elId = questionID + "_ta"; @@ -1183,8 +1502,13 @@ export class QuestionProcessor { elId = z1; } - return ` - `; + const accessibleName = generatedControlNameFromReplace( + fullmatch, + offset, + source, + i18n.enterValue, + ); + return ``; } // replace #YNP with Yes No input: `(1) Yes, (0) No, (99) Prefer not to answer` @@ -1317,12 +1641,15 @@ export class QuestionProcessor { ); questText = questText.replace( /" + (_, attributes, skipTarget) => ( + `` + ), ); questText = questText.replace(/<\/div>
/g, "
"); + questText = authoredAccessibleLabels.restore(questText); // handle the back/next/reset buttons - const hasInputfield = questText.includes('input'); + const hasInputfield = /<(?:input|textarea)\b/i.test(questText); const questButtonsDiv = this.getButtonDiv(hasInputfield, questionID, endMatch, target); return ` diff --git a/questionnaire.js b/questionnaire.js index 6a3a5b6..7fc3ca7 100644 --- a/questionnaire.js +++ b/questionnaire.js @@ -788,7 +788,7 @@ export async function getNextQuestion(nextOrPreviousButton, revertOnStoreError = questionQueue.next(); } - await swapVisibleQuestion(nextQuestionEle); + await swapVisibleQuestion(nextQuestionEle, { skipAsyncQuestionLoad: revertOnStoreError }); } function exitLoop(nextQuestionEle) { @@ -832,10 +832,11 @@ function exitLoop(nextQuestionEle) { * Update the active question in the DOM. * Identifies the existing question and swaps it with the new question. * If an existing question is not found (this happens on startup), the new question is appended to the parent div. - * @param {HTMLElement} questDiv - The parent div housing the Quest UI. - * @param {string} questionHTMLString - The HTML string of the question to be swapped in. + * @param {HTMLElement} questionEle - The prepared question form to activate. + * @param {object} [options] - Navigation-only preparation options. + * @param {boolean} [options.skipAsyncQuestionLoad=false] - Reuse cached async markup during store rollback. */ -export async function swapVisibleQuestion(questionEle) { +export async function swapVisibleQuestion(questionEle, { skipAsyncQuestionLoad = false } = {}) { // return early if the renderer tool is active and displaying the full question list. if (moduleParams.renderFullQuestionList) return; @@ -859,7 +860,7 @@ export async function swapVisibleQuestion(questionEle) { } // Ensure the question is appended to the active DOM before calling prepareQuestionDOM for accessibility. - await prepareQuestionDOM(questionEle); + await prepareQuestionDOM(questionEle, { skipAsyncQuestionLoad }); return questionEle; } @@ -887,14 +888,14 @@ export function showAllQuestions(allProcessedQuestionsMap) { // Manage the text builder for screen readers (only build when necessary) let questionFocusSet; -export async function prepareQuestionDOM(questionElement) { +export async function prepareQuestionDOM(questionElement, { skipAsyncQuestionLoad = false } = {}) { // Fail gently in the renderer tool. if (!questionElement && !moduleParams.activate) return; // Reset the questionFocusSet flag to reset accessibility features. questionFocusSet = false; // Handle questions in moduleParams.asyncQuestionsMap. These are fetched externally and appended to the DOM. Uncommon. Connect example: SOCcer. - if (Object.keys(moduleParams.asyncQuestionsMap).length > 0 && Object.keys(moduleParams.asyncQuestionsMap).includes(questionElement.id) && moduleParams.fetchAsyncQuestion instanceof Function) { + if (!skipAsyncQuestionLoad && Object.keys(moduleParams.asyncQuestionsMap).length > 0 && Object.keys(moduleParams.asyncQuestionsMap).includes(questionElement.id) && moduleParams.fetchAsyncQuestion instanceof Function) { const fieldset = questionElement.querySelector('fieldset') || questionElement.querySelector('tbody'); await manageAsyncQuestionLoad(fieldset, questionElement.id); } @@ -937,7 +938,7 @@ export async function prepareQuestionDOM(questionElement) { */ function updateProgressBar(questionProcessor) { const progressBar = moduleParams.questDiv.querySelector('.progress-bar'); - const progressText = document.getElementById('progressBarText'); + const progressText = moduleParams.questDiv.querySelector('#progressBarText'); if (!progressBar || !progressText) return; let completionPercentage = 0; @@ -954,7 +955,7 @@ function updateProgressBar(questionProcessor) { } progressBar.style.width = `${completionPercentage}%`; - progressBar.setAttribute('aria-valuenow', `${Math.round(completionPercentage)}%`); + progressBar.setAttribute('aria-valuenow', String(completionPercentage)); progressText.textContent = `${Math.round(completionPercentage)}%`; } } @@ -1266,6 +1267,8 @@ function isMonthInputSupported() { } export async function getPreviousQuestion(nextOrPreviousButton, revertOnStoreError = false) { + const navigationRoot = moduleParams.questDiv; + // Get the previousElement from questionQueue let pv = questionQueue.previous(); while (pv.value.value.substring(0, 9) === "_CONTINUE") { @@ -1282,7 +1285,12 @@ export async function getPreviousQuestion(nextOrPreviousButton, revertOnStoreErr const previousElementID = pv.value.value; const previousQuestionEle = questionProcessor.loadPreviousQuestion(previousElementID); - await swapVisibleQuestion(previousQuestionEle); + await swapVisibleQuestion(previousQuestionEle, { skipAsyncQuestionLoad: revertOnStoreError }); + + if (moduleParams.questDiv !== navigationRoot || !navigationRoot?.contains(previousQuestionEle)) { + return; + } + restoreResponses(appState.getSurveyState(), previousElementID); } diff --git a/restoreResponses.js b/restoreResponses.js index ca056c4..477f25d 100644 --- a/restoreResponses.js +++ b/restoreResponses.js @@ -78,7 +78,8 @@ function handleObjectResponse(formElement, response) { if (handled) return; if (typeof resObject === "string") { - const element = document.getElementById(resKey); + const element = formElement.querySelector(`#${CSS.escape(resKey)}`); + if (!element) return; if (element.tagName == "DIV" || element.tagName == "FORM") { const selector = `input[value='${response[resKey]}']`; const selectedRadioElement = element.querySelector(selector); @@ -126,19 +127,27 @@ function handleStringInObjectResponse(questionElement, id, value) { export function restoreResponses(results, questionID) { const appState = getStateManager(); appState.clearActiveQuestionState(); - - const formElement = document.querySelector("#" + CSS.escape(questionID)); - if (!formElement || !results[questionID]) return; - const response = results[questionID]; + // The tree stores question tokens, including trailing soft/hard markers. + // Rendered form IDs and response keys omit the markers. + const normalizedQuestionID = questionID.replace(/[?!]$/, ''); + const formElement = moduleParams.questDiv?.querySelector( + `form.question[id="${CSS.escape(normalizedQuestionID)}"]`, + ); + if (!formElement || !Object.prototype.hasOwnProperty.call(results, normalizedQuestionID)) return; + + const response = results[normalizedQuestionID]; + // An explicit null/undefined value is the host's deletion tombstone. It is + // valid persisted state, but there is no participant response to restore. + if (response == null) return; // CASE 1: The response is a simple string value. if (typeof response === "string") { handleSimpleStringResponse(formElement, response); // CASE 2: Array - } else if (Array.isArray(results[questionID])) { - getFromRbCb(formElement, questionID, results[questionID]); + } else if (Array.isArray(response)) { + getFromRbCb(formElement, normalizedQuestionID, response); // CASE 3: Object } else if (typeof response === "object") { diff --git a/stateManager.js b/stateManager.js index 3e854af..b35c983 100644 --- a/stateManager.js +++ b/stateManager.js @@ -11,6 +11,22 @@ import { restoreResponses } from './restoreResponses.js'; */ let appState = null; +// Survey responses can contain nested compound values and checkbox arrays. +// Keep state boundaries independent so live edits and delayed host callbacks +// cannot mutate the snapshots used for rollback. +function cloneStateValue(value, seen = new WeakMap()) { + if (value === null || typeof value !== 'object') return value; + if (value instanceof Date) return new Date(value.getTime()); + if (seen.has(value)) return seen.get(value); + + const clone = Array.isArray(value) ? [] : {}; + seen.set(value, clone); + Object.entries(value).forEach(([key, nestedValue]) => { + clone[key] = cloneStateValue(nestedValue, seen); + }); + return clone; +} + /** * Create a new state manager with the provided store and initial state. * The state manager is an object with survey data and methods to set, get, remove, and clear state. @@ -20,8 +36,12 @@ let appState = null; */ const createStateManager = (store, initialState = {}) => { + // The hostStore function belongs to the current render. Rebind it when + // the same manager instance is reused for a sequential render. + let hostStore = store; + let renderGeneration = 0; // The complete survey state with all questions. - let surveyState = { ...initialState }; + let surveyState = cloneStateValue(initialState); // The active question state with the current question's responses. let activeQuestionState = {}; // The the number of response keys for each question. Memoized to avoid re-calculating on each setFormValue call. @@ -85,17 +105,18 @@ const createStateManager = (store, initialState = {}) => { if (removeMultipleKeys) { for (const key in activeQuestionState[questionID]) { const compoundKey = `${key}.${questionID}`; - delete responseToQuestionMappingObj[compoundKey]; - delete foundResponseCache[compoundKey]; + // Retain an explicit cache tombstone until a newer live value + // or render replaces it, so lookups cannot fall back to the + // old value. + foundResponseCache[compoundKey] = undefined; } activeQuestionState[questionID] = undefined; // Clear one key in a multi-value response. } else if (key) { const compoundKey = `${key}.${questionID}`; - activeQuestionState[questionID][key] = undefined; - delete responseToQuestionMappingObj[compoundKey]; - delete foundResponseCache[compoundKey]; + delete activeQuestionState[questionID][key]; + foundResponseCache[compoundKey] = undefined; if (Object.keys(activeQuestionState[questionID]).length === 0) { activeQuestionState[questionID] = undefined; @@ -104,8 +125,7 @@ const createStateManager = (store, initialState = {}) => { // Clear the single value response. } else { activeQuestionState[questionID] = undefined; - delete responseToQuestionMappingObj[questionID]; - delete foundResponseCache[questionID]; + foundResponseCache[questionID] = undefined; } } @@ -113,44 +133,79 @@ const createStateManager = (store, initialState = {}) => { * Return to the previous question and reset the form elements if the store() operation fails. * @param {object} error - the error object from the store function. * @param {HTMLButtonElement} nextOrPreviousButton - the most recent button clicked by the user (Next or Back). - * @param {object} previousSurveyState - the survey state before the failed store operation. - * @param {object} previousActiveQuestionState - the active question state before the failed store operation. + * @param {object} previousSurveyState - the committed survey state before the failed store operation. + * @param {object} previousActiveQuestionState - the participant's live response at the time of the failed write. + * @param {object} previousResponseMapping - response mappings from before optimistic navigation. + * @param {object} previousResponseCache - response cache from before optimistic navigation. + * @param {number} expectedRenderGeneration - render that initiated the host write. */ - function handleStoreError(error, nextOrPreviousButton, previousSurveyState, previousActiveQuestionState) { + async function handleStoreError( + error, + nextOrPreviousButton, + previousSurveyState, + previousActiveQuestionState, + previousResponseMapping, + previousResponseCache, + expectedRenderGeneration, + ) { + if (expectedRenderGeneration !== renderGeneration) return; moduleParams.errorLogger('StateManager -> syncToStore: Error syncing state to store', error); // Clear the selection announcement since the user is returning to the previous question. clearSelectionAnnouncement(); - // Revert the state - if (Object.keys(previousSurveyState).length > 0) { - surveyState = { ...previousSurveyState }; - } - - if (Object.keys(previousActiveQuestionState).length > 0) { - activeQuestionState = { ...previousActiveQuestionState }; - } + // Restore the exact pre-write snapshots, including an intentionally + // empty survey. Conditional assignment here left optimistically merged + // answers behind whenever the participant had no earlier responses. + surveyState = { ...previousSurveyState }; + activeQuestionState = { ...previousActiveQuestionState }; + responseToQuestionMappingObj = { ...previousResponseMapping }; + foundResponseCache = { ...previousResponseCache }; // Reset the form and return to the previous question. const clickType = nextOrPreviousButton.getAttribute('data-click-type'); + const responseQuestionID = Object.keys(previousActiveQuestionState) + .find((key) => key !== 'treeJSON'); + let responsesToRestore = surveyState; + + // A failed Next write should retain the participant's unsaved edit for + // retry. A failed Back write should restore the committed response that + // Back attempted to delete. + if (clickType === 'next' && responseQuestionID) { + responsesToRestore = { + ...surveyState, + [responseQuestionID]: previousActiveQuestionState[responseQuestionID], + }; + } + if (clickType === 'next') { - resetChildren(document.querySelector('.question')); - getPreviousQuestion(nextOrPreviousButton, true); + const currentQuestion = moduleParams.questDiv?.querySelector('.question'); + if (currentQuestion) resetChildren(currentQuestion); + await getPreviousQuestion(nextOrPreviousButton, true); } else if (clickType === 'previous') { - getNextQuestion(nextOrPreviousButton, true); + await getNextQuestion(nextOrPreviousButton, true); } else { moduleParams.errorLogger('Invalid click type (handleStoreError):', clickType); } - restoreResponses(surveyState, Object.keys(activeQuestionState)[0]); + // A new render can start while awaiting an asynchronous host question. Never restore or open a modal in it. + if (expectedRenderGeneration !== renderGeneration) return; + + delete activeQuestionState.treeJSON; + + if (responseQuestionID) { + restoreResponses(responsesToRestore, responseQuestionID); + } showStoreErrorModal(); } function showStoreErrorModal() { - const modal = new bootstrap.Modal(document.getElementById("storeErrorModal")); + const modalElement = moduleParams.questDiv?.querySelector('#storeErrorModal'); + if (!modalElement) return; + const modal = new bootstrap.Modal(modalElement); modal.show(); // Automatically close the modal after 5 seconds. @@ -265,6 +320,8 @@ const createStateManager = (store, initialState = {}) => { }, clearAllState: () => { + // Invalidate pending host callbacks from the cleared render. + renderGeneration += 1; surveyState = {}; activeQuestionState = {}; responseKeysObj = {}; @@ -273,6 +330,19 @@ const createStateManager = (store, initialState = {}) => { questionProcessor = null; }, + // Reset all state while retaining the manager object's + // identity for callers that already hold a reference to it. + reinitialize: (nextStore, nextInitialState = {}) => { + renderGeneration += 1; + hostStore = nextStore; + surveyState = cloneStateValue(nextInitialState); + activeQuestionState = {}; + responseKeysObj = {}; + responseToQuestionMappingObj = {}; + foundResponseCache = {}; + questionProcessor = null; + }, + // Set the active question state to the provided questionID. Important for: (1) return to survey and (2) 'Back' button click. setActiveQuestionState: (questionID) => { if (typeof questionID !== 'string') { @@ -280,7 +350,9 @@ const createStateManager = (store, initialState = {}) => { } if (Object.prototype.hasOwnProperty.call(surveyState, questionID)) { - activeQuestionState = { [questionID]: surveyState[questionID] }; + activeQuestionState = { + [questionID]: cloneStateValue(surveyState[questionID]), + }; } }, @@ -294,7 +366,7 @@ const createStateManager = (store, initialState = {}) => { loadInitialSurveyState: (retrievedData) => { const initialUserData = retrievedData || {}; - surveyState = { ...initialUserData }; + surveyState = cloneStateValue(initialUserData); responseToQuestionMappingObj = generateResponseKeyToQuestionIDMapping(surveyState); foundResponseCache = mapResponseKeysToCache(responseToQuestionMappingObj, surveyState); }, @@ -310,6 +382,8 @@ const createStateManager = (store, initialState = {}) => { syncToStore: (nextOrPreviousButton) => { let previousSurveyState = {}; let previousActiveQuestionState = {}; + let previousResponseMapping = {}; + let previousResponseCache = {}; // check loopData in case it's a loop-controlling question if (Object.keys(activeQuestionState).length === 1) { @@ -322,12 +396,15 @@ const createStateManager = (store, initialState = {}) => { const changedState = {}; Object.keys(activeQuestionState).forEach((key) => { - changedState[`${moduleParams.questName}.${key}`] = activeQuestionState[key]; + // Keep payload that boundary independent from Quest's optimistic state. + changedState[`${moduleParams.questName}.${key}`] = cloneStateValue(activeQuestionState[key]); }); // Store previous state for possible reversion on error - previousSurveyState = { ...surveyState }; - previousActiveQuestionState = { ...activeQuestionState }; + previousSurveyState = cloneStateValue(surveyState); + previousActiveQuestionState = cloneStateValue(activeQuestionState); + previousResponseMapping = cloneStateValue(responseToQuestionMappingObj); + previousResponseCache = cloneStateValue(foundResponseCache); // Update the survey state with the active question state. surveyState = { ...surveyState, ...activeQuestionState }; @@ -340,16 +417,34 @@ const createStateManager = (store, initialState = {}) => { // Use .then() instead of await to avoid blocking the UI. // On error: revert to the previous question and restore the previous state (handleStoreError()). - if (typeof store === 'function') { - store(changedState) + if (typeof hostStore === 'function') { + const syncGeneration = renderGeneration; + const rollbackStoreFailure = (error) => { + if (syncGeneration !== renderGeneration) return; + handleStoreError( + error, + nextOrPreviousButton, + previousSurveyState, + previousActiveQuestionState, + previousResponseMapping, + previousResponseCache, + syncGeneration, + ).catch((rollbackError) => { + if (syncGeneration !== renderGeneration) return; + moduleParams.errorLogger( + 'StateManager -> syncToStore: Error rolling back failed store operation', + rollbackError, + ); + }); + }; + + hostStore(changedState) .then((storeResponse) => { + if (syncGeneration !== renderGeneration) return; if (storeResponse?.code !== 200) { - handleStoreError(storeResponse, nextOrPreviousButton, previousSurveyState, previousActiveQuestionState); + rollbackStoreFailure(storeResponse); } - }) - .catch((error) => { - handleStoreError(error, nextOrPreviousButton, previousSurveyState, previousActiveQuestionState); - }); + }, rollbackStoreFailure); } else { delete activeQuestionState['treeJSON']; } @@ -368,8 +463,8 @@ const createStateManager = (store, initialState = {}) => { [`${moduleParams.questName}.COMPLETED_TS`]: new Date(), }; - if (typeof store === 'function') { - return await store(changedState); + if (typeof hostStore === 'function') { + return await hostStore(changedState); } }, @@ -435,17 +530,20 @@ const createStateManager = (store, initialState = {}) => { // (2) responseKey.responseKey for object structures // (3) responseKey for single value responses. let cachedValue; + let hasCachedValue = false; if (questionID && Object.prototype.hasOwnProperty.call(foundResponseCache, compoundKey)) { cachedValue = foundResponseCache[compoundKey]; + hasCachedValue = true; } else if (Object.prototype.hasOwnProperty.call(foundResponseCache, `${responseKey}.${responseKey}`)) { cachedValue = foundResponseCache[`${responseKey}.${responseKey}`]; + hasCachedValue = true; } else if (Object.prototype.hasOwnProperty.call(foundResponseCache, responseKey)) { cachedValue = foundResponseCache[responseKey]; + hasCachedValue = true; } - if (cachedValue !== null && cachedValue !== undefined && typeof cachedValue !== 'object') { - return cachedValue; - } + // Cache ownership is authoritative. + if (hasCachedValue) return cachedValue; // Check if the responseKey is already in the surveyState object. const existingResponse = surveyState[compoundKey]; @@ -524,6 +622,12 @@ const createStateManager = (store, initialState = {}) => { } else { pathToData = responseToQuestionMappingObj[compoundKey]; } + + // Compound live responses are cached under their mapped key before + // they reach surveyState. Return that value. + if (foundKey && Object.prototype.hasOwnProperty.call(foundResponseCache, foundKey)) { + return foundResponseCache[foundKey]; + } if (!pathToData) return undefined; @@ -560,7 +664,7 @@ export function initializeStateManager(store, initialState = {}) { if (!appState) { appState = createStateManager(store, initialState); } else { - appState.clearAllState(); + appState.reinitialize(store, initialState); } } diff --git a/tests/corpus/module4ConditionalCompoundStructure.spec.js b/tests/corpus/module4ConditionalCompoundStructure.spec.js new file mode 100644 index 0000000..5934264 --- /dev/null +++ b/tests/corpus/module4ConditionalCompoundStructure.spec.js @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; + +import { renderFreshQuest } from '../helpers/questRuntime.js'; +import { readLockedMarkdown } from '../e2e/support/corpus.js'; + +const conditionalCompoundQuestionIds = [ + 'D_733638576', + 'D_532260562', + 'D_440093675', + 'D_786253125', + 'D_968388901', + 'D_679430807', + 'D_135529881', + 'D_219317801', +]; + +const indentedResponseQuestionIds = new Set([ + 'D_135529881', + 'D_219317801', +]); + +const expectedDurationValues = [ + '428999623', + '248303092', + '998679771', + '638092100', + '127455035', +]; + +function normalizeText(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim(); +} + +function safelyDecodeCondition(value) { + let decoded = String(value ?? '').trim(); + + for (let attempt = 0; attempt < 3; attempt += 1) { + let next; + try { + next = decodeURIComponent(decoded); + } catch { + break; + } + if (next === decoded) break; + decoded = next; + } + + return normalizeText(decoded); +} + +function lockedQuestionBlock(markdown, questionId) { + const lines = markdown.split(/\r?\n/); + const header = new RegExp(`^\\[${questionId}(?:[?!])?(?=\\||,|\\])`); + const nextHeader = /^\[[A-Z_][A-Z0-9_#]*[?!]?(?=\||,|\])/; + const start = lines.findIndex((line) => header.test(line)); + expect(start, `Locked Module 4 must contain ${questionId}`).toBeGreaterThanOrEqual(0); + + const relativeEnd = lines.slice(start + 1).findIndex((line) => nextHeader.test(line)); + const end = relativeEnd < 0 ? lines.length : start + 1 + relativeEnd; + return lines.slice(start, end); +} + +function authoredCompoundGroups(questionLines, questionId) { + const promptPattern = /^\s*\|displayif=(.+?)\|\s*(.*?)\|\s*$/; + const responsePattern = /^\s*\((\d+):([A-Z_][A-Z0-9_#]*),displayif=(.+)\)\s+(.+?)\s*$/; + const groups = []; + const groupsByName = new Map(); + let currentPrompt = null; + + for (const line of questionLines.slice(1)) { + const promptMatch = line.match(promptPattern); + if (promptMatch) { + currentPrompt = { + condition: safelyDecodeCondition(promptMatch[1]), + text: normalizeText(promptMatch[2]), + }; + continue; + } + + const responseMatch = line.match(responsePattern); + if (!responseMatch) continue; + + expect(currentPrompt, `${questionId} response ${responseMatch[2]} must follow a subgroup prompt`).not.toBeNull(); + const [, value, name, condition, label] = responseMatch; + let group = groupsByName.get(name); + if (!group) { + group = { name, prompt: currentPrompt, responses: [] }; + groupsByName.set(name, group); + groups.push(group); + } + group.responses.push({ + value, + condition: safelyDecodeCondition(condition), + label: normalizeText(label), + }); + } + + return groups; +} + +function previousConditionalPrompt(firstResponse) { + for (let node = firstResponse.previousSibling; node; node = node.previousSibling) { + if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '') continue; + if (node.nodeType !== Node.ELEMENT_NODE) continue; + if (node.tagName === 'BR') continue; + if (node.matches('.displayif[displayif]')) return node; + if (node.matches('.response')) return null; + } + return null; +} + +describe('locked Module 4 conditional compound-radio structure @corpus', () => { + it.each(['en', 'es'])('preserves all conditional subgroup prompts and native radios in %s', async (locale) => { + const markdown = readLockedMarkdown('module4', locale); + const quest = await renderFreshQuest({ + markdown, + params: { + lang: locale, + showProgressBarInQuest: false, + }, + }); + + expect(quest.rendered).toBe(true); + expect(quest.errors).toEqual([]); + const questionProcessor = quest.state.getQuestionProcessor(); + quest.moduleParams.isRenderer = true; + const accessibility = await import('../../accessibleQuestionTextBuilder.js'); + + for (const questionId of conditionalCompoundQuestionIds) { + const questionLines = lockedQuestionBlock(markdown, questionId); + const authoredGroups = authoredCompoundGroups(questionLines, questionId); + const { question } = questionProcessor.findQuestion(questionId); + + expect(question, `${locale} Module 4 must render ${questionId}`).not.toBeNull(); + expect(authoredGroups, `${questionId} must contain eight authored prompt mappings`).toHaveLength(8); + expect(new Set(authoredGroups.map(({ name }) => name)).size).toBe(8); + expect(authoredGroups.flatMap(({ responses }) => responses)).toHaveLength(40); + + if (indentedResponseQuestionIds.has(questionId)) { + expect( + questionLines.filter((line) => /^\s+\(\d+:/.test(line)), + `${questionId} must retain its 40 indented authored response lines`, + ).toHaveLength(40); + } + + const radios = Array.from(question.querySelectorAll('input[type="radio"]')); + expect(radios, `${questionId} must render all 40 native radios`).toHaveLength(40); + expect(new Set(radios.map(({ name }) => name))).toEqual( + new Set(authoredGroups.map(({ name }) => name)), + ); + + const mappedPrompts = new Set(); + for (const authoredGroup of authoredGroups) { + expect(authoredGroup.responses).toHaveLength(5); + expect(authoredGroup.responses.map(({ value }) => value)).toEqual(expectedDurationValues); + expect(new Set(authoredGroup.responses.map(({ condition }) => condition))).toEqual( + new Set([authoredGroup.prompt.condition]), + ); + + const groupRadios = radios.filter(({ name }) => name === authoredGroup.name); + expect(groupRadios, `${questionId}.${authoredGroup.name} must render five radios`).toHaveLength(5); + expect(groupRadios.map(({ value }) => value)).toEqual(expectedDurationValues); + + const firstResponse = groupRadios[0].closest('.response'); + const prompt = previousConditionalPrompt(firstResponse); + expect(prompt, `${questionId}.${authoredGroup.name} must retain its preceding prompt`).not.toBeNull(); + mappedPrompts.add(prompt); + expect(normalizeText(prompt.textContent)).toBe(authoredGroup.prompt.text); + expect(safelyDecodeCondition(prompt.getAttribute('displayif'))).toBe( + authoredGroup.prompt.condition, + ); + + for (const [index, input] of groupRadios.entries()) { + const authoredResponse = authoredGroup.responses[index]; + const response = input.closest('.response'); + const labels = Array.from(input.labels); + + expect(input.type).toBe('radio'); + expect(input.name).toBe(authoredGroup.name); + expect(input.value).toBe(authoredResponse.value); + expect(input.id).toBe(`${authoredGroup.name}_${authoredResponse.value}`); + expect(input.hasAttribute('aria-label')).toBe(false); + expect(input.hasAttribute('aria-labelledby')).toBe(false); + expect(labels).toHaveLength(1); + expect(labels[0].htmlFor).toBe(input.id); + expect(normalizeText(labels[0].textContent)).toBe(authoredResponse.label); + expect(safelyDecodeCondition(response.getAttribute('displayif'))).toBe( + authoredGroup.prompt.condition, + ); + } + } + + expect(mappedPrompts, `${questionId} must map to eight distinct subgroup prompts`).toHaveLength(8); + + const fieldset = question.querySelector(':scope > fieldset'); + accessibility.manageAccessibleQuestion(fieldset, false); + const semanticGroups = Array.from(fieldset.querySelectorAll(':scope > [role="radiogroup"]')); + expect(semanticGroups, `${questionId} must expose all eight conditional groups`).toHaveLength(8); + for (const group of semanticGroups) { + const ownedIds = group.getAttribute('aria-owns').split(/\s+/).filter(Boolean); + const ownedRadios = ownedIds.map((id) => question.querySelector(`#${id}`)); + expect(group.getAttribute('aria-label')).toBe(normalizeText(group.textContent)); + expect(group.hasAttribute('tabindex')).toBe(false); + expect(ownedIds).toHaveLength(5); + expect(ownedRadios.every(Boolean)).toBe(true); + expect(new Set(ownedRadios.map(({ name }) => name))).toHaveLength(1); + expect(ownedRadios.every((radio) => radio.closest('.response').parentElement === fieldset)).toBe(true); + } + } + }); +}); diff --git a/tests/corpus/productionMarkupEdgeCases.spec.js b/tests/corpus/productionMarkupEdgeCases.spec.js index f98ee99..cc5cbec 100644 --- a/tests/corpus/productionMarkupEdgeCases.spec.js +++ b/tests/corpus/productionMarkupEdgeCases.spec.js @@ -9,6 +9,7 @@ const corpusLock = JSON.parse(readFileSync(path.join(import.meta.dirname, 'lock. const hostPersonas = JSON.parse(readFileSync(path.join(import.meta.dirname, 'hostPersonas.json'), 'utf8')); const profilePersona = hostPersonas.personas.find(({ id }) => id === 'external-branch-primary'); const lockedCorpusRoot = path.join(repositoryRoot, corpusLock.cacheRoot, corpusLock.commit); +const WHOLE_NUMBER_KEYPRESS_HANDLER = 'return (event.charCode == 8 || event.charCode == 0 || event.charCode == 13) ? null : event.charCode >= 48 && event.charCode <= 57'; function lockedMarkdown(file) { const record = corpusLock.files.find(({ path: sourcePath }) => sourcePath === `prod/${file}`); @@ -59,6 +60,186 @@ async function renderLockedFullList(file) { } describe('locked production markup edge cases', () => { + it.each([ + { + locale: 'en', + module1File: 'module1.txt', + module4File: 'module4.txt', + diagnosisLabels: ['Age at diagnosis', 'Year at diagnosis'], + weightLabels: [ + 'a. 18 years old, Pounds (lbs)', + 'b. 25 years old, Pounds (lbs)', + 'c. 35 years old, Pounds (lbs)', + 'd. 45 years old, Pounds (lbs)', + 'e. 55 years old, Pounds (lbs)', + ], + addressLabels: [ + 'Street number', + 'Full Street name', + 'Apartment, suite, unit, building, etc.', + 'City', + 'State/Province', + 'Zip code', + 'Country', + ], + backupAddressLabels: ['City:', 'State/Province:', 'Zip code:', 'Country:'], + dailyFrequencyLabel: 'times per day', + }, + { + locale: 'es', + module1File: 'module1Spanish.txt', + module4File: 'module4Spanish.txt', + diagnosisLabels: ['Edad', 'Año'], + weightLabels: [ + 'a. 18 años, NÚM. DE LIBRAS (lbs)', + 'b. 25 años, NÚM. DE LIBRAS (lbs)', + 'c. 35 años, NÚM. DE LIBRAS (lbs)', + 'd. 45 años, NÚM. DE LIBRAS (lbs)', + 'e. 55 años, NÚM. DE LIBRAS (lbs)', + ], + addressLabels: [ + 'Número de la calle', + 'Nombre completo de la calle', + 'Apartamento, suite, unidad, edificio, etc.', + 'Ciudad', + 'Estado o provincia', + 'Código postal', + 'País', + ], + backupAddressLabels: ['Ciudad:', 'Estado o provincia:', 'Código postal:', 'País:'], + dailyFrequencyLabel: 'veces al día', + }, + ])('preserves distinct scalar names in locked production compound forms ($locale)', async ({ + locale, + module1File, + module4File, + diagnosisLabels, + weightLabels, + addressLabels, + backupAddressLabels, + dailyFrequencyLabel, + }) => { + const markdown = survey( + `LOCKED_SCALAR_NAMES_${locale.toUpperCase()}`, + lockedQuestion(module1File, 'D_904550680'), + lockedQuestion(module1File, 'D_912857732'), + lockedQuestion(module4File, 'D_121490150'), + lockedQuestion(module4File, 'D_920576363'), + lockedQuestion(module4File, 'D_138116092'), + ); + const quest = await renderFreshQuest({ + markdown, + params: { + activate: false, + isRenderer: true, + lang: locale, + showProgressBarInQuest: false, + }, + }); + + const diagnosis = quest.root.querySelector('#D_904550680'); + const diagnosisInputs = Array.from(diagnosis.querySelectorAll('input[type="number"]')); + expect(diagnosisInputs.map(({ id }) => id)).toEqual(['D_206625031', 'D_261863326']); + expect(diagnosisInputs.map(({ name }) => name)).toEqual(['D_904550680', 'D_904550680']); + expect(diagnosisInputs.map((input) => input.getAttribute('aria-label'))).toEqual(diagnosisLabels); + + const weight = quest.root.querySelector('#D_912857732'); + const weightInputs = Array.from(weight.querySelectorAll('input[type="number"]')); + expect(weightInputs.map(({ id }) => id)).toEqual([ + 'D_821387277', + 'D_121646540', + 'D_950080618', + 'D_407167089', + 'D_503154158', + ]); + expect(weightInputs.map((input) => input.getAttribute('aria-label'))).toEqual(weightLabels); + expect(new Set(weightInputs.map((input) => input.getAttribute('aria-label'))).size).toBe(5); + expect(weightInputs.map((input) => input.getAttribute('onkeypress'))) + .toEqual(Array(5).fill(WHOLE_NUMBER_KEYPRESS_HANDLER)); + expect(weight.textContent).not.toContain('|displayif='); + if (locale === 'es') { + expect(weightInputs.map((input) => ( + input.closest('.displayif')?.getAttribute('displayif').replace(/\s+/g, '') + ))).toEqual([ + "_value('age')>=18", + "_value('age')>=25", + "_value('age')>=35", + "_value('age')>=45", + "_value('age')>=55", + ]); + } + + const address = quest.root.querySelector('#D_121490150'); + const addressInputs = Array.from(address.querySelectorAll('input[type="text"]')); + expect(addressInputs.map(({ id }) => id)).toEqual([ + 'D_255248624', + 'D_945532934', + 'D_469838242', + 'D_303500597', + 'D_195068098', + 'D_202784871', + 'D_831127170', + ]); + expect(addressInputs.map(({ name }) => name)).toEqual(Array(7).fill('D_121490150')); + expect(addressInputs.map((input) => input.getAttribute('aria-label'))).toEqual(addressLabels); + expect(new Set(addressInputs.map((input) => input.getAttribute('aria-label'))).size).toBe(7); + + const backupAddress = quest.root.querySelector('#D_920576363'); + const backupAddressInputs = Array.from(backupAddress.querySelectorAll('input[type="text"]')); + expect(backupAddressInputs.map(({ id }) => id)).toEqual([ + 'D_725583683', + 'D_917021073', + 'D_970000442', + 'D_500100435', + ]); + expect(backupAddressInputs.map((input) => input.getAttribute('aria-label'))).toEqual(backupAddressLabels); + expect(new Set(backupAddressInputs.map((input) => input.getAttribute('aria-label'))).size).toBe(4); + + const dailyFrequency = quest.root.querySelector('#D_138116092 #D_386624051'); + expect(dailyFrequency.getAttribute('aria-label')).toBe(dailyFrequencyLabel); + expect(quest.errors).toEqual([]); + }); + + it.each([ + { + locale: 'en', + file: 'module3.txt', + accessibleName: 'Pipe fills smoked per day', + conditionCount: 4, + }, + { + locale: 'es', + file: 'module3Spanish.txt', + accessibleName: 'Introduzca un valor', + conditionCount: 6, + }, + ])('keeps dynamic scalar captions live and derives only safe accessible names ($locale)', async ({ + locale, + file, + accessibleName, + conditionCount, + }) => { + const quest = await renderFreshQuest({ + markdown: survey( + `LOCKED_DYNAMIC_SCALAR_${locale.toUpperCase()}`, + lockedQuestion(file, 'D_780767323'), + ), + params: { + activate: false, + isRenderer: true, + lang: locale, + showProgressBarInQuest: false, + }, + }); + const question = quest.root.querySelector('#D_780767323'); + const input = question.querySelector('input[type="number"]'); + + expect(input.getAttribute('aria-label')).toBe(accessibleName); + expect(input.hasAttribute('displayif')).toBe(false); + expect(question.querySelectorAll('[displayif]')).toHaveLength(conditionCount); + expect(quest.errors).toEqual([]); + }); + it('renders each production popup variant without re-authoring its question block', async () => { const titlelessBlock = lockedQuestion('module2.txt', 'D_674976924'); const conditionalBlock = lockedQuestion('module2.txt', 'D_486574018'); diff --git a/tests/corpus/structuralCatalog.json b/tests/corpus/structuralCatalog.json index 2fc6800..ad1050c 100644 --- a/tests/corpus/structuralCatalog.json +++ b/tests/corpus/structuralCatalog.json @@ -18,7 +18,7 @@ "processorQuestionCount": 8302, "processedQuestionCount": 8302, "renderedFormCount": 8302, - "controlCount": 53169, + "controlCount": 53174, "responseControlCount": 27160, "explicitTransitionCount": 7586, "sequentialAdjacencyCount": 8273, @@ -41,7 +41,7 @@ "processorQuestionCount": 3996, "processedQuestionCount": 3996, "renderedFormCount": 3996, - "controlCount": 23386, + "controlCount": 23390, "responseControlCount": 11220, "explicitTransitionCount": 3716, "sequentialAdjacencyCount": 3994, @@ -182,7 +182,7 @@ "processorQuestionCount": 32, "processedQuestionCount": 32, "renderedFormCount": 32, - "controlCount": 235, + "controlCount": 236, "responseControlCount": 158, "explicitTransitionCount": 0, "sequentialAdjacencyCount": 30, @@ -7236,7 +7236,7 @@ "processorQuestionCount": 1998, "processedQuestionCount": 1998, "renderedFormCount": 1998, - "controlCount": 11726, + "controlCount": 11728, "responseControlCount": 5610, "explicitTransitionCount": 1885, "sequentialAdjacencyCount": 1997, @@ -13828,7 +13828,7 @@ "processorQuestionCount": 1998, "processedQuestionCount": 1998, "renderedFormCount": 1998, - "controlCount": 11660, + "controlCount": 11662, "responseControlCount": 5610, "explicitTransitionCount": 1831, "sequentialAdjacencyCount": 1997, @@ -15480,11 +15480,11 @@ "valueEquals(\"D_613744428\",353358909)", "equals(\"D_613744428\",353358909)", "someSelected(\"D_980120253_1\",\"D_980120253_2\",\"D_980120253_3\",\"D_980120253_4\",\"D_980120253_5\",\"D_980120253_6\",\"D_993029890_1\",\"D_993029890_2\",\"D_993029890_3\",\"D_993029890_4\",\"D_993029890_5\",\"D_993029890_6\",\"D_980196073_1\",\"D_980196073_2\",\"D_980196073_3\",\"D_980196073_4\",\"D_980196073_5\",\"D_980196073_6\",\"D_115422925_1\",\"D_115422925_2\",\"D_115422925_3\",\"D_115422925_4\",\"D_115422925_5\",\"D_115422925_6\",\"D_151161693_1\",\"D_151161693_2\",\"D_151161693_3\",\"D_151161693_4\",\"D_151161693_5\",\"D_151161693_6\")", - "someSelected(%22D_980120253_1%22%2C%22D_980120253_2%22%2C%22D_980120253_3%22%2C%22D_980120253_4%22%2C%22D_980120253_5%22%2C%22D_980120253_6%22)", - "someSelected(%22D_993029890_1%22%2C%22D_993029890_2%22%2C%22D_993029890_3%22%2C%22D_993029890_4%22%2C%22D_993029890_5%22%2C%22D_993029890_6%22)", - "someSelected(%22D_980196073_1%22%2C%22D_980196073_2%22%2C%22D_980196073_3%22%2C%22D_980196073_4%22%2C%22D_980196073_5%22%2C%22D_980196073_6%22)", - "someSelected(%22D_115422925_1%22%2C%22D_115422925_2%22%2C%22D_115422925_3%22%2C%22D_115422925_4%22%2C%22D_115422925_5%22%2C%22D_115422925_6%22)", - "someSelected(%22D_151161693_1%22%2C%22D_151161693_2%22%2C%22D_151161693_3%22%2C%22D_151161693_4%22%2C%22D_151161693_5%22%2C%22D_151161693_6%22)", + "someSelected(\"D_980120253_1\",\"D_980120253_2\",\"D_980120253_3\",\"D_980120253_4\",\"D_980120253_5\",\"D_980120253_6\")", + "someSelected(\"D_993029890_1\",\"D_993029890_2\",\"D_993029890_3\",\"D_993029890_4\",\"D_993029890_5\",\"D_993029890_6\")", + "someSelected(\"D_980196073_1\",\"D_980196073_2\",\"D_980196073_3\",\"D_980196073_4\",\"D_980196073_5\",\"D_980196073_6\")", + "someSelected(\"D_115422925_1\",\"D_115422925_2\",\"D_115422925_3\",\"D_115422925_4\",\"D_115422925_5\",\"D_115422925_6\")", + "someSelected(\"D_151161693_1\",\"D_151161693_2\",\"D_151161693_3\",\"D_151161693_4\",\"D_151161693_5\",\"D_151161693_6\")", "equals(D_204421360,797221287)", "doesNotEqual(numberOfChoicesSelected(D_442059084,0),1)", "equals(D_204421360,500744195)", @@ -16190,7 +16190,7 @@ "processorQuestionCount": 16, "processedQuestionCount": 16, "renderedFormCount": 16, - "controlCount": 117, + "controlCount": 118, "responseControlCount": 79, "explicitTransitionCount": 0, "sequentialAdjacencyCount": 15, @@ -17875,11 +17875,11 @@ "valueEquals(\"D_613744428\",353358909)", "equals(\"D_613744428\",353358909)", "someSelected(\"D_980120253_1\",\"D_980120253_2\",\"D_980120253_3\",\"D_980120253_4\",\"D_980120253_5\",\"D_980120253_6\",\"D_993029890_1\",\"D_993029890_2\",\"D_993029890_3\",\"D_993029890_4\",\"D_993029890_5\",\"D_993029890_6\",\"D_980196073_1\",\"D_980196073_2\",\"D_980196073_3\",\"D_980196073_4\",\"D_980196073_5\",\"D_980196073_6\",\"D_115422925_1\",\"D_115422925_2\",\"D_115422925_3\",\"D_115422925_4\",\"D_115422925_5\",\"D_115422925_6\",\"D_151161693_1\",\"D_151161693_2\",\"D_151161693_3\",\"D_151161693_4\",\"D_151161693_5\",\"D_151161693_6\")", - "someSelected(%22D_980120253_1%22%2C%22D_980120253_2%22%2C%22D_980120253_3%22%2C%22D_980120253_4%22%2C%22D_980120253_5%22%2C%22D_980120253_6%22)", - "someSelected(%22D_993029890_1%22%2C%22D_993029890_2%22%2C%22D_993029890_3%22%2C%22D_993029890_4%22%2C%22D_993029890_5%22%2C%22D_993029890_6%22)", - "someSelected(%22D_980196073_1%22%2C%22D_980196073_2%22%2C%22D_980196073_3%22%2C%22D_980196073_4%22%2C%22D_980196073_5%22%2C%22D_980196073_6%22)", - "someSelected(%22D_115422925_1%22%2C%22D_115422925_2%22%2C%22D_115422925_3%22%2C%22D_115422925_4%22%2C%22D_115422925_5%22%2C%22D_115422925_6%22)", - "someSelected(%22D_151161693_1%22%2C%22D_151161693_2%22%2C%22D_151161693_3%22%2C%22D_151161693_4%22%2C%22D_151161693_5%22%2C%22D_151161693_6%22)", + "someSelected(\"D_980120253_1\",\"D_980120253_2\",\"D_980120253_3\",\"D_980120253_4\",\"D_980120253_5\",\"D_980120253_6\")", + "someSelected(\"D_993029890_1\",\"D_993029890_2\",\"D_993029890_3\",\"D_993029890_4\",\"D_993029890_5\",\"D_993029890_6\")", + "someSelected(\"D_980196073_1\",\"D_980196073_2\",\"D_980196073_3\",\"D_980196073_4\",\"D_980196073_5\",\"D_980196073_6\")", + "someSelected(\"D_115422925_1\",\"D_115422925_2\",\"D_115422925_3\",\"D_115422925_4\",\"D_115422925_5\",\"D_115422925_6\")", + "someSelected(\"D_151161693_1\",\"D_151161693_2\",\"D_151161693_3\",\"D_151161693_4\",\"D_151161693_5\",\"D_151161693_6\")", "equals(D_204421360,797221287)", "equals(numberOfChoicesSelected(D_442059084,0),1)", "greaterThan(numberOfChoicesSelected(D_442059084,0),1)", @@ -19016,13 +19016,13 @@ "(exists(\"D_663551714\") and ((valueEquals(\"D_559388168\",132232896) and valueEquals(\"D_458395129\",804785430)) and (valueOrDefault(\"D_663551714\",69)>=70 or valueOrDefault(\"age\",69)>=70))) or (allExist(\"D_663551714\",\"D_780685299\") and ((valueEquals(\"D_559388168\",132232896) and valueEquals(\"D_311569789\",353358909)) and (valueOrDefault(\"D_663551714\",69)>=70 or valueOrDefault(\"D_780685299\",69)>=70)))", "greaterThanOrEqual(isDefined(D_663551714,-1),70)", "lessThan(isDefined(D_663551714,-1),70)", - "greaterThan(difference(age%2C2)%2C18)", - "greaterThan(difference(age%2C2)%2C25)", - "greaterThan(difference(age%2C2)%2C30)", - "greaterThan(difference(age%2C2)%2C40)", - "greaterThan(difference(age%2C2)%2C50)", - "greaterThan(difference(age%2C2)%2C60)", - "greaterThanOrEqual(age%2C70)", + "greaterThan(difference(age,2),18)", + "greaterThan(difference(age,2),25)", + "greaterThan(difference(age,2),30)", + "greaterThan(difference(age,2),40)", + "greaterThan(difference(age,2),50)", + "greaterThan(difference(age,2),60)", + "greaterThanOrEqual(age,70)", "equals(D_830608495,760521319)", "doesNotExist('D_180308733') or valueEquals('D_180308733',317567178)", "valueIsOneOf('D_180308733',484055234,802197176)", @@ -19315,23 +19315,23 @@ "equals(isDefined(D_141874857,-1),-1)", "doesNotExist(\"D_480426504\")", "exists(\"D_480426504\")", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D0%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D17", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D18%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D24", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D25%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D29", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D30%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D39", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D40%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D49", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D50%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D59", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D60%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D69", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D70", + "valueOrDefault(\"D_480426504\",\"age\")>=0 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=17", + "valueOrDefault(\"D_480426504\",\"age\")>=18 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=24", + "valueOrDefault(\"D_480426504\",\"age\")>=25 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=29", + "valueOrDefault(\"D_480426504\",\"age\")>=30 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=39", + "valueOrDefault(\"D_480426504\",\"age\")>=40 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=49", + "valueOrDefault(\"D_480426504\",\"age\")>=50 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=59", + "valueOrDefault(\"D_480426504\",\"age\")>=60 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=69", + "valueOrDefault(\"D_480426504\",\"age\")>=70", "someSelected(\"D_294629316_1\",\"D_294629316_2\",\"D_294629316_3\",\"D_294629316_4\",\"D_294629316_5\",\"D_294629316_6\",\"D_294629316_7\",\"D_294629316_8\",\"D_164707243_1\",\"D_164707243_2\",\"D_164707243_3\",\"D_164707243_4\",\"D_164707243_5\",\"D_164707243_6\",\"D_164707243_7\",\"D_164707243_8\",\"D_771426895_1\",\"D_771426895_2\",\"D_771426895_3\",\"D_771426895_4\",\"D_771426895_5\",\"D_771426895_6\",\"D_771426895_7\",\"D_771426895_8\",\"D_818310825_1\",\"D_818310825_2\",\"D_818310825_3\",\"D_818310825_4\",\"D_818310825_5\",\"D_818310825_6\",\"D_818310825_7\",\"D_818310825_8\",\"D_843593800_1\",\"D_843593800_2\",\"D_843593800_3\",\"D_843593800_4\",\"D_843593800_5\",\"D_843593800_6\",\"D_843593800_7\",\"D_843593800_8\",\"D_175385712_1\",\"D_175385712_2\",\"D_175385712_3\",\"D_175385712_4\",\"D_175385712_5\",\"D_175385712_6\",\"D_175385712_7\",\"D_175385712_8\",\"D_772143730_1\",\"D_772143730_2\",\"D_772143730_3\",\"D_772143730_4\",\"D_772143730_5\",\"D_772143730_6\",\"D_772143730_7\",\"D_772143730_8\",\"D_602102163_1\",\"D_602102163_2\",\"D_602102163_3\",\"D_602102163_4\",\"D_602102163_5\",\"D_602102163_6\",\"D_602102163_7\",\"D_602102163_8\")", - "someSelected(%22D_294629316_1%22%2C%22D_294629316_2%22%2C%22D_294629316_3%22%2C%22D_294629316_4%22%2C%22D_294629316_5%22%2C%22D_294629316_6%22%2C%22D_294629316_7%22%2C%22D_294629316_8%22)", - "someSelected(%22D_164707243_1%22%2C%22D_164707243_2%22%2C%22D_164707243_3%22%2C%22D_164707243_4%22%2C%22D_164707243_5%22%2C%22D_164707243_6%22%2C%22D_164707243_7%22%2C%22D_164707243_8%22)", - "someSelected(%22D_771426895_1%22%2C%22D_771426895_2%22%2C%22D_771426895_3%22%2C%22D_771426895_4%22%2C%22D_771426895_5%22%2C%22D_771426895_6%22%2C%22D_771426895_7%22%2C%22D_771426895_8%22)", - "someSelected(%22D_818310825_1%22%2C%22D_818310825_2%22%2C%22D_818310825_3%22%2C%22D_818310825_4%22%2C%22D_818310825_5%22%2C%22D_818310825_6%22%2C%22D_818310825_7%22%2C%22D_818310825_8%22)", - "someSelected(%22D_843593800_1%22%2C%22D_843593800_2%22%2C%22D_843593800_3%22%2C%22D_843593800_4%22%2C%22D_843593800_5%22%2C%22D_843593800_6%22%2C%22D_843593800_7%22%2C%22D_843593800_8%22)", - "someSelected(%22D_175385712_1%22%2C%22D_175385712_2%22%2C%22D_175385712_3%22%2C%22D_175385712_4%22%2C%22D_175385712_5%22%2C%22D_175385712_6%22%2C%22D_175385712_7%22%2C%22D_175385712_8%22)", - "someSelected(%22D_772143730_1%22%2C%22D_772143730_2%22%2C%22D_772143730_3%22%2C%22D_772143730_4%22%2C%22D_772143730_5%22%2C%22D_772143730_6%22%2C%22D_772143730_7%22%2C%22D_772143730_8%22)", - "someSelected(%22D_602102163_1%22%2C%22D_602102163_2%22%2C%22D_602102163_3%22%2C%22D_602102163_4%22%2C%22D_602102163_5%22%2C%22D_602102163_6%22%2C%22D_602102163_7%22%2C%22D_602102163_8%22)", + "someSelected(\"D_294629316_1\",\"D_294629316_2\",\"D_294629316_3\",\"D_294629316_4\",\"D_294629316_5\",\"D_294629316_6\",\"D_294629316_7\",\"D_294629316_8\")", + "someSelected(\"D_164707243_1\",\"D_164707243_2\",\"D_164707243_3\",\"D_164707243_4\",\"D_164707243_5\",\"D_164707243_6\",\"D_164707243_7\",\"D_164707243_8\")", + "someSelected(\"D_771426895_1\",\"D_771426895_2\",\"D_771426895_3\",\"D_771426895_4\",\"D_771426895_5\",\"D_771426895_6\",\"D_771426895_7\",\"D_771426895_8\")", + "someSelected(\"D_818310825_1\",\"D_818310825_2\",\"D_818310825_3\",\"D_818310825_4\",\"D_818310825_5\",\"D_818310825_6\",\"D_818310825_7\",\"D_818310825_8\")", + "someSelected(\"D_843593800_1\",\"D_843593800_2\",\"D_843593800_3\",\"D_843593800_4\",\"D_843593800_5\",\"D_843593800_6\",\"D_843593800_7\",\"D_843593800_8\")", + "someSelected(\"D_175385712_1\",\"D_175385712_2\",\"D_175385712_3\",\"D_175385712_4\",\"D_175385712_5\",\"D_175385712_6\",\"D_175385712_7\",\"D_175385712_8\")", + "someSelected(\"D_772143730_1\",\"D_772143730_2\",\"D_772143730_3\",\"D_772143730_4\",\"D_772143730_5\",\"D_772143730_6\",\"D_772143730_7\",\"D_772143730_8\")", + "someSelected(\"D_602102163_1\",\"D_602102163_2\",\"D_602102163_3\",\"D_602102163_4\",\"D_602102163_5\",\"D_602102163_6\",\"D_602102163_7\",\"D_602102163_8\")", "equals(equals(D_605063358,353358909),false)", "equals(D_605063358,353358909)" ], @@ -20310,13 +20310,13 @@ "(exists(\"D_663551714\") and ((valueEquals(\"D_559388168\",132232896) and valueEquals(\"D_458395129\",804785430)) and (valueOrDefault(\"D_663551714\",69)>=70 or valueOrDefault(\"age\",69)>=70))) or (allExist(\"D_663551714\",\"D_780685299\") and ((valueEquals(\"D_559388168\",132232896) and valueEquals(\"D_311569789\",353358909)) and (valueOrDefault(\"D_663551714\",69)>=70 or valueOrDefault(\"D_780685299\",69)>=70)))", "greaterThanOrEqual(isDefined(D_663551714,-1),70)", "lessThan(isDefined(D_663551714,-1),70)", - "greaterThan(difference(age%2C2)%2C18)", - "greaterThan(difference(age%2C2)%2C25)", - "greaterThan(difference(age%2C2)%2C30)", - "greaterThan(difference(age%2C2)%2C40)", - "greaterThan(difference(age%2C2)%2C50)", - "greaterThan(difference(age%2C2)%2C60)", - "greaterThanOrEqual(age%2C70)", + "greaterThan(difference(age,2),18)", + "greaterThan(difference(age,2),25)", + "greaterThan(difference(age,2),30)", + "greaterThan(difference(age,2),40)", + "greaterThan(difference(age,2),50)", + "greaterThan(difference(age,2),60)", + "greaterThanOrEqual(age,70)", "equals(D_830608495,760521319)", "doesNotExist('D_180308733') or valueEquals('D_180308733',317567178)", "valueIsOneOf('D_180308733',484055234,802197176)", @@ -20606,23 +20606,23 @@ "equals(isDefined(D_141874857,-1),-1)", "doesNotExist(\"D_480426504\")", "exists(\"D_480426504\")", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D0%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D17", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D18%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D24", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D25%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D29", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D30%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D39", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D40%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D49", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D50%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D59", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D60%20and%20valueOrDefault(%22D_141874857%22%2C%22D_250456537%22)%3C%3D69", - "valueOrDefault(%22D_480426504%22%2C%22age%22)%3E%3D70", + "valueOrDefault(\"D_480426504\",\"age\")>=0 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=17", + "valueOrDefault(\"D_480426504\",\"age\")>=18 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=24", + "valueOrDefault(\"D_480426504\",\"age\")>=25 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=29", + "valueOrDefault(\"D_480426504\",\"age\")>=30 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=39", + "valueOrDefault(\"D_480426504\",\"age\")>=40 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=49", + "valueOrDefault(\"D_480426504\",\"age\")>=50 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=59", + "valueOrDefault(\"D_480426504\",\"age\")>=60 and valueOrDefault(\"D_141874857\",\"D_250456537\")<=69", + "valueOrDefault(\"D_480426504\",\"age\")>=70", "someSelected(\"D_294629316_1\",\"D_294629316_2\",\"D_294629316_3\",\"D_294629316_4\",\"D_294629316_5\",\"D_294629316_6\",\"D_294629316_7\",\"D_294629316_8\",\"D_164707243_1\",\"D_164707243_2\",\"D_164707243_3\",\"D_164707243_4\",\"D_164707243_5\",\"D_164707243_6\",\"D_164707243_7\",\"D_164707243_8\",\"D_771426895_1\",\"D_771426895_2\",\"D_771426895_3\",\"D_771426895_4\",\"D_771426895_5\",\"D_771426895_6\",\"D_771426895_7\",\"D_771426895_8\",\"D_818310825_1\",\"D_818310825_2\",\"D_818310825_3\",\"D_818310825_4\",\"D_818310825_5\",\"D_818310825_6\",\"D_818310825_7\",\"D_818310825_8\",\"D_843593800_1\",\"D_843593800_2\",\"D_843593800_3\",\"D_843593800_4\",\"D_843593800_5\",\"D_843593800_6\",\"D_843593800_7\",\"D_843593800_8\",\"D_175385712_1\",\"D_175385712_2\",\"D_175385712_3\",\"D_175385712_4\",\"D_175385712_5\",\"D_175385712_6\",\"D_175385712_7\",\"D_175385712_8\",\"D_772143730_1\",\"D_772143730_2\",\"D_772143730_3\",\"D_772143730_4\",\"D_772143730_5\",\"D_772143730_6\",\"D_772143730_7\",\"D_772143730_8\",\"D_602102163_1\",\"D_602102163_2\",\"D_602102163_3\",\"D_602102163_4\",\"D_602102163_5\",\"D_602102163_6\",\"D_602102163_7\",\"D_602102163_8\")", - "someSelected(%22D_294629316_1%22%2C%22D_294629316_2%22%2C%22D_294629316_3%22%2C%22D_294629316_4%22%2C%22D_294629316_5%22%2C%22D_294629316_6%22%2C%22D_294629316_7%22%2C%22D_294629316_8%22)", - "someSelected(%22D_164707243_1%22%2C%22D_164707243_2%22%2C%22D_164707243_3%22%2C%22D_164707243_4%22%2C%22D_164707243_5%22%2C%22D_164707243_6%22%2C%22D_164707243_7%22%2C%22D_164707243_8%22)", - "someSelected(%22D_771426895_1%22%2C%22D_771426895_2%22%2C%22D_771426895_3%22%2C%22D_771426895_4%22%2C%22D_771426895_5%22%2C%22D_771426895_6%22%2C%22D_771426895_7%22%2C%22D_771426895_8%22)", - "someSelected(%22D_818310825_1%22%2C%22D_818310825_2%22%2C%22D_818310825_3%22%2C%22D_818310825_4%22%2C%22D_818310825_5%22%2C%22D_818310825_6%22%2C%22D_818310825_7%22%2C%22D_818310825_8%22)", - "someSelected(%22D_843593800_1%22%2C%22D_843593800_2%22%2C%22D_843593800_3%22%2C%22D_843593800_4%22%2C%22D_843593800_5%22%2C%22D_843593800_6%22%2C%22D_843593800_7%22%2C%22D_843593800_8%22)", - "someSelected(%22D_175385712_1%22%2C%22D_175385712_2%22%2C%22D_175385712_3%22%2C%22D_175385712_4%22%2C%22D_175385712_5%22%2C%22D_175385712_6%22%2C%22D_175385712_7%22%2C%22D_175385712_8%22)", - "someSelected(%22D_772143730_1%22%2C%22D_772143730_2%22%2C%22D_772143730_3%22%2C%22D_772143730_4%22%2C%22D_772143730_5%22%2C%22D_772143730_6%22%2C%22D_772143730_7%22%2C%22D_772143730_8%22)", - "someSelected(%22D_602102163_1%22%2C%22D_602102163_2%22%2C%22D_602102163_3%22%2C%22D_602102163_4%22%2C%22D_602102163_5%22%2C%22D_602102163_6%22%2C%22D_602102163_7%22%2C%22D_602102163_8%22)", + "someSelected(\"D_294629316_1\",\"D_294629316_2\",\"D_294629316_3\",\"D_294629316_4\",\"D_294629316_5\",\"D_294629316_6\",\"D_294629316_7\",\"D_294629316_8\")", + "someSelected(\"D_164707243_1\",\"D_164707243_2\",\"D_164707243_3\",\"D_164707243_4\",\"D_164707243_5\",\"D_164707243_6\",\"D_164707243_7\",\"D_164707243_8\")", + "someSelected(\"D_771426895_1\",\"D_771426895_2\",\"D_771426895_3\",\"D_771426895_4\",\"D_771426895_5\",\"D_771426895_6\",\"D_771426895_7\",\"D_771426895_8\")", + "someSelected(\"D_818310825_1\",\"D_818310825_2\",\"D_818310825_3\",\"D_818310825_4\",\"D_818310825_5\",\"D_818310825_6\",\"D_818310825_7\",\"D_818310825_8\")", + "someSelected(\"D_843593800_1\",\"D_843593800_2\",\"D_843593800_3\",\"D_843593800_4\",\"D_843593800_5\",\"D_843593800_6\",\"D_843593800_7\",\"D_843593800_8\")", + "someSelected(\"D_175385712_1\",\"D_175385712_2\",\"D_175385712_3\",\"D_175385712_4\",\"D_175385712_5\",\"D_175385712_6\",\"D_175385712_7\",\"D_175385712_8\")", + "someSelected(\"D_772143730_1\",\"D_772143730_2\",\"D_772143730_3\",\"D_772143730_4\",\"D_772143730_5\",\"D_772143730_6\",\"D_772143730_7\",\"D_772143730_8\")", + "someSelected(\"D_602102163_1\",\"D_602102163_2\",\"D_602102163_3\",\"D_602102163_4\",\"D_602102163_5\",\"D_602102163_6\",\"D_602102163_7\",\"D_602102163_8\")", "equals(equals(D_605063358,353358909),false)", "equals(D_605063358,353358909)" ], @@ -24361,41 +24361,41 @@ "doesNotExist('D_637540387')", "noneSelected(\"D_488415137_0\",\"D_488415137_1\",\"D_167695804_0\",\"D_167695804_1\",\"D_730334054_0\",\"D_730334054_1\",\"D_215996690_0\",\"D_215996690_1\",\"D_462737492_0\",\"D_462737492_1\",\"D_469675296_0\",\"D_469675296_1\",\"D_962475128_0\",\"D_962475128_1\",\"D_989576239_0\",\"D_989576239_1\",\"D_338613869_0\",\"D_338613869_1\",\"D_126794793_0\",\"D_126794793_1\",\"D_218793117_0\",\"D_218793117_1\",\"D_524096053_0\",\"D_524096053_1\",\"D_814101706_0\",\"D_814101706_1\",\"D_635026188_0\",\"D_635026188_1\",\"D_238135048_0\",\"D_238135048_1\",\"D_632714520_0\",\"D_632714520_1\")", "someSelected(\"D_488415137_0\",\"D_488415137_1\",\"D_167695804_0\",\"D_167695804_1\",\"D_730334054_0\",\"D_730334054_1\",\"D_215996690_0\",\"D_215996690_1\",\"D_462737492_0\",\"D_462737492_1\",\"D_469675296_0\",\"D_469675296_1\",\"D_962475128_0\",\"D_962475128_1\",\"D_989576239_0\",\"D_989576239_1\",\"D_338613869_0\",\"D_338613869_1\",\"D_126794793_0\",\"D_126794793_1\",\"D_218793117_0\",\"D_218793117_1\",\"D_524096053_0\",\"SRVCOV_COV19C1_V1R0_1,1\",\"D_814101706_0\",\"D_814101706_1\",\"D_635026188_0\",\"D_238135048_0\",\"D_238135048_1\",\"D_632714520_0\",\"D_632714520_1", - "someSelected(%22D_488415137_0%22%2C%22D_488415137_1%22)", - "someSelected(%22D_730334054_0%22%2C%22D_730334054_1%22)", - "someSelected(%22D_215996690_0%22%2C%22D_215996690_1%22)", - "someSelected(%22D_462737492_0%22%2C%22D_462737492_1%22)", - "someSelected(%22D_469675296_0%22%2C%22D_469675296_1%22)", - "someSelected(%22D_167695804_0%22%2C%22D_167695804_1%22)", - "someSelected(%22D_524096053_0%22%2C%22D_524096053_1%22)", - "someSelected(%22D_814101706_0%22%2C%22D_814101706_1%22)", - "someSelected(%22D_962475128_0%22%2C%22D_962475128_1%22)", - "someSelected(%22D_989576239_0%22%2C%22D_989576239_1%22)", - "someSelected(%22D_338613869_0%22%2C%22D_338613869_1%22)", - "someSelected(%22D_126794793_0%22%2C%22D_126794793_1%22)", - "someSelected(%22D_218793117_0%22%2C%22D_218793117_1%22)", - "someSelected(%22D_635026188_0%22%2C%22D_635026188_1%22)", - "someSelected(%22D_238135048_0%22%2C%22D_238135048_1%22)", - "someSelected(%22D_632714520_0%22%2C%22D_632714520_1%22)", + "someSelected(\"D_488415137_0\",\"D_488415137_1\")", + "someSelected(\"D_730334054_0\",\"D_730334054_1\")", + "someSelected(\"D_215996690_0\",\"D_215996690_1\")", + "someSelected(\"D_462737492_0\",\"D_462737492_1\")", + "someSelected(\"D_469675296_0\",\"D_469675296_1\")", + "someSelected(\"D_167695804_0\",\"D_167695804_1\")", + "someSelected(\"D_524096053_0\",\"D_524096053_1\")", + "someSelected(\"D_814101706_0\",\"D_814101706_1\")", + "someSelected(\"D_962475128_0\",\"D_962475128_1\")", + "someSelected(\"D_989576239_0\",\"D_989576239_1\")", + "someSelected(\"D_338613869_0\",\"D_338613869_1\")", + "someSelected(\"D_126794793_0\",\"D_126794793_1\")", + "someSelected(\"D_218793117_0\",\"D_218793117_1\")", + "someSelected(\"D_635026188_0\",\"D_635026188_1\")", + "someSelected(\"D_238135048_0\",\"D_238135048_1\")", + "someSelected(\"D_632714520_0\",\"D_632714520_1\")", "doesNotEqual(D_110872086,707601969)", "equals(D_110872086,707601969)", "exists('D_637540387')", "and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(doesNotEqual(D_494226443,104430631),doesNotEqual(D_494226443,178420302)),doesNotEqual(isDefined(D_494226443,-1),-1)),doesNotEqual(D_498462481_1_1,104430631)),doesNotEqual(D_694503437_1_1,104430631)),doesNotEqual(D_498462481_2_2,104430631)),doesNotEqual(D_694503437_2_2,104430631)),doesNotEqual(D_498462481_3_3,104430631)),doesNotEqual(D_694503437_3_3,104430631)),doesNotEqual(D_498462481_4_4,104430631)),doesNotEqual(D_694503437_4_4,104430631)),doesNotEqual(D_498462481_5_5,104430631)),doesNotEqual(D_694503437_5_5,104430631)),doesNotEqual(D_498462481_6_6,104430631)),doesNotEqual(D_694503437_6_6,104430631)),doesNotEqual(D_498462481_7_7,104430631)),doesNotEqual(D_694503437_7_7,104430631)),doesNotEqual(D_498462481_8_8,104430631)),doesNotEqual(D_694503437_8_8,104430631)),doesNotEqual(D_498462481_9_9,104430631)),doesNotEqual(D_694503437_9_9,104430631)),doesNotEqual(D_498462481_10_10,104430631)),doesNotEqual(D_694503437_10_10,104430631)),doesNotEqual(D_498462481_11_11,104430631)),doesNotEqual(D_694503437_11_11,104430631)),doesNotEqual(D_498462481_12_12,104430631)),doesNotEqual(D_694503437_12_12,104430631)),doesNotEqual(D_498462481_13_13,104430631)),doesNotEqual(D_694503437_13_13,104430631)),doesNotEqual(D_498462481_14_14,104430631)),doesNotEqual(D_694503437_14_14,104430631)),doesNotEqual(D_498462481_15_15,104430631)),doesNotEqual(D_694503437_15_15,104430631)),doesNotEqual(D_498462481_16_16,104430631)),doesNotEqual(D_694503437_16_16,104430631)),doesNotEqual(D_498462481_17_17,104430631)),doesNotEqual(D_694503437_17_17,104430631)),doesNotEqual(D_498462481_18_18,104430631)),doesNotEqual(D_694503437_18_18,104430631)),doesNotEqual(D_498462481_19_19,104430631)),doesNotEqual(D_694503437_19_19,104430631)),doesNotEqual(D_498462481_20_20,104430631)),doesNotEqual(D_694503437_20_20,104430631)),doesNotEqual(D_498462481_21_21,104430631)),doesNotEqual(D_694503437_21_21,104430631)),doesNotEqual(D_498462481_22_22,104430631)),doesNotEqual(D_694503437_22_22,104430631)),doesNotEqual(D_498462481_23_23,104430631)),doesNotEqual(D_694503437_23_23,104430631)),doesNotEqual(D_498462481_24_24,104430631)),doesNotEqual(D_694503437_24_24,104430631)),doesNotEqual(D_498462481_25_25,104430631)),doesNotEqual(D_694503437_25_25,104430631))", "or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(equals(D_494226443,104430631),equals(D_494226443,178420302)),equals(isDefined(D_494226443,-1),-1)),equals(D_498462481_1_1,104430631)),equals(D_694503437_1_1,104430631)),equals(D_498462481_2_2,104430631)),equals(D_694503437_2_2,104430631)),equals(D_498462481_3_3,104430631)),equals(D_694503437_3_3,104430631)),equals(D_498462481_4_4,104430631)),equals(D_694503437_4_4,104430631)),equals(D_498462481_5_5,104430631)),equals(D_694503437_5_5,104430631)),equals(D_498462481_6_6,104430631)),equals(D_694503437_6_6,104430631)),equals(D_498462481_7_7,104430631)),equals(D_694503437_7_7,104430631)),equals(D_498462481_8_8,104430631)),equals(D_694503437_8_8,104430631)),equals(D_498462481_9_9,104430631)),equals(D_694503437_9_9,104430631)),equals(D_498462481_10_10,104430631)),equals(D_694503437_10_10,104430631)),equals(D_498462481_11_11,104430631)),equals(D_694503437_11_11,104430631)),equals(D_498462481_12_12,104430631)),equals(D_694503437_12_12,104430631)),equals(D_498462481_13_13,104430631)),equals(D_694503437_13_13,104430631)),equals(D_498462481_14_14,104430631)),equals(D_694503437_14_14,104430631)),equals(D_498462481_15_15,104430631)),equals(D_694503437_15_15,104430631)),equals(D_498462481_16_16,104430631)),equals(D_694503437_16_16,104430631)),equals(D_498462481_17_17,104430631)),equals(D_694503437_17_17,104430631)),equals(D_498462481_18_18,104430631)),equals(D_694503437_18_18,104430631)),equals(D_498462481_19_19,104430631)),equals(D_694503437_19_19,104430631)),equals(D_498462481_20_20,104430631)),equals(D_694503437_20_20,104430631)),equals(D_498462481_21_21,104430631)),equals(D_694503437_21_21,104430631)),equals(D_498462481_22_22,104430631)),equals(D_694503437_22_22,104430631)),equals(D_498462481_23_23,104430631)),equals(D_694503437_23_23,104430631)),equals(D_498462481_24_24,104430631)),equals(D_694503437_24_24,104430631)),equals(D_498462481_25_25,104430631)),equals(D_694503437_25_25,104430631))", "someSelected(\"D_874168085_0\",\"D_874168085_1\",\"D_283112988_0\",\"D_283112988_1\",\"D_580629349_0\",\"D_580629349_1\",\"D_151327643_0\",\"D_151327643_1\",\"D_440872808_0\",\"D_440872808_1\",\"D_874223830_0\",\"D_874223830_1\",\"D_847529903_0\",\"D_847529903_1\",\"D_219358831_0\",\"D_219358831_1\",\"D_636367178_0\",\"D_636367178_1\",\"D_243443780_0\",\"D_243443780_1\",\"D_357462273_0\",\"D_357462273_1\",\"D_638380747_0\",\"D_638380747_1\",\"D_187399900_0\",\"D_187399900_1\")", - "someSelected(%22D_874168085_0%22%2C%22D_874168085_1%22)", - "someSelected(%22D_283112988_0%22%2C%22D_283112988_1%22)", - "someSelected(%22D_580629349_0%22%2C%22D_580629349_1%22)", - "someSelected(%22D_151327643_0%22%2C%22D_151327643_1%22)", - "someSelected(%22D_440872808_0%22%2C%22D_440872808_1%22)", - "someSelected(%22D_874223830_0%22%2C%22D_874223830_1%22)", - "someSelected(%22D_847529903_0%22%2C%22D_847529903_1%22)", - "someSelected(%22D_219358831_0%22%2C%22D_219358831_1%22)", - "someSelected(%22D_636367178_0%22%2C%22D_636367178_1%22)", - "someSelected(%22D_243443780_0%22%2C%22D_243443780_1%22)", - "someSelected(%22D_357462273_0%22%2C%22D_357462273_1%22)", - "someSelected(%22D_638380747_0%22%2C%22D_638380747_1%22)", - "someSelected(%22D_187399900_0%22%2C%22D_187399900_1%22)", + "someSelected(\"D_874168085_0\",\"D_874168085_1\")", + "someSelected(\"D_283112988_0\",\"D_283112988_1\")", + "someSelected(\"D_580629349_0\",\"D_580629349_1\")", + "someSelected(\"D_151327643_0\",\"D_151327643_1\")", + "someSelected(\"D_440872808_0\",\"D_440872808_1\")", + "someSelected(\"D_874223830_0\",\"D_874223830_1\")", + "someSelected(\"D_847529903_0\",\"D_847529903_1\")", + "someSelected(\"D_219358831_0\",\"D_219358831_1\")", + "someSelected(\"D_636367178_0\",\"D_636367178_1\")", + "someSelected(\"D_243443780_0\",\"D_243443780_1\")", + "someSelected(\"D_357462273_0\",\"D_357462273_1\")", + "someSelected(\"D_638380747_0\",\"D_638380747_1\")", + "someSelected(\"D_187399900_0\",\"D_187399900_1\")", "greaterThanOrEqual(D_877074400_V2,1)", "equals(D_220055064_1_1,661871565)", "equals(D_220055064_1_1,657978450)", @@ -25978,41 +25978,41 @@ "doesNotExist('D_637540387')", "noneSelected(\"D_488415137_0\",\"D_488415137_1\",\"D_167695804_0\",\"D_167695804_1\",\"D_730334054_0\",\"D_730334054_1\",\"D_215996690_0\",\"D_215996690_1\",\"D_462737492_0\",\"D_462737492_1\",\"D_469675296_0\",\"D_469675296_1\",\"D_962475128_0\",\"D_962475128_1\",\"D_989576239_0\",\"D_989576239_1\",\"D_338613869_0\",\"D_338613869_1\",\"D_126794793_0\",\"D_126794793_1\",\"D_218793117_0\",\"D_218793117_1\",\"D_524096053_0\",\"D_524096053_1\",\"D_814101706_0\",\"D_814101706_1\",\"D_635026188_0\",\"D_635026188_1\",\"D_238135048_0\",\"D_238135048_1\",\"D_632714520_0\",\"D_632714520_1\")", "someSelected(\"D_488415137_0\",\"D_488415137_1\",\"D_167695804_0\",\"D_167695804_1\",\"D_730334054_0\",\"D_730334054_1\",\"D_215996690_0\",\"D_215996690_1\",\"D_462737492_0\",\"D_462737492_1\",\"D_469675296_0\",\"D_469675296_1\",\"D_962475128_0\",\"D_962475128_1\",\"D_989576239_0\",\"D_989576239_1\",\"D_338613869_0\",\"D_338613869_1\",\"D_126794793_0\",\"D_126794793_1\",\"D_218793117_0\",\"D_218793117_1\",\"D_524096053_0\",\"SRVCOV_COV19C1_V1R0_1,1\",\"D_814101706_0\",\"D_814101706_1\",\"D_635026188_0\",\"D_238135048_0\",\"D_238135048_1\",\"D_632714520_0\",\"D_632714520_1", - "someSelected(%22D_488415137_0%22%2C%22D_488415137_1%22)", - "someSelected(%22D_730334054_0%22%2C%22D_730334054_1%22)", - "someSelected(%22D_215996690_0%22%2C%22D_215996690_1%22)", - "someSelected(%22D_462737492_0%22%2C%22D_462737492_1%22)", - "someSelected(%22D_469675296_0%22%2C%22D_469675296_1%22)", - "someSelected(%22D_167695804_0%22%2C%22D_167695804_1%22)", - "someSelected(%22D_524096053_0%22%2C%22D_524096053_1%22)", - "someSelected(%22D_814101706_0%22%2C%22D_814101706_1%22)", - "someSelected(%22D_962475128_0%22%2C%22D_962475128_1%22)", - "someSelected(%22D_989576239_0%22%2C%22D_989576239_1%22)", - "someSelected(%22D_338613869_0%22%2C%22D_338613869_1%22)", - "someSelected(%22D_126794793_0%22%2C%22D_126794793_1%22)", - "someSelected(%22D_218793117_0%22%2C%22D_218793117_1%22)", - "someSelected(%22D_635026188_0%22%2C%22D_635026188_1%22)", - "someSelected(%22D_238135048_0%22%2C%22D_238135048_1%22)", - "someSelected(%22D_632714520_0%22%2C%22D_632714520_1%22)", + "someSelected(\"D_488415137_0\",\"D_488415137_1\")", + "someSelected(\"D_730334054_0\",\"D_730334054_1\")", + "someSelected(\"D_215996690_0\",\"D_215996690_1\")", + "someSelected(\"D_462737492_0\",\"D_462737492_1\")", + "someSelected(\"D_469675296_0\",\"D_469675296_1\")", + "someSelected(\"D_167695804_0\",\"D_167695804_1\")", + "someSelected(\"D_524096053_0\",\"D_524096053_1\")", + "someSelected(\"D_814101706_0\",\"D_814101706_1\")", + "someSelected(\"D_962475128_0\",\"D_962475128_1\")", + "someSelected(\"D_989576239_0\",\"D_989576239_1\")", + "someSelected(\"D_338613869_0\",\"D_338613869_1\")", + "someSelected(\"D_126794793_0\",\"D_126794793_1\")", + "someSelected(\"D_218793117_0\",\"D_218793117_1\")", + "someSelected(\"D_635026188_0\",\"D_635026188_1\")", + "someSelected(\"D_238135048_0\",\"D_238135048_1\")", + "someSelected(\"D_632714520_0\",\"D_632714520_1\")", "doesNotEqual(D_110872086,707601969)", "equals(D_110872086,707601969)", "exists('D_637540387')", "and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(and(doesNotEqual(D_494226443,104430631),doesNotEqual(D_494226443,178420302)),doesNotEqual(isDefined(D_494226443,-1),-1)),doesNotEqual(D_498462481_1_1,104430631)),doesNotEqual(D_694503437_1_1,104430631)),doesNotEqual(D_498462481_2_2,104430631)),doesNotEqual(D_694503437_2_2,104430631)),doesNotEqual(D_498462481_3_3,104430631)),doesNotEqual(D_694503437_3_3,104430631)),doesNotEqual(D_498462481_4_4,104430631)),doesNotEqual(D_694503437_4_4,104430631)),doesNotEqual(D_498462481_5_5,104430631)),doesNotEqual(D_694503437_5_5,104430631)),doesNotEqual(D_498462481_6_6,104430631)),doesNotEqual(D_694503437_6_6,104430631)),doesNotEqual(D_498462481_7_7,104430631)),doesNotEqual(D_694503437_7_7,104430631)),doesNotEqual(D_498462481_8_8,104430631)),doesNotEqual(D_694503437_8_8,104430631)),doesNotEqual(D_498462481_9_9,104430631)),doesNotEqual(D_694503437_9_9,104430631)),doesNotEqual(D_498462481_10_10,104430631)),doesNotEqual(D_694503437_10_10,104430631)),doesNotEqual(D_498462481_11_11,104430631)),doesNotEqual(D_694503437_11_11,104430631)),doesNotEqual(D_498462481_12_12,104430631)),doesNotEqual(D_694503437_12_12,104430631)),doesNotEqual(D_498462481_13_13,104430631)),doesNotEqual(D_694503437_13_13,104430631)),doesNotEqual(D_498462481_14_14,104430631)),doesNotEqual(D_694503437_14_14,104430631)),doesNotEqual(D_498462481_15_15,104430631)),doesNotEqual(D_694503437_15_15,104430631)),doesNotEqual(D_498462481_16_16,104430631)),doesNotEqual(D_694503437_16_16,104430631)),doesNotEqual(D_498462481_17_17,104430631)),doesNotEqual(D_694503437_17_17,104430631)),doesNotEqual(D_498462481_18_18,104430631)),doesNotEqual(D_694503437_18_18,104430631)),doesNotEqual(D_498462481_19_19,104430631)),doesNotEqual(D_694503437_19_19,104430631)),doesNotEqual(D_498462481_20_20,104430631)),doesNotEqual(D_694503437_20_20,104430631)),doesNotEqual(D_498462481_21_21,104430631)),doesNotEqual(D_694503437_21_21,104430631)),doesNotEqual(D_498462481_22_22,104430631)),doesNotEqual(D_694503437_22_22,104430631)),doesNotEqual(D_498462481_23_23,104430631)),doesNotEqual(D_694503437_23_23,104430631)),doesNotEqual(D_498462481_24_24,104430631)),doesNotEqual(D_694503437_24_24,104430631)),doesNotEqual(D_498462481_25_25,104430631)),doesNotEqual(D_694503437_25_25,104430631))", "or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(or(equals(D_494226443,104430631),equals(D_494226443,178420302)),equals(isDefined(D_494226443,-1),-1)),equals(D_498462481_1_1,104430631)),equals(D_694503437_1_1,104430631)),equals(D_498462481_2_2,104430631)),equals(D_694503437_2_2,104430631)),equals(D_498462481_3_3,104430631)),equals(D_694503437_3_3,104430631)),equals(D_498462481_4_4,104430631)),equals(D_694503437_4_4,104430631)),equals(D_498462481_5_5,104430631)),equals(D_694503437_5_5,104430631)),equals(D_498462481_6_6,104430631)),equals(D_694503437_6_6,104430631)),equals(D_498462481_7_7,104430631)),equals(D_694503437_7_7,104430631)),equals(D_498462481_8_8,104430631)),equals(D_694503437_8_8,104430631)),equals(D_498462481_9_9,104430631)),equals(D_694503437_9_9,104430631)),equals(D_498462481_10_10,104430631)),equals(D_694503437_10_10,104430631)),equals(D_498462481_11_11,104430631)),equals(D_694503437_11_11,104430631)),equals(D_498462481_12_12,104430631)),equals(D_694503437_12_12,104430631)),equals(D_498462481_13_13,104430631)),equals(D_694503437_13_13,104430631)),equals(D_498462481_14_14,104430631)),equals(D_694503437_14_14,104430631)),equals(D_498462481_15_15,104430631)),equals(D_694503437_15_15,104430631)),equals(D_498462481_16_16,104430631)),equals(D_694503437_16_16,104430631)),equals(D_498462481_17_17,104430631)),equals(D_694503437_17_17,104430631)),equals(D_498462481_18_18,104430631)),equals(D_694503437_18_18,104430631)),equals(D_498462481_19_19,104430631)),equals(D_694503437_19_19,104430631)),equals(D_498462481_20_20,104430631)),equals(D_694503437_20_20,104430631)),equals(D_498462481_21_21,104430631)),equals(D_694503437_21_21,104430631)),equals(D_498462481_22_22,104430631)),equals(D_694503437_22_22,104430631)),equals(D_498462481_23_23,104430631)),equals(D_694503437_23_23,104430631)),equals(D_498462481_24_24,104430631)),equals(D_694503437_24_24,104430631)),equals(D_498462481_25_25,104430631)),equals(D_694503437_25_25,104430631))", "someSelected(\"D_874168085_0\",\"D_874168085_1\",\"D_283112988_0\",\"D_283112988_1\",\"D_580629349_0\",\"D_580629349_1\",\"D_151327643_0\",\"D_151327643_1\",\"D_440872808_0\",\"D_440872808_1\",\"D_874223830_0\",\"D_874223830_1\",\"D_847529903_0\",\"D_847529903_1\",\"D_219358831_0\",\"D_219358831_1\",\"D_636367178_0\",\"D_636367178_1\",\"D_243443780_0\",\"D_243443780_1\",\"D_357462273_0\",\"D_357462273_1\",\"D_638380747_0\",\"D_638380747_1\",\"D_187399900_0\",\"D_187399900_1\")", - "someSelected(%22D_874168085_0%22%2C%22D_874168085_1%22)", - "someSelected(%22D_283112988_0%22%2C%22D_283112988_1%22)", - "someSelected(%22D_580629349_0%22%2C%22D_580629349_1%22)", - "someSelected(%22D_151327643_0%22%2C%22D_151327643_1%22)", - "someSelected(%22D_440872808_0%22%2C%22D_440872808_1%22)", - "someSelected(%22D_874223830_0%22%2C%22D_874223830_1%22)", - "someSelected(%22D_847529903_0%22%2C%22D_847529903_1%22)", - "someSelected(%22D_219358831_0%22%2C%22D_219358831_1%22)", - "someSelected(%22D_636367178_0%22%2C%22D_636367178_1%22)", - "someSelected(%22D_243443780_0%22%2C%22D_243443780_1%22)", - "someSelected(%22D_357462273_0%22%2C%22D_357462273_1%22)", - "someSelected(%22D_638380747_0%22%2C%22D_638380747_1%22)", - "someSelected(%22D_187399900_0%22%2C%22D_187399900_1%22)", + "someSelected(\"D_874168085_0\",\"D_874168085_1\")", + "someSelected(\"D_283112988_0\",\"D_283112988_1\")", + "someSelected(\"D_580629349_0\",\"D_580629349_1\")", + "someSelected(\"D_151327643_0\",\"D_151327643_1\")", + "someSelected(\"D_440872808_0\",\"D_440872808_1\")", + "someSelected(\"D_874223830_0\",\"D_874223830_1\")", + "someSelected(\"D_847529903_0\",\"D_847529903_1\")", + "someSelected(\"D_219358831_0\",\"D_219358831_1\")", + "someSelected(\"D_636367178_0\",\"D_636367178_1\")", + "someSelected(\"D_243443780_0\",\"D_243443780_1\")", + "someSelected(\"D_357462273_0\",\"D_357462273_1\")", + "someSelected(\"D_638380747_0\",\"D_638380747_1\")", + "someSelected(\"D_187399900_0\",\"D_187399900_1\")", "greaterThanOrEqual(D_877074400_V2,1)", "equals(D_220055064_1_1,661871565)", "equals(D_220055064_1_1,657978450)", diff --git a/tests/corpus/structuralCatalog.spec.js b/tests/corpus/structuralCatalog.spec.js index 86ed73b..68942b6 100644 --- a/tests/corpus/structuralCatalog.spec.js +++ b/tests/corpus/structuralCatalog.spec.js @@ -264,6 +264,38 @@ async function buildSurveyCatalog(record) { expect(rendered.root.querySelectorAll('form.question'), `${record.path}: not every runtime entry was appended`) .toHaveLength(processor.questions.length); + const scalarInputTypes = new Set([ + 'date', 'email', 'month', 'number', 'password', 'search', 'tel', 'text', 'time', 'url', + ]); + const scalarControls = Array.from(rendered.root.querySelectorAll('input, select, textarea')).filter((control) => ( + control.tagName !== 'INPUT' + || (scalarInputTypes.has(control.type) && !control.hasAttribute('data-hidden')) + )); + const explicitLabelTargets = new Set(Array.from( + rendered.root.querySelectorAll('label[for]'), + (label) => label.htmlFor, + )); + const implicitlyLabelledControls = new Set( + rendered.root.querySelectorAll('label input, label select, label textarea'), + ); + const missingScalarNames = []; + const leakedScalarNames = []; + scalarControls.forEach((control) => { + const ariaLabel = control.getAttribute('aria-label')?.trim() ?? ''; + const ariaLabelledBy = control.getAttribute('aria-labelledby')?.trim() ?? ''; + const controlTarget = `${record.path}#${control.id || control.name}`; + const hasNativeLabel = explicitLabelTargets.has(control.id) + || implicitlyLabelledControls.has(control); + if (!ariaLabel && !ariaLabelledBy && !hasNativeLabel) { + missingScalarNames.push(controlTarget); + } + if (ariaLabel && /<|>|\||->|(?:^|\s)(?:displayif|forid)\s*=/i.test(ariaLabel)) { + leakedScalarNames.push(`${controlTarget}: ${ariaLabel}`); + } + }); + expect(missingScalarNames, `${record.path}: scalar controls without an accessible name source`).toEqual([]); + expect(leakedScalarNames, `${record.path}: scalar names containing parser syntax`).toEqual([]); + const survey = buildStructuralCatalogRecord({ record, processor, diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js index 2a67e36..bb174cf 100644 --- a/tests/e2e/accessibility.spec.js +++ b/tests/e2e/accessibility.spec.js @@ -10,7 +10,6 @@ import { waitInHarness, } from './support/harness.js'; import { analyzeQuestAxe, unwaivedAxeFindings } from './support/axe.js'; -import { axeDefects } from '../knownDefects/registry.js'; const ACCESSIBILITY_PROJECTS = new Set([ 'chromium-desktop', @@ -19,29 +18,45 @@ const ACCESSIBILITY_PROJECTS = new Set([ 'chromium-windows-ua', ]); +const GRID_SEMANTIC_CASES = [ + { + description: 'English radio', + fixture: 'gridResponsive.txt', + questionId: 'GRID_RATE', + role: 'radio', + selectedId: 'GRID_WALK_1', + selectedName: 'Walking Sometimes', + unselectedName: 'Cycling Often', + }, + { + description: 'Spanish radio', + fixture: 'gridResponsiveSpanish.txt', + lang: 'es', + previousResults: { firstName: 'Ana' }, + questionId: 'GRID_RATE_ES', + role: 'radio', + selectedId: 'GRID_CAMINAR_1', + selectedName: 'Caminar con Ana A veces', + unselectedName: 'Andar en bicicleta A menudo', + }, + { + description: 'English checkbox', + fixture: 'gridCheckboxFocus.txt', + questionId: 'GRID_CHECK', + role: 'checkbox', + selectedId: 'GRID_CHECK_ROW_A_0', + selectedName: 'First need Phone', + unselectedName: 'Second need Email', + }, +]; + const AXE_PROJECTS = new Set([ ...ACCESSIBILITY_PROJECTS, 'chromium-phone', 'chromium-tablet', ]); -const CURRENT_AXE_BASELINE = [ - axeDefects.progressbarName, - axeDefects.progressbarValue, -]; - -const GRID_AXE_BASELINE = [ - ...CURRENT_AXE_BASELINE, - axeDefects.emptyGridHeader, -]; - -const VALIDATION_AXE_BASELINE = [ - ...CURRENT_AXE_BASELINE, - axeDefects.validationContrast, - axeDefects.validationLabel, -]; - -async function expectNoUnwaivedAxeViolations(page, accepted = CURRENT_AXE_BASELINE) { +async function expectNoUnwaivedAxeViolations(page, accepted = []) { const findings = await analyzeQuestAxe(page); expect(unwaivedAxeFindings(findings, accepted)).toEqual([]); } @@ -91,6 +106,48 @@ async function traverseHostBoundary(page, key, terminalId, maximumPresses = 20) throw new Error(`Focus did not reach #${terminalId} after ${maximumPresses} ${key} presses: ${JSON.stringify(path)}`); } +test.describe('selection announcement navigation lifecycle @canonical', () => { + test.beforeEach(async ({}, testInfo) => { + test.skip( + testInfo.project.name !== 'chromium-desktop', + 'Fake timers cover the browser-neutral scheduling details; Chromium covers the real navigation boundary.', + ); + }); + + test('does not replay a delayed response announcement after Next', async ({ page }) => { + await openParticipant(page); + + // Keep selection and navigation in one browser: this is a regression test for the delayed-callback race. + await page.evaluate(() => { + document.querySelector('label[for="CHOICE_1"]').click(); + document.querySelector('#CHOICE button.next').click(); + }); + + await expect(activeQuestion(page, 'CHECKS')).toBeVisible(); + await waitInHarness(page, 150); + await expect(page.locator('#ariaLiveSelectionAnnouncer')).toHaveText(''); + await expectHealthyHarness(page); + }); + + test('does not replay a delayed response announcement after Back', async ({ page }) => { + await openParticipant(page); + await selectLabeledResponse(page, 'Blue'); + await waitInHarness(page, 150); + await goNext(page); + await expect(activeQuestion(page, 'CHECKS')).toBeVisible(); + + await page.evaluate(() => { + document.querySelector('label[for="CHECKS_1"]').click(); + document.querySelector('#CHECKS button.previous').click(); + }); + + await expect(activeQuestion(page, 'CHOICE')).toBeVisible(); + await waitInHarness(page, 150); + await expect(page.locator('#ariaLiveSelectionAnnouncer')).toHaveText(''); + await expectHealthyHarness(page); + }); +}); + test.describe('participant accessibility contract @canonical @windows-a11y', () => { test.beforeEach(async ({}, testInfo) => { test.skip( @@ -103,6 +160,9 @@ test.describe('participant accessibility contract @canonical @windows-a11y', () await openParticipant(page); const question = activeQuestion(page, 'CHOICE'); + const progress = page.locator('#progressBar'); + await expect(progress).toHaveAccessibleName('Survey progress'); + await expect(progress).toHaveAttribute('aria-valuenow', '0'); await expect(question.locator('fieldset')).toHaveCount(1); await expect(question.locator('legend')).toHaveText('Which color do you prefer?'); await expect(question.getByRole('button', { name: 'Next question' })).toBeVisible(); @@ -143,6 +203,37 @@ test.describe('participant accessibility contract @canonical @windows-a11y', () await expectHealthyHarness(page); }); + for (const scenario of GRID_SEMANTIC_CASES) { + test(`${scenario.description} grid choices expose row, option, and checked state`, async ({ page }) => { + await openParticipant(page, { + fixture: scenario.fixture, + lang: scenario.lang, + previousResults: scenario.previousResults, + }); + await goNext(page); + await waitInHarness(page, 550); + + const question = activeQuestion(page, scenario.questionId); + const selected = question.getByRole(scenario.role, { + name: scenario.selectedName, + exact: true, + }); + const unselected = question.getByRole(scenario.role, { + name: scenario.unselectedName, + exact: true, + }); + + await expect(selected).toHaveCount(1); + await expect(unselected).toHaveCount(1); + await expect(selected).not.toBeChecked(); + await expect(unselected).not.toBeChecked(); + await question.locator(`label[for="${scenario.selectedId}"]`).click(); + await expect(selected).toBeChecked(); + await expect(unselected).not.toBeChecked(); + await expectHealthyHarness(page); + }); + } + test('keeps the deliberate action-button tab order on a later question', async ({ page }, testInfo) => { await openParticipant(page, { fixture: 'navigationState.txt' }); await waitInHarness(page, 550); @@ -198,24 +289,131 @@ test.describe('participant accessibility contract @canonical @windows-a11y', () await expectHealthyHarness(page); }); - test('keeps a generated scalar label associated with its input', async ({ page }) => { + for (const scalarCase of [ + { + description: 'English', + lang: 'en', + ageLabel: 'Age at diagnosis', + yearLabel: 'Year at diagnosis', + streetLabel: 'Street number', + cityLabel: 'City', + timeLabel: 'Preferred call time', + notesLabel: 'Additional notes', + emailLabel: 'Email address', + phoneLabel: 'Telephone', + ssnLabel: 'Full SSN', + ssnLastFourLabel: 'Last four SSN digits', + zipLabel: 'ZIP code', + stateLabel: 'State', + dateLabel: 'Visit date', + monthLabel: 'Visit month', + fallbackLabel: 'Enter a value', + fallbackDescription: 'Value must be greater than or equal to 1. Value must be less than or equal to 3', + }, + { + description: 'Spanish', + lang: 'es', + ageLabel: 'Edad al momento del diagnóstico', + yearLabel: 'Año del diagnóstico', + streetLabel: 'Número de la calle', + cityLabel: 'Ciudad', + timeLabel: 'Hora preferida para llamar', + notesLabel: 'Notas adicionales', + emailLabel: 'Correo electrónico', + phoneLabel: 'Teléfono', + ssnLabel: 'Número de Seguro Social completo', + ssnLastFourLabel: 'Últimos cuatro dígitos del Seguro Social', + zipLabel: 'Código postal', + stateLabel: 'Estado', + dateLabel: 'Fecha de la visita', + monthLabel: 'Mes de la visita', + fallbackLabel: 'Introduzca un valor', + fallbackDescription: 'El valor debe ser mayor o igual a 1. El valor debe ser menor o igual a 3', + }, + ]) { + test(`gives ${scalarCase.description} generated scalar controls useful names and separate guidance`, async ({ page }) => { + await openParticipant(page, { + lang: scalarCase.lang, + markdown: `{"name":"TEST_SCALAR_LABEL"} + +[SCALAR?] Diagnosis details. +|__|__|id=scalar_age min=1 max=10| ${scalarCase.ageLabel} +|__|__|__|__|id=scalar_year min=1900 max=2030| ${scalarCase.yearLabel} +${scalarCase.streetLabel} |__|id=scalar_street| +${scalarCase.cityLabel} |__|id=scalar_city| +${scalarCase.timeLabel} |time|id=scalar_time| +${scalarCase.notesLabel} |___|scalar_notes| +${scalarCase.emailLabel} |@|id=scalar_email| +${scalarCase.phoneLabel} |tel|id=scalar_phone| +${scalarCase.ssnLabel} |SSN|id=scalar_ssn| +${scalarCase.ssnLastFourLabel} |SSNsm|id=scalar_ssn_last_four| +${scalarCase.zipLabel} |zip|id=scalar_zip| +${scalarCase.stateLabel} |state|id=scalar_state| +${scalarCase.dateLabel} |date|id=scalar_date| +${scalarCase.monthLabel} |month|id=scalar_month| + +[FALLBACK?] Enter a number from 1 through 3. +|__|__|id=scalar_fallback min=1 max=3| + +[END,end] Complete.`, + }); + + const question = activeQuestion(page, 'SCALAR'); + await expect(question.locator('#scalar_age')).toHaveAccessibleName(scalarCase.ageLabel); + await expect(question.locator('#scalar_year')).toHaveAccessibleName(scalarCase.yearLabel); + await expect(question.locator('#scalar_street')).toHaveAccessibleName(scalarCase.streetLabel); + await expect(question.locator('#scalar_city')).toHaveAccessibleName(scalarCase.cityLabel); + await expect(question.locator('#scalar_time')).toHaveAccessibleName(scalarCase.timeLabel); + await expect(question.locator('#scalar_notes')).toHaveAccessibleName(scalarCase.notesLabel); + await expect(question.locator('#scalar_email')).toHaveAccessibleName(scalarCase.emailLabel); + await expect(question.locator('#scalar_phone')).toHaveAccessibleName(scalarCase.phoneLabel); + await expect(question.locator('#scalar_ssn')).toHaveAccessibleName(scalarCase.ssnLabel); + await expect(question.locator('#scalar_ssn_last_four')).toHaveAccessibleName(scalarCase.ssnLastFourLabel); + await expect(question.locator('#scalar_zip')).toHaveAccessibleName(scalarCase.zipLabel); + await expect(question.locator('#scalar_state')).toHaveAccessibleName(scalarCase.stateLabel); + await expect(question.locator('#scalar_date')).toHaveAccessibleName(scalarCase.dateLabel); + await expect(question.locator('#scalar_month')).toHaveAccessibleName(scalarCase.monthLabel); + + await question.locator('#scalar_age').fill('4'); + await question.locator('#scalar_age').blur(); + await goNext(page); + const fallback = activeQuestion(page, 'FALLBACK').locator('#scalar_fallback'); + await expect(fallback).toHaveAccessibleName(scalarCase.fallbackLabel); + await expect(fallback).toHaveAccessibleDescription(scalarCase.fallbackDescription); + await expectHealthyHarness(page); + }); + } + + test('preserves scalar names that contain choice-like delimiters', async ({ page }) => { await openParticipant(page, { - markdown: `{"name":"TEST_SCALAR_LABEL"} + markdown: `{"name":"TEST_AUTHORED_SCALAR_NAMES"} -[SCALAR?] At what time should we call? -|time|id=scalar_time| +[QUESTA11YOPTION_0_END?] Scalar names. +|@|id=authored_email aria-label='Contact option (1) [2] & more'| +|date|id=authored_date aria-label='Appointment [2]'| +|time|id=authored_time aria-label='Preferred time (3)'| +|__|__|id=authored_number aria-label='Amount [4]'| [END,end] Complete.`, }); - const question = activeQuestion(page, 'SCALAR'); - const label = question.locator('label[for="scalar_time"]'); - const input = question.locator('#scalar_time'); - await expect(label).toHaveText('Enter Time'); - await expect(input).toHaveAccessibleName('Enter Time'); - expect(await input.evaluate((element) => ( - [...element.labels].map((candidate) => candidate.getAttribute('for')) - ))).toEqual(['scalar_time']); + const question = activeQuestion(page, 'QUESTA11YOPTION_0_END'); + await expect(question.locator('#authored_email')).toHaveAccessibleName('Contact option (1) [2] & more'); + await expect(question.locator('#authored_date')).toHaveAccessibleName('Appointment [2]'); + await expect(question.locator('#authored_time')).toHaveAccessibleName('Preferred time (3)'); + await expect(question.locator('#authored_number')).toHaveAccessibleName('Amount [4]'); + await expect(question.locator('#authored_number')).toHaveAttribute('name', 'QUESTA11YOPTION_0_END'); + await expect(question.locator('input')).toHaveCount(4); + await expect(question.locator('input[type="radio"], input[type="checkbox"]')).toHaveCount(0); + await expect(question.locator('.response')).toHaveCount(0); + + await question.locator('#authored_number').fill('4'); + await question.locator('#authored_number').blur(); + expect((await harnessSnapshot(page)).state.active).toEqual({ + QUESTA11YOPTION_0_END: { + authored_number: '4', + }, + }); await expectHealthyHarness(page); }); @@ -494,7 +692,7 @@ test.describe('automated accessibility scan @axe', () => { await openParticipant(page, { fixture: 'gridResponsive.txt' }); await goNext(page); await expect(activeQuestion(page, 'GRID_RATE')).toBeVisible(); - await expectNoUnwaivedAxeViolations(page, GRID_AXE_BASELINE); + await expectNoUnwaivedAxeViolations(page); await expectHealthyHarness(page); }); @@ -511,7 +709,26 @@ test.describe('automated accessibility scan @axe', () => { // Next button. Keep this scan on the validation state, not an accidental // and browser-layout-dependent hover state. await page.mouse.move(0, 0); - await expectNoUnwaivedAxeViolations(page, VALIDATION_AXE_BASELINE); + await expectNoUnwaivedAxeViolations(page); + await expect(activeQuestion(page).locator('.validation-container > span')).toHaveCSS( + 'color', + 'rgb(193, 18, 31)', + ); + await expectHealthyHarness(page); + }); + + test('retains sufficient action contrast while hovered', async ({ page }, testInfo) => { + test.skip( + !ACCESSIBILITY_PROJECTS.has(testInfo.project.name), + 'Pointer-hover contrast is checked in each desktop engine and the Windows user-agent project.', + ); + await openParticipant(page, { fixture: 'validation.txt' }); + const next = activeQuestion(page, 'BOUNDED').getByRole('button', { name: 'Next question' }); + await next.hover(); + + await expect(next).toHaveCSS('background-color', 'rgb(44, 109, 168)'); + await expect(next).toHaveCSS('color', 'rgb(255, 255, 255)'); + await expectNoUnwaivedAxeViolations(page); await expectHealthyHarness(page); }); diff --git a/tests/e2e/accessibilityLifecycleKnownDefects.spec.js b/tests/e2e/accessibilityLifecycleKnownDefects.spec.js index 692f01c..a18aa79 100644 --- a/tests/e2e/accessibilityLifecycleKnownDefects.spec.js +++ b/tests/e2e/accessibilityLifecycleKnownDefects.spec.js @@ -2,50 +2,11 @@ import { test, expect } from './support/test.js'; import { activeQuestion, openParticipant, - waitInHarness, } from './support/harness.js'; import { analyzeQuestAxe } from './support/axe.js'; -import { axeDefects, runtimeDefects } from '../knownDefects/registry.js'; - -test.describe('accessibility event lifecycle regressions @known-defect', () => { - test('clears a delayed list-selection announcement when advancing immediately', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium-desktop', 'The asynchronous announcement race is characterized once in Chromium.'); - const defect = runtimeDefects.staleSelectionAnnouncement; - - await openParticipant(page); - // Dispatch selection and navigation in the same browser task so the - // delayed 100 ms announcement cannot win the race before Next clears it. - await page.evaluate(() => { - document.querySelector('label[for="CHOICE_1"]').click(); - document.querySelector('#CHOICE button.next').click(); - }); - await expect(activeQuestion(page, 'CHECKS')).toBeVisible(); - await waitInHarness(page, 150); - - test.fail(true, `${defect.localDefectId}: ${defect.reason}`); - await expect(page.locator('#ariaLiveSelectionAnnouncer')).toHaveText('', { timeout: 1_000 }); - }); - - test('clears a delayed list-selection announcement when returning immediately', async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== 'chromium-desktop', 'The asynchronous announcement race is characterized once in Chromium.'); - const defect = runtimeDefects.staleSelectionAnnouncement; - - await openParticipant(page); - await activeQuestion(page, 'CHOICE').locator('label', { hasText: 'Blue' }).click(); - await waitInHarness(page, 150); - await activeQuestion(page, 'CHOICE').getByRole('button', { name: 'Next question' }).click(); - await expect(activeQuestion(page, 'CHECKS')).toBeVisible(); - await page.evaluate(() => { - document.querySelector('label[for="CHECKS_1"]').click(); - document.querySelector('#CHECKS button.previous').click(); - }); - await expect(activeQuestion(page, 'CHOICE')).toBeVisible(); - await waitInHarness(page, 150); - - test.fail(true, `${defect.localDefectId}: ${defect.reason}`); - await expect(page.locator('#ariaLiveSelectionAnnouncer')).toHaveText('', { timeout: 1_000 }); - }); +import { axeDefects } from '../knownDefects/registry.js'; +test.describe('accessibility lifecycle known defects @known-defect', () => { test('production-shaped image markup supplies a text alternative and has no image-alt violation', async ({ page }, testInfo) => { test.skip(testInfo.project.name !== 'chromium-desktop', 'The image-alt defect is characterized once in Chromium.'); const defect = axeDefects.imageAlt; diff --git a/tests/e2e/authoring.spec.js b/tests/e2e/authoring.spec.js index 5ab5aa3..5d9d423 100644 --- a/tests/e2e/authoring.spec.js +++ b/tests/e2e/authoring.spec.js @@ -494,6 +494,76 @@ test.describe('cross-browser authoring storage and export boundaries @authoring' await expect(page.locator('#pagelogic')).toHaveAttribute('href', 'Default.css'); }); + test('clears previous results after LocalForage initialization falls back @cross-browser-authoring', async ({ page, diagnostics }) => { + await installAuthoringRoutes(page, diagnostics, { + localForageMode: 'initialization-failure', + }); + await page.goto('/index.html'); + await waitForAuthoringReady(page); + expectAndClearConsoleErrors(diagnostics, [/Failed to initialize LocalForage/]); + + await openAuthoringSettings(page); + const jsonInput = page.locator('#jsonInput'); + await jsonInput.fill('{"CLEAR_ME":true}'); + await page.getByRole('button', { name: 'Add JSON to Memory' }).click(); + await expect(page.locator('#loadDisplay')).toHaveText('Added JSON successfully.'); + + await page.getByRole('button', { name: 'Clear Memory' }).click(); + + await expect(page.locator('#loadDisplay')).toHaveText('Previous results cleared successfully'); + await expect(jsonInput).toHaveValue(''); + expectAndClearConsoleWarnings(diagnostics, [ + /Using memory for getItem \(LF fallback\): previousResults/, + /Using memory for setItem \(LF fallback\): previousResults/, + /Using memory for removeItem \(LF fallback\): previousResults/, + ]); + }); + + test('clears fallback results when the legacy global store also rejects @cross-browser-authoring', async ({ page, diagnostics }) => { + await installAuthoringRoutes(page, diagnostics, { + localForageMode: 'initialization-and-clear-failure', + }); + await page.goto('/index.html'); + await waitForAuthoringReady(page); + expectAndClearConsoleErrors(diagnostics, [/Failed to initialize LocalForage/]); + + await openAuthoringSettings(page); + const jsonInput = page.locator('#jsonInput'); + await jsonInput.fill('{"CLEAR_ME":true}'); + await page.getByRole('button', { name: 'Add JSON to Memory' }).click(); + await expect(page.locator('#loadDisplay')).toHaveText('Added JSON successfully.'); + await page.getByRole('button', { name: 'Clear Memory' }).click(); + + await expect(page.locator('#loadDisplay')).toHaveText('Previous results cleared successfully'); + await expect(jsonInput).toHaveValue(''); + expectAndClearConsoleWarnings(diagnostics, [ + /Using memory for getItem \(LF fallback\): previousResults/, + /Using memory for setItem \(LF fallback\): previousResults/, + /Unable to clear global LocalForage; continuing with Quest storage/, + /Using memory for removeItem \(LF fallback\): previousResults/, + ]); + }); + + test('keeps validation errors distinguishable without optional participant styling @cross-browser-authoring', async ({ page, diagnostics }) => { + await installAuthoringRoutes(page, diagnostics); + await page.goto('/index.html'); + await waitForAuthoringReady(page); + await openAuthoringSettings(page); + await page.getByRole('switch', { name: 'Activate Logic' }).check(); + await closeAuthoringSettings(page); + await renderAuthoringMarkdown(page, readCanonicalFixture('validation.txt')); + + const question = page.locator('#rendering #BOUNDED'); + await question.locator('#bounded').fill('9'); + await question.getByRole('button', { name: 'Next question' }).click(); + + await expect(page.locator('#pagestyle')).toHaveAttribute('href', 'Default.css'); + await expect(question.locator('.validation-container > span')).toHaveCSS( + 'color', + 'rgb(193, 18, 31)', + ); + }); + test('persists settings/results, rejects invalid JSON, and clears previous results @cross-browser-authoring', async ({ page, diagnostics }) => { await installAuthoringRoutes(page, diagnostics); await page.goto('/index.html'); diff --git a/tests/e2e/authoringKnownDefects.spec.js b/tests/e2e/authoringKnownDefects.spec.js deleted file mode 100644 index faa2933..0000000 --- a/tests/e2e/authoringKnownDefects.spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import { test, expect, expectAndClearConsoleErrors } from './support/test.js'; -import { - installAuthoringRoutes, - openAuthoringSettings, - waitForAuthoringReady, -} from './support/authoring.js'; -import { runtimeDefects } from '../knownDefects/registry.js'; - -const DESKTOP_ENGINES = new Set(['chromium-desktop', 'firefox-desktop', 'webkit-desktop']); - -test.describe('open authoring regressions @known-defect @authoring', () => { - test('clears previous results after localforage initialization falls back', async ({ page, diagnostics }, testInfo) => { - test.skip( - !DESKTOP_ENGINES.has(testInfo.project.name), - 'Each desktop browser engine characterizes the fallback storage-adapter defect.', - ); - await installAuthoringRoutes(page, diagnostics, { - localForageMode: 'initialization-failure', - }); - await page.goto('/index.html'); - await waitForAuthoringReady(page); - await openAuthoringSettings(page); - - const jsonInput = page.locator('#jsonInput'); - await jsonInput.fill('{"CLEAR_ME":true}'); - await page.getByRole('button', { name: 'Add JSON to Memory' }).click(); - await expect(page.locator('#loadDisplay')).toHaveText('Added JSON successfully.'); - await page.getByRole('button', { name: 'Clear Memory' }).click(); - - test.fail(true, `${runtimeDefects.authoringFallbackClear.localDefectId}: ${runtimeDefects.authoringFallbackClear.reason}`); - await expect.poll(() => diagnostics.consoleErrors.join('\n')).toMatch(/Error clearing LocalForage:.*removeItem/); - const actual = { - status: await page.locator('#loadDisplay').textContent(), - json: await jsonInput.inputValue(), - }; - expectAndClearConsoleErrors(diagnostics, [/Error clearing LocalForage:.*removeItem/]); - expect(actual).toEqual({ - status: 'Previous results cleared successfully', - json: '', - }); - }); -}); diff --git a/tests/e2e/backDeletion.spec.js b/tests/e2e/backDeletion.spec.js index 3a0a65c..0ba14fd 100644 --- a/tests/e2e/backDeletion.spec.js +++ b/tests/e2e/backDeletion.spec.js @@ -7,7 +7,6 @@ import { openParticipant, selectLabeledResponse, } from './support/harness.js'; -import { runtimeDefects } from '../knownDefects/registry.js'; const questName = 'TEST_NAV'; const pathQuestion = 'PATH'; @@ -95,19 +94,14 @@ test.describe('Back response deletion and branch pruning @core', () => { persistedData, }); await expect(activeQuestion(page, pathQuestion)).toBeVisible(); - // Reaffirm the retained parent route before revisiting its child. Quest's - // separate raw-tree-token response-restoration behavior is not part of - // this deletion contract. - await activeQuestion(page, pathQuestion).locator('label[for="PATH_1"]').click(); + await expect(activeQuestion(page, pathQuestion).locator('#PATH_1')).toBeChecked(); await goNext(page); await expect(activeQuestion(page, detailQuestion)).toBeVisible(); await expect(activeQuestion(page, detailQuestion).locator('#detail')).toHaveValue(''); await expectHealthyHarness(page); }); - test('restores a retained parent response from the raw tree token written by Back @known-defect', async ({ page }) => { - const defect = runtimeDefects.backResumeResponseRestoration; - + test('restores a retained parent response from the raw tree token written by Back', async ({ page }) => { await openParticipant(page, { fixture: 'navigationState.txt', persistedData: { @@ -117,7 +111,6 @@ test.describe('Back response deletion and branch pruning @core', () => { }); await expect(activeQuestion(page, pathQuestion)).toBeVisible(); - test.fail(true, `${defect.localDefectId}: ${defect.reason}`); await expect(activeQuestion(page, pathQuestion).locator('#PATH_1')) .toBeChecked({ timeout: 1_000 }); }); diff --git a/tests/e2e/constructs.spec.js b/tests/e2e/constructs.spec.js index f7aec17..9c73fa8 100644 --- a/tests/e2e/constructs.spec.js +++ b/tests/e2e/constructs.spec.js @@ -56,24 +56,29 @@ test.describe('canonical Markdown construct renderer @canonical @constructs', () await expect(root.locator('#CONFIRM #CONFIRM_COPY')).toHaveAttribute('data-confirm', 'CONFIRM_ORIGINAL'); await expect(root.locator('#CONFIRM #CONFIRM_ORIGINAL')).toHaveAttribute('data-confirmation-for', 'CONFIRM_COPY'); - const scalarTypes = { - EMAIL_VALUE: 'email', - PHONE_VALUE: 'tel', - FULL_SSN_VALUE: 'text', - SHORT_SSN_VALUE: 'text', - ZIP_VALUE: 'text', - DATE_VALUE: 'date', - MONTH_VALUE: 'month', - TIME_VALUE: 'time', - NUMBER_VALUE: 'number', - TEXT_VALUE: 'text', - TEXTBOX_VALUE: 'text', + const scalarControls = { + EMAIL_VALUE: { type: 'email', name: 'Email address' }, + PHONE_VALUE: { type: 'tel', name: 'Telephone' }, + FULL_SSN_VALUE: { type: 'text', name: 'Full SSN' }, + SHORT_SSN_VALUE: { type: 'text', name: 'Last four SSN digits' }, + ZIP_VALUE: { type: 'text', name: 'ZIP code' }, + DATE_VALUE: { type: 'date', name: 'Date' }, + MONTH_VALUE: { type: 'month', name: 'Month' }, + NUMBER_VALUE: { type: 'number', name: 'Number' }, + TEXT_VALUE: { type: 'text', name: 'Text before, text after.' }, + TEXTBOX_VALUE: { type: 'text', name: 'Text-box macro' }, }; - for (const [id, type] of Object.entries(scalarTypes)) { + for (const [id, { type, name }] of Object.entries(scalarControls)) { await expect(root.locator(`#${id}`), `${id} should render as ${type}`).toHaveAttribute('type', type); + await expect(root.locator(`#${id}`), `${id} should retain its authored caption`).toHaveAttribute('aria-label', name); } + await expect(root.locator('#TIME_VALUE')).toHaveAttribute('type', 'time'); + await expect(root.locator('#TIME_VALUE')).not.toHaveAttribute('aria-label'); + await expect(root.locator('label[for="TIME_VALUE"]')).toHaveText('Time'); await expect(root.locator('#STATE_VALUE')).toHaveJSProperty('tagName', 'SELECT'); + await expect(root.locator('#STATE_VALUE')).toHaveAttribute('aria-label', 'State'); await expect(root.locator('#TEXTAREA_VALUE')).toHaveJSProperty('tagName', 'TEXTAREA'); + await expect(root.locator('#TEXTAREA_VALUE')).toHaveAttribute('aria-label', 'Long answer'); await expect(root.locator('#HIDDEN_VALUE')).toHaveAttribute('data-hidden', 'true'); await expect(root.locator('#YES_NO input[type="radio"]')).toHaveCount(2); await expect(root.locator('#YES_NO_PREFER input[type="radio"]')).toHaveCount(3); diff --git a/tests/e2e/gridDeepCoverage.spec.js b/tests/e2e/gridDeepCoverage.spec.js index b2f5e26..71624a8 100644 --- a/tests/e2e/gridDeepCoverage.spec.js +++ b/tests/e2e/gridDeepCoverage.spec.js @@ -14,12 +14,23 @@ import { selectProductionSleepGridRows, } from './support/gridDeepCoverageFixtures.js'; -const CHECKBOX_GRID_PROJECTS = new Set(['chromium-desktop', 'chromium-phone']); +const DEEP_GRID_PROJECTS = new Set(['chromium-desktop', 'chromium-phone']); + +const CONDITIONAL_GRID_MARKDOWN = ` +{"name":"TEST_CONDITIONAL_GRID"} +[GRID_LEAD] The next question uses condition-driven rows. +|grid?|id="GRID_CONDITIONAL"|How often does each visible activity occur?|[ +[ROW_VISIBLE,displayif=equals(SHOW_VISIBLE,1)] Visible activity; +[ROW_HIDDEN,displayif=equals(SHOW_HIDDEN,1)] Hidden activity;]| +(1:Never) +(2:Often)| +[END,end] Done. +`; test.describe('deep participant grid coverage @canonical @responsive', () => { test.beforeEach(async ({}, testInfo) => { test.skip( - !CHECKBOX_GRID_PROJECTS.has(testInfo.project.name), + !DEEP_GRID_PROJECTS.has(testInfo.project.name), 'This deep grid walk runs at the desktop and phone layout boundaries.', ); }); @@ -35,6 +46,7 @@ test.describe('deep participant grid coverage @canonical @responsive', () => { await expect(grid.locator('th[scope="col"]')).toHaveCount(4); const firstRowSecondOption = grid.locator('#D_403155173_1'); await expect(firstRowSecondOption.locator('xpath=following-sibling::label')).toHaveText('Slight Chance'); + await expect(firstRowSecondOption).toHaveAccessibleName('Sitting and reading Slight Chance'); await selectProductionSleepGridRows(grid, expectedRows); await expect(firstRowSecondOption).toBeChecked(); @@ -80,9 +92,50 @@ test.describe('deep participant grid coverage @canonical @responsive', () => { for (const [rowId, value] of Object.entries(expectedRows)) { await expect(grid.locator(`input[name="${rowId}"][value="${value}"]`)).toBeChecked(); } + await expect(firstRowSecondOption).toHaveAccessibleName('Sitting and reading Slight Chance'); const focusTarget = grid.locator('.screen-reader-focus'); await expect(focusTarget).toHaveAttribute('tabindex', '-1'); await expect(focusTarget).toBeFocused(); await expectHealthyHarness(page); }); + + test('evaluates each conditional row from a once-encoded expression and stores only visible selections', async ({ page }) => { + await openParticipant(page, { + markdown: CONDITIONAL_GRID_MARKDOWN, + previousResults: { + SHOW_VISIBLE: '1', + SHOW_HIDDEN: '0', + }, + }); + await goNext(page); + + const grid = activeQuestion(page, 'GRID_CONDITIONAL'); + const visibleRow = grid.locator('tr[data-question-id="ROW_VISIBLE"]'); + const hiddenRow = grid.locator('tr[data-question-id="ROW_HIDDEN"]'); + await expect(visibleRow).toHaveAttribute('data-displayif', 'equals(SHOW_VISIBLE%2C1)'); + await expect(hiddenRow).toHaveAttribute('data-displayif', 'equals(SHOW_HIDDEN%2C1)'); + await expect(visibleRow).toBeVisible(); + await expect(hiddenRow).not.toBeVisible(); + await expect(hiddenRow).toHaveAttribute('data-hidden', 'true'); + + const selected = visibleRow.locator('input[value="2"]'); + await selected.locator('xpath=following-sibling::label').click(); + await expect(selected).toHaveAccessibleName('Visible activity Often'); + await expect(selected).toBeChecked(); + const expectedRows = { ROW_VISIBLE: '2' }; + expect((await harnessSnapshot(page)).state.active).toEqual({ + GRID_CONDITIONAL: expectedRows, + }); + + await goNext(page); + await expect(activeQuestion(page, 'END')).toBeVisible(); + await flushHarness(page); + const stored = await harnessSnapshot(page); + expect(stored.state.survey).toMatchObject({ GRID_CONDITIONAL: expectedRows }); + expect(stored.logs.storeCalls.at(-1).changes['TEST_CONDITIONAL_GRID.GRID_CONDITIONAL']) + .toEqual(expectedRows); + expect(stored.logs.storeCalls.at(-1).changes['TEST_CONDITIONAL_GRID.GRID_CONDITIONAL']) + .not.toHaveProperty('ROW_HIDDEN'); + await expectHealthyHarness(page); + }); }); diff --git a/tests/e2e/keyboardNavigation.spec.js b/tests/e2e/keyboardNavigation.spec.js index a61cd15..a4a778b 100644 --- a/tests/e2e/keyboardNavigation.spec.js +++ b/tests/e2e/keyboardNavigation.spec.js @@ -208,6 +208,51 @@ test.describe('native participant keyboard navigation @canonical @keyboard @wind expect(state.end).toBe(0); }); + test('standalone textarea Reset clears its visible value and active response with native keyboard activation', async ({ page }, testInfo) => { + test.skip(!NATIVE_KEYBOARD_PROJECTS.has(testInfo.project.name), 'The textarea Reset contract runs in every desktop engine and the Windows browser branch.'); + + await openParticipant(page, { + markdown: TEXTAREA_KEYBOARD_MARKDOWN, + persistedData: { + NOTES: 'Previously stored notes', + treeJSON: JSON.stringify({ + rootNode: { value: null, children: [{ value: 'NOTES?', children: [] }] }, + currentNode: 'NOTES?', + }), + }, + }); + await waitInHarness(page, 550); + const question = activeQuestion(page, 'NOTES'); + const textarea = question.locator('#notes'); + const reset = question.getByRole('button', { name: 'Reset this answer' }); + await expect(textarea).toHaveValue('Previously stored notes'); + + for (const [key, value] of [ + ['Enter', 'Clear this response with Enter'], + ['Space', 'Clear this response with Space'], + ]) { + await textarea.fill(value); + await textarea.blur(); + expect((await harnessSnapshot(page)).state.active.NOTES).toBe(value); + + await reset.focus(); + await expect(reset).toBeFocused(); + await page.keyboard.press(key); + await expect(textarea).toHaveValue(''); + await expect(reset).toBeFocused(); + expect((await harnessSnapshot(page)).state.active.NOTES).toBeUndefined(); + } + + await goNext(page); + await page.getByRole('button', { name: 'Continue Without Answering' }).click(); + await expect(activeQuestion(page, 'END')).toBeVisible(); + const snapshot = await expectHealthyHarness(page); + const deletion = snapshot.logs.storeCalls.findLast(({ changes }) => ( + Object.hasOwn(changes, 'TEST_TEXTAREA_KEYBOARD.NOTES') + )); + expect(deletion.changes['TEST_TEXTAREA_KEYBOARD.NOTES']).toBeUndefined(); + }); + test('choice-linked textarea arrows remain native and move its multiline caret', async ({ page }, testInfo) => { test.skip(!NATIVE_KEYBOARD_PROJECTS.has(testInfo.project.name), 'The native textarea contract runs in every desktop engine and the Windows browser branch.'); @@ -800,6 +845,9 @@ test.describe('native participant keyboard navigation @canonical @keyboard @wind await activeQuestion(page, 'END').getByRole('button', { name: 'Back to the previous section' }).click(); await expect(activeQuestion(page, 'GRID_RATE')).toBeVisible(); await expect(activeQuestion(page, 'GRID_RATE').locator('#GRID_WALK_1')).toBeChecked(); + await expect(activeQuestion(page, 'GRID_RATE').locator('#GRID_WALK_1')).toHaveAccessibleName( + 'Walking Sometimes', + ); }); test('native checkbox-grid traversal and selection retain native focus', async ({ page }, testInfo) => { diff --git a/tests/e2e/knownDefects.spec.js b/tests/e2e/knownDefects.spec.js deleted file mode 100644 index b6e6d99..0000000 --- a/tests/e2e/knownDefects.spec.js +++ /dev/null @@ -1,98 +0,0 @@ -import { test, expect } from './support/test.js'; -import { activeQuestion, goNext, openParticipant, waitInHarness } from './support/harness.js'; -import { analyzeQuestAxe, matchesAxeDefect } from './support/axe.js'; -import { accessibilityDefects, axeDefects } from '../knownDefects/registry.js'; - -const CHOICE_SEMANTIC_PROJECTS = new Set([ - 'chromium-desktop', - 'webkit-desktop', - 'chromium-windows-ua', -]); -async function axeFindingsForDefect(page, defect) { - const findings = await analyzeQuestAxe(page); - return findings.filter((finding) => matchesAxeDefect(finding, defect)); -} - -function characterizeAxeDefect(name, defect, prepare, project = 'chromium-desktop') { - test(name, async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== project, `Characterized in ${project}; blocking scans still run in every desktop engine.`); - await prepare(page); - const findings = await axeFindingsForDefect(page, defect); - test.fail(true, `${defect.localDefectId}: ${defect.reason}`); - expect(findings).toEqual([]); - }); -} - -test.describe('open accessibility regressions @known-defect', () => { - characterizeAxeDefect( - 'progressbar has an accessible name', - axeDefects.progressbarName, - (page) => openParticipant(page, { fixture: 'navigationState.txt' }), - ); - - characterizeAxeDefect( - 'progressbar exposes a valid ARIA value', - axeDefects.progressbarValue, - (page) => openParticipant(page, { fixture: 'navigationState.txt' }), - ); - - characterizeAxeDefect( - 'responsive grid has no empty row-header spacer', - axeDefects.emptyGridHeader, - async (page) => { - await openParticipant(page, { fixture: 'gridResponsive.txt' }); - await goNext(page); - await expect(activeQuestion(page, 'GRID_RATE')).toBeVisible(); - }, - ); - - characterizeAxeDefect( - 'validation message meets contrast requirements', - axeDefects.validationContrast, - async (page) => { - await openParticipant(page, { fixture: 'validation.txt' }); - await activeQuestion(page, 'BOUNDED').locator('#bounded').fill('9'); - await goNext(page); - await expect(activeQuestion(page).locator('.validation-container')).toBeVisible(); - }, - ); - - characterizeAxeDefect( - 'participant action text retains sufficient contrast while hovered', - axeDefects.actionHoverContrast, - async (page) => { - await openParticipant(page, { fixture: 'validation.txt' }); - await activeQuestion(page, 'BOUNDED').getByRole('button', { name: 'Next question' }).hover(); - }, - 'firefox-desktop', - ); - - characterizeAxeDefect( - 'bounded numeric input has a non-title label', - axeDefects.validationLabel, - async (page) => { - await openParticipant(page, { fixture: 'validation.txt' }); - await activeQuestion(page, 'BOUNDED').locator('#bounded').fill('9'); - await goNext(page); - await expect(activeQuestion(page).locator('.validation-container')).toBeVisible(); - }, - ); - - test('#1079 exposes each grid choice with its row and column name plus checked state', async ({ page }, testInfo) => { - test.skip(!CHOICE_SEMANTIC_PROJECTS.has(testInfo.project.name), 'Chromium, WebKit, and the Windows branch capture the assistive-technology semantic baseline.'); - - await openParticipant(page, { fixture: 'gridResponsive.txt' }); - await goNext(page); - await waitInHarness(page, 550); - const question = activeQuestion(page, 'GRID_RATE'); - await question.locator('tr[data-question-id="GRID_WALK"] label', { hasText: 'Sometimes' }).click(); - await expect(question.locator('#GRID_WALK_1')).toBeChecked(); - - test.fail(true, `${accessibilityDefects[1079].issue}: a grid choice must expose its row prompt, column option, and checked state together.`); - const selected = question.getByRole('radio', { name: /Walking.*Sometimes|Sometimes.*Walking/ }); - await expect(selected).toHaveCount(1); - await expect(selected).toBeChecked(); - await expect(question.getByRole('radio', { name: /Cycling.*Often|Often.*Cycling/ })).not.toBeChecked(); - }); - -}); diff --git a/tests/e2e/module4AddressPaths.spec.js b/tests/e2e/module4AddressPaths.spec.js index 806e425..d5c4ad3 100644 --- a/tests/e2e/module4AddressPaths.spec.js +++ b/tests/e2e/module4AddressPaths.spec.js @@ -72,6 +72,65 @@ test.describe('Module 4 residential-address paths @canonical @corpus', () => { test.skip(!DESKTOP_ENGINES.has(testInfo.project.name), 'Address paths run in the desktop browser matrix.'); }); + for (const addressCase of [ + { + locale: 'English', + markdown: englishAddressFixture, + lang: 'en', + names: [ + 'Street number', + 'Full Street name', + 'Apartment, suite, unit, building, etc.', + 'City', + 'State/Province', + 'Zip code', + 'Country', + ], + backupNames: ['City:', 'State/Province:', 'Zip code:', 'Country:'], + }, + { + locale: 'Spanish', + markdown: spanishAddressFixture, + lang: 'es', + names: [ + 'Número de la calle', + 'Nombre completo de la calle', + 'Apartamento, suite, unidad, edificio, etc.', + 'Ciudad', + 'Estado o provincia', + 'Código postal', + 'País', + ], + backupNames: ['Ciudad:', 'Estado o provincia:', 'Código postal:', 'País:'], + }, + ]) { + test(`gives every ${addressCase.locale} address field its accessible name`, async ({ page }) => { + await openParticipant(page, { markdown: addressCase.markdown, lang: addressCase.lang }); + const addressInputs = activeQuestion(page, 'D_121490150').locator('input[type="text"]'); + await expect(addressInputs).toHaveCount(addressCase.names.length); + + for (const [index, accessibleName] of addressCase.names.entries()) { + await expect(addressInputs.nth(index)).toHaveAccessibleName(accessibleName); + } + + expect(await addressInputs.evaluateAll((inputs) => ( + new Set(inputs.map((input) => input.getAttribute('aria-label'))).size + ))).toBe(addressCase.names.length); + + await goNext(page); + await continueWithoutAnswering(page, addressCase.lang); + const backupInputs = activeQuestion(page, 'D_920576363').locator('input[type="text"]:visible'); + await expect(backupInputs).toHaveCount(addressCase.backupNames.length); + for (const [index, accessibleName] of addressCase.backupNames.entries()) { + await expect(backupInputs.nth(index)).toHaveAccessibleName(accessibleName); + } + expect(await backupInputs.evaluateAll((inputs) => ( + new Set(inputs.map((input) => input.getAttribute('aria-label'))).size + ))).toBe(addressCase.backupNames.length); + await expectHealthyHarness(page); + }); + } + test('keeps a complete primary address out of backup and intersection fallbacks, then stores and reuses it', async ({ page }) => { await openParticipant(page, { markdown: englishAddressFixture }); await fillCompletePrimaryAddress(page); diff --git a/tests/e2e/participantNavigationState.spec.js b/tests/e2e/participantNavigationState.spec.js index caa686e..2aea22e 100644 --- a/tests/e2e/participantNavigationState.spec.js +++ b/tests/e2e/participantNavigationState.spec.js @@ -193,30 +193,65 @@ test.describe('participant navigation and state @core @canonical', () => { for (const storeFailure of [ { label: 'non-200 result', - config: { storeMode: 'non200' }, + outcome: { + kind: 'resolve', + value: { code: 503, message: 'Synthetic non-200 store response' }, + }, }, { label: 'rejected operation', - config: { storeMode: 'rejected' }, + outcome: { kind: 'reject', message: 'Synthetic store rejection' }, }, ]) { - test(`reverts navigation and reports the host error after a ${storeFailure.label}`, async ({ page }) => { + test(`restores the response and permits an exact retry after a ${storeFailure.label}`, async ({ page }) => { await openParticipant(page, { fixture: 'navigationState.txt', - ...storeFailure.config, + storeOutcomes: [ + storeFailure.outcome, + { kind: 'resolve', value: { code: 200 } }, + ], }); await selectLabeledResponse(page, 'Yes'); await goNext(page); await flushHarness(page); await expect(activeQuestion(page, 'PATH')).toBeVisible(); + await expect(activeQuestion(page, 'PATH').locator('#PATH_1')).toBeChecked(); await expect(page.locator('#storeErrorModal')).toHaveClass(/show/); - const snapshot = await expectHealthyHarness(page, { allowErrors: true }); - expect(snapshot.logs.errors.some((entry) => entry.message.includes('syncToStore'))).toBe(true); - expect(snapshot.logs.storeCalls).toHaveLength(1); - expect(snapshot.logs.storeCalls[0].outcome.kind).toBe( - storeFailure.config.storeMode === 'rejected' ? 'reject' : 'resolve', + const failed = await expectHealthyHarness(page, { allowErrors: true }); + expect(failed.logs.errors.filter((entry) => entry.message.includes('syncToStore'))).toHaveLength(1); + expect(failed.logs.storeCalls).toHaveLength(1); + expect(failed.logs.storeCalls[0].outcome.kind).toBe(storeFailure.outcome.kind); + expect(failed.logs.storeCalls[0].changes).toEqual({ + 'TEST_NAV.PATH': '1', + 'TEST_NAV.treeJSON': expect.any(String), + }); + expect(JSON.parse(failed.logs.storeCalls[0].changes['TEST_NAV.treeJSON']).currentNode).toBe('PATH?'); + expect(failed.state.survey).toEqual({}); + expect(failed.state.active).toEqual({ PATH: '1' }); + expect(failed.state.mapping).toMatchObject({ PATH: 'PATH' }); + expect(failed.state.cache).toMatchObject({ PATH: '1' }); + + await page.locator('#storeErrorModal .btn-close').click(); + await expect(page.locator('#storeErrorModal')).not.toHaveClass(/show/); + await goNext(page); + await flushHarness(page); + + await expect(activeQuestion(page, 'DETAIL')).toBeVisible(); + const retried = await expectHealthyHarness(page, { allowErrors: true }); + expect(retried.logs.storeCalls).toHaveLength(2); + expect(retried.logs.storeCalls[1]).toMatchObject({ + status: 'fulfilled', + value: { code: 200 }, + }); + expect(retried.logs.storeCalls[1].changes).toEqual( + retried.logs.storeCalls[0].changes, ); + expect(retried.state.survey).toEqual({ + PATH: '1', + treeJSON: retried.logs.storeCalls[1].changes['TEST_NAV.treeJSON'], + }); + expect(retried.state.active).toEqual({}); }); } diff --git a/tests/e2e/productionCompoundForms.spec.js b/tests/e2e/productionCompoundForms.spec.js index c15a2bd..86cdc3c 100644 --- a/tests/e2e/productionCompoundForms.spec.js +++ b/tests/e2e/productionCompoundForms.spec.js @@ -9,8 +9,8 @@ import { goNext, harnessSnapshot, openParticipant, + waitInHarness, } from './support/harness.js'; -import { accessibilityDefects } from '../knownDefects/registry.js'; import { readLockedMarkdown, repositoryRoot, @@ -19,6 +19,7 @@ import { } from './support/corpus.js'; const dietQuestion = 'D_916948380'; +const dietModuleName = 'D_515124081'; const dietVegetables = 'D_970075000'; const dietFruit = 'D_724001040'; const qualityOfLifeQuestion = 'D_284353934'; @@ -27,79 +28,345 @@ const commuteModes = 'D_421586693'; const commuteDuration = 'D_733638576'; const carDuration = 'D_583216333'; const taxiDuration = 'D_920653797'; +const walkingDuration = 'D_175502589'; +const commuteDurationGroups = [ + carDuration, + taxiDuration, + 'D_843066684', + 'D_101850740', + 'D_317194063', + 'D_621948602', + walkingDuration, + 'D_614443767', +]; const commuteFollowUpImage = 'https://user-images.githubusercontent.com/64271614/86169815-34d86f80-bae8-11ea-98a6-1b3de33fedf0.png'; const localImageAsset = path.join(repositoryRoot, 'tests/e2e/assets/FemaleBaldness1.png'); +const compoundAccessibilityProjects = new Set([ + 'chromium-desktop', + 'firefox-desktop', + 'webkit-desktop', + 'chromium-windows-ua', +]); +const conditionalKeyboardProjects = new Set([ + 'chromium-desktop', + 'firefox-desktop', + 'chromium-windows-ua', +]); +const dietGroupNames = [ + 'D_136695974', + 'D_172676248', + 'D_174911197', + 'D_181240315', + 'D_284856542', + 'D_379851141', + 'D_412397796', + 'D_484124027', + 'D_529369529', + 'D_593777242', + 'D_724001040', + 'D_736936997', + 'D_771538757', + 'D_827631896', + 'D_848411929', + 'D_876252122', + 'D_896562194', + 'D_910980928', + 'D_930916389', + 'D_970075000', +]; +const qualityOfLifeGroupNames = [ + 'D_559540891', + 'D_780866928', + 'D_783201540', + 'D_917425212', +]; +const conditionalRuntimeCases = [ + { + questionId: 'D_532260562', + description: 'complex work-return conditions', + persistedData: { + D_614208066: '104430631', + D_799598595: ['339946103', '364857371'], + }, + visibleGroups: ['D_827461454', 'D_306356124'], + }, + { + questionId: 'D_532260562', + description: 'same-mode work-return second condition arm', + persistedData: { + D_614208066: '353358909', + D_308634850: '104430631', + D_421586693: ['767755239', '385609081'], + }, + visibleGroups: ['D_827461454', 'D_306356124'], + }, + { + questionId: 'D_135529881', + description: 'indented school-outbound markup', + previousResults: { D_784967158: '551525967' }, + persistedData: { + D_404863614: ['782113721', '207263450'], + }, + visibleGroups: ['D_891237683', 'D_980695076'], + }, + { + questionId: 'D_219317801', + description: 'complex indented school-return conditions', + previousResults: { D_784967158: '551525967' }, + persistedData: { + D_496801729: '104430631', + D_345355061: ['467242967', '645051966'], + }, + visibleGroups: ['D_990162153', 'D_576149634'], + }, +]; async function chooseRadio(question, name, value) { await question.locator(`label[for="${name}_${value}"]`).click(); } -async function compoundChoiceContract(question, { - groupName, - value, - prompt, - answer, -}) { - const control = question.locator(`input[type="radio"][name="${groupName}"][value="${value}"]`); - await expect(control).toHaveCount(1); +function exactTextPattern(text) { + return new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); +} - return control.evaluate((input, expected) => { - const normalize = (text) => String(text ?? '').replace(/\s+/g, ' ').trim().toLowerCase(); - const referencedElements = (element, attribute) => String(element.getAttribute(attribute) ?? '') - .split(/\s+/) - .filter(Boolean) - .map((id) => document.getElementById(id)) - .filter(Boolean); - const referencedText = (element, attribute) => referencedElements(element, attribute) - .map(({ textContent }) => textContent ?? '') - .join(' '); - const accessibleNameSource = (element, nativeName = '') => { - const labelledBy = referencedElements(element, 'aria-labelledby'); - if (labelledBy.length > 0) { - return labelledBy.map(({ textContent }) => textContent ?? '').join(' '); - } - if (element.hasAttribute('aria-label')) { - return element.getAttribute('aria-label') ?? ''; +function normalizeText(text) { + return String(text ?? '').replace(/\s+/g, ' ').trim(); +} + +function authoredCompoundPrompts(markdown, questionId) { + const lines = markdown.split(/\r?\n/); + const questionStart = lines.findIndex((line) => line.startsWith(`[${questionId}`)); + expect(questionStart, `Locked corpus question ${questionId} must exist`).toBeGreaterThanOrEqual(0); + + const prompts = {}; + let precedingPrompt = ''; + for (const rawLine of lines.slice(questionStart + 1)) { + const line = rawLine.trim(); + if (line.startsWith('[')) break; + if (!line) continue; + + const response = line.match(/^\([^:]+:([^,)]+)/); + if (response) { + prompts[response[1]] ??= normalizeText(precedingPrompt.replace(/<[^>]*>/g, '')); + } else { + precedingPrompt = line; + } + } + return prompts; +} + +function authoredConditionalCompoundPrompts(markdown, questionId) { + const lines = markdown.split(/\r?\n/); + const questionStart = lines.findIndex((line) => line.startsWith(`[${questionId}`)); + expect(questionStart, `Locked corpus question ${questionId} must exist`).toBeGreaterThanOrEqual(0); + + const prompts = {}; + let precedingPrompt = ''; + for (const rawLine of lines.slice(questionStart + 1)) { + const line = rawLine.trim(); + if (line.startsWith('[')) break; + if (!line) continue; + + const prompt = line.match(/^\|displayif=[^|]+\|\s*(.*?)\|\s*$/); + if (prompt) { + precedingPrompt = normalizeText(prompt[1].replace(/<[^>]*>/g, '')); + continue; + } + + const response = line.match(/^\([^:]+:([^,)]+),displayif=/); + if (response && precedingPrompt) { + prompts[response[1]] ??= precedingPrompt; + } + } + return prompts; +} + +async function expectConditionalCompoundRadioStructure( + question, + expectedPromptsByGroup, + visibleGroupNames, + expectedRadiosPerGroup = 5, +) { + const inventory = await question.evaluate((form) => { + const normalize = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const normalizeCondition = (value) => { + try { + return normalize(decodeURIComponent(value)); + } catch { + return normalize(value); } - return nativeName; }; - const radioNames = (container) => new Set(Array.from( - container.querySelectorAll('input[type="radio"]'), - ({ name }) => name, - )); + const visible = (element) => { + if (!element || element.hidden || element.getAttribute('aria-hidden') === 'true') return false; + const style = getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0; + }; - // Keep ARIA's name-source precedence intact. Combining overridden sources - // could hide an incomplete aria-labelledby/aria-label regression. - const ownName = accessibleNameSource( - input, - Array.from(input.labels, ({ textContent }) => textContent).join(' '), - ); - const describedContext = referencedText(input, 'aria-describedby'); - const groupedContext = []; - const questionRoot = input.closest('form.question'); + const fieldset = form.querySelector(':scope > fieldset'); + const radios = Array.from(fieldset.querySelectorAll(':scope > .response > input[type="radio"][name]')); + const radiosByName = new Map(); + radios.forEach((radio) => { + if (!radiosByName.has(radio.name)) radiosByName.set(radio.name, []); + radiosByName.get(radio.name).push(radio); + }); + return Array.from(fieldset.querySelectorAll(':scope > [role="radiogroup"]')).map((group) => { + const ownedIds = String(group.getAttribute('aria-owns') ?? '').split(/\s+/).filter(Boolean); + const ownedRadios = ownedIds.map((id) => document.getElementById(id)); + const radioNames = [...new Set(ownedRadios.map((radio) => radio?.name).filter(Boolean))]; + const groupName = radioNames[0] ?? ''; + const expectedRadios = radiosByName.get(groupName) ?? []; + const groupCondition = normalizeCondition(group.getAttribute('displayif')); + return { + groupName, + text: normalize(group.textContent), + ariaLabel: normalize(group.getAttribute('aria-label')), + ownedIds, + expectedIds: expectedRadios.map(({ id }) => id), + answerLabels: ownedRadios.map((radio) => normalize(radio?.labels[0]?.textContent)), + visible: visible(group), + promptCondition: groupCondition, + responseConditionsMatch: expectedRadios.every((radio) => ( + normalizeCondition(radio.closest('.response')?.getAttribute('displayif')) === groupCondition + )), + responseVisibilityMatches: expectedRadios.every((radio) => ( + visible(radio.closest('.response')) === visible(group) + )), + inputsAreNativeAndLabelled: ownedRadios.every((radio) => ( + radio?.type === 'radio' + && radio.labels.length === 1 + && normalize(radio.labels[0].textContent).length > 0 + && !radio.hasAttribute('aria-label') + && !radio.hasAttribute('aria-labelledby') + )), + responsesRemainDirectChildren: expectedRadios.every((radio) => ( + radio.closest('.response')?.parentElement === fieldset + )), + promptIsNotFocusableOrLive: !group.hasAttribute('tabindex') + && group.getAttribute('role') === 'radiogroup' + && !group.hasAttribute('aria-live'), + }; + }); + }); - for (let ancestor = input.parentElement; ancestor && ancestor !== questionRoot; ancestor = ancestor.parentElement) { - const names = radioNames(ancestor); - if (names.size !== 1 || !names.has(input.name)) continue; + const expectedGroupNames = Object.keys(expectedPromptsByGroup); + expect(inventory).toHaveLength(expectedGroupNames.length); + expect(inventory.map(({ groupName }) => groupName).sort()).toEqual([...expectedGroupNames].sort()); + expect(inventory.filter(({ visible }) => visible).map(({ groupName }) => groupName).sort()) + .toEqual([...visibleGroupNames].sort()); - if (ancestor.tagName === 'FIELDSET') { - groupedContext.push(ancestor.querySelector(':scope > legend')?.textContent ?? ''); - } - if (ancestor.getAttribute('role') === 'radiogroup') { - groupedContext.push(accessibleNameSource(ancestor)); - groupedContext.push(referencedText(ancestor, 'aria-describedby')); + for (const group of inventory) { + expect.soft(group.text).toBe(expectedPromptsByGroup[group.groupName]); + expect.soft(group.ariaLabel).toBe(expectedPromptsByGroup[group.groupName]); + expect.soft(group.ownedIds).toEqual(group.expectedIds); + expect.soft(group.ownedIds).toHaveLength(expectedRadiosPerGroup); + expect.soft(group.promptCondition).not.toBe(''); + expect.soft(group.responseConditionsMatch).toBe(true); + expect.soft(group.responseVisibilityMatches).toBe(true); + expect.soft(group.inputsAreNativeAndLabelled).toBe(true); + expect.soft(group.responsesRemainDirectChildren).toBe(true); + expect.soft(group.promptIsNotFocusableOrLive).toBe(true); + + const exposedGroup = question.getByRole('radiogroup', { + name: expectedPromptsByGroup[group.groupName], + exact: true, + }); + await expect.soft(exposedGroup).toHaveCount(group.visible ? 1 : 0); + if (group.visible) { + const ariaSnapshot = await exposedGroup.ariaSnapshot(); + expect.soft(ariaSnapshot.match(/^\s*- radio(?:\s|$)/gm) ?? []).toHaveLength( + expectedRadiosPerGroup, + ); + for (const answer of group.answerLabels) { + expect.soft(ariaSnapshot).toContain(`radio ${JSON.stringify(answer)}`); } } + } +} - const normalizedName = normalize(ownName); - const normalizedPrompt = normalize(expected.prompt); - return { - answerNamed: normalizedName.includes(normalize(expected.answer)), - promptContext: normalizedName.includes(normalizedPrompt) - || normalize(describedContext).includes(normalizedPrompt) - || normalize(groupedContext.join(' ')).includes(normalizedPrompt), - }; - }, { prompt, answer }); +async function expectCompoundRadioStructure( + question, + expectedGroupNames, + expectedRadioCount, + expectedPromptsByGroup, +) { + const inventory = await question.locator('[role="radiogroup"]').evaluateAll((groups) => { + const idCounts = Array.from(document.querySelectorAll('[id]')).reduce((counts, element) => { + counts[element.id] = (counts[element.id] ?? 0) + 1; + return counts; + }, {}); + + return groups.map((group) => { + const radios = Array.from(group.querySelectorAll('input[type="radio"]')); + const labelIds = String(group.getAttribute('aria-labelledby') ?? '').split(/\s+/).filter(Boolean); + const prompts = labelIds.map((id) => document.getElementById(id)); + let previousNode = group.previousSibling; + while (previousNode && ( + (previousNode.nodeType === Node.TEXT_NODE && previousNode.textContent.trim() === '') + || (previousNode.nodeType === Node.ELEMENT_NODE && ( + previousNode.tagName === 'BR' || previousNode.classList.contains('screen-reader-focus') + )) + )) previousNode = previousNode.previousSibling; + return { + labelIds, + labelIsNearestPrompt: prompts.length === 1 && prompts[0] === previousNode, + labelsResolveUniquely: prompts.every((prompt, index) => ( + prompt && idCounts[labelIds[index]] === 1 + )), + promptIsVisible: prompts.every((prompt) => { + if (!prompt || prompt.hidden || prompt.getAttribute('aria-hidden') === 'true') return false; + const style = getComputedStyle(prompt); + return style.display !== 'none' && style.visibility !== 'hidden' && prompt.getClientRects().length > 0; + }), + promptRole: prompts.map((prompt) => prompt?.getAttribute('role') ?? null), + promptText: prompts.map((prompt) => prompt?.textContent.replace(/\s+/g, ' ').trim() ?? ''), + radioNames: [...new Set(radios.map(({ name }) => name))], + radioCount: radios.length, + radioLabelsAreNativeAndNonempty: radios.every((radio) => ( + radio.labels.length === 1 && radio.labels[0].textContent.trim().length > 0 + )), + radiosKeepNativeNameSource: radios.every((radio) => ( + !radio.hasAttribute('aria-label') && !radio.hasAttribute('aria-labelledby') + )), + radiosBelongDirectlyToGroup: radios.every((radio) => radio.closest('[role="radiogroup"]') === group), + }; + }); + }); + + expect(inventory).toHaveLength(expectedGroupNames.length); + expect(inventory.flatMap(({ radioNames }) => radioNames).sort()).toEqual([...expectedGroupNames].sort()); + expect(inventory.reduce((total, { radioCount }) => total + radioCount, 0)).toBe(expectedRadioCount); + if (expectedPromptsByGroup) { + expect(Object.keys(expectedPromptsByGroup).sort()).toEqual([...expectedGroupNames].sort()); + } + for (const group of inventory) { + expect.soft(group.labelIds).toHaveLength(1); + expect.soft(group.labelIsNearestPrompt).toBe(true); + expect.soft(group.labelsResolveUniquely).toBe(true); + expect.soft(group.promptIsVisible).toBe(true); + expect.soft(group.promptRole).toEqual([null]); + expect.soft(group.radioNames).toHaveLength(1); + expect.soft(group.radioLabelsAreNativeAndNonempty).toBe(true); + expect.soft(group.radiosKeepNativeNameSource).toBe(true); + expect.soft(group.radiosBelongDirectlyToGroup).toBe(true); + if (expectedPromptsByGroup) { + const expectedPrompt = expectedPromptsByGroup[group.radioNames[0]]; + expect.soft(group.promptText[0]).toContain(expectedPrompt); + } + } +} + +async function expectCompoundChoice(question, { + groupName, + value, + prompt, + answer, +}) { + const group = question.locator(`[role="radiogroup"]:has(input[name="${groupName}"])`); + const control = group.locator(`input[type="radio"][name="${groupName}"][value="${value}"]`); + await expect(group).toHaveCount(1); + await expect(group).toHaveAccessibleName(new RegExp(prompt, 'i')); + await expect(control).toHaveCount(1); + await expect(control).toHaveAccessibleName(exactTextPattern(answer)); } test.describe('locked production compound-response forms @corpus', () => { @@ -132,12 +399,15 @@ test.describe('locked production compound-response forms @corpus', () => { await flushHarness(page); const partial = await harnessSnapshot(page); - expect(partial.state.survey).toMatchObject({ - [dietQuestion]: { - [dietVegetables]: '192247286', - [dietFruit]: '351464854', - }, - }); + const expectedDietResponses = { + [dietVegetables]: '192247286', + [dietFruit]: '351464854', + }; + expect(partial.state.survey).toMatchObject({ [dietQuestion]: expectedDietResponses }); + const dietStoreCall = partial.logs.storeCalls.findLast(({ changes }) => ( + Object.hasOwn(changes, `${dietModuleName}.${dietQuestion}`) + )); + expect(dietStoreCall?.changes[`${dietModuleName}.${dietQuestion}`]).toEqual(expectedDietResponses); await page.getByRole('button', { name: 'Back to the previous section' }).click(); await expect(activeQuestion(page, dietQuestion)).toBeVisible(); @@ -161,6 +431,7 @@ test.describe('locked production compound-response forms @corpus', () => { test('Module 4 commute duration limits visible subgroups to selected modes and persists them independently', async ({ page }) => { const markdown = readLockedMarkdown('module4'); + const expectedPrompts = authoredConditionalCompoundPrompts(markdown, commuteDuration); const selectedModes = ['767755239', '385609081']; // The valid selected-mode transition leads to a production image. It is // unrelated to this contract, so retain the suite's boundary by @@ -187,6 +458,11 @@ test.describe('locked production compound-response forms @corpus', () => { await expect(question.locator(`input[name="${carDuration}"]`).first().locator('xpath=..')).toBeVisible(); await expect(question.locator(`input[name="${taxiDuration}"]`).first().locator('xpath=..')).toBeVisible(); await expect(question.locator('input[name="D_843066684"]').first().locator('xpath=..')).toBeHidden(); + await expectConditionalCompoundRadioStructure( + question, + expectedPrompts, + [carDuration, taxiDuration], + ); await chooseRadio(question, carDuration, '248303092'); await chooseRadio(question, taxiDuration, '638092100'); @@ -200,9 +476,10 @@ test.describe('locked production compound-response forms @corpus', () => { [taxiDuration]: '638092100', }; expect(completed.state.survey).toMatchObject({ [commuteDuration]: expectedDurations }); - expect(completed.logs.storeCalls.some(({ changes }) => ( - JSON.stringify(changes[`${module4Name}.${commuteDuration}`]) === JSON.stringify(expectedDurations) - ))).toBe(true); + const durationStoreCall = completed.logs.storeCalls.findLast(({ changes }) => ( + Object.hasOwn(changes, `${module4Name}.${commuteDuration}`) + )); + expect(durationStoreCall?.changes[`${module4Name}.${commuteDuration}`]).toEqual(expectedDurations); await goBack(page); const returned = activeQuestion(page, commuteDuration); @@ -221,54 +498,287 @@ test.describe('locked production compound-response forms @corpus', () => { await expect(restored.locator(`#${carDuration}_248303092`)).toBeChecked(); await expect(restored.locator(`#${taxiDuration}_638092100`)).toBeChecked(); await expect(restored.locator('input[name="D_843066684"]').first().locator('xpath=..')).toBeHidden(); + await expectConditionalCompoundRadioStructure( + restored, + expectedPrompts, + [carDuration, taxiDuration], + ); + + // Change the controlling modes through the real Back path. Quest clears + // downstream duration data and reuses the cached form, so this guards + // against stale hidden groups, duplicate semantics, and stale payloads. + await goBack(page); + const modeQuestion = activeQuestion(page, commuteModes); + await expect(modeQuestion).toBeVisible(); + await expect(modeQuestion.locator(`#${commuteModes}_385609081`)).toBeChecked(); + await modeQuestion.locator(`label[for="${commuteModes}_385609081"]`).click(); + await modeQuestion.locator(`label[for="${commuteModes}_817932069"]`).click(); + await expect(modeQuestion.locator(`#${commuteModes}_385609081`)).not.toBeChecked(); + await expect(modeQuestion.locator(`#${commuteModes}_817932069`)).toBeChecked(); + await goNext(page); + + const changed = activeQuestion(page, commuteDuration); + await expect(changed).toBeVisible(); + await expectConditionalCompoundRadioStructure( + changed, + expectedPrompts, + [carDuration, walkingDuration], + ); + await expect(changed.locator(`#${carDuration}_248303092`)).not.toBeChecked(); + await expect(changed.locator(`#${taxiDuration}_638092100`)).not.toBeChecked(); + await chooseRadio(changed, carDuration, '248303092'); + await chooseRadio(changed, walkingDuration, '638092100'); + await goNext(page); + + await flushHarness(page); + const changedSnapshot = await harnessSnapshot(page); + const changedDurations = { + [carDuration]: '248303092', + [walkingDuration]: '638092100', + }; + const changedModes = { + [commuteModes]: ['767755239', '817932069'], + D_669966323: undefined, + }; + expect(changedSnapshot.state.survey[commuteModes]).toEqual(changedModes); + expect(changedSnapshot.state.survey[commuteDuration]).toEqual(changedDurations); + const modeStoreCall = changedSnapshot.logs.storeCalls.findLast(({ changes }) => ( + Object.hasOwn(changes, `${module4Name}.${commuteModes}`) + )); + expect(modeStoreCall?.changes[`${module4Name}.${commuteModes}`]).toEqual(changedModes); + const changedStoreCall = changedSnapshot.logs.storeCalls.findLast(({ changes }) => ( + Object.hasOwn(changes, `${module4Name}.${commuteDuration}`) + )); + expect(changedStoreCall?.changes[`${module4Name}.${commuteDuration}`]).toEqual(changedDurations); + + await rerenderParticipant(page, { + markdown, + lang: 'en', + persistedData: { + ...changedSnapshot.state.survey, + treeJSON: treeAt(commuteDuration, commuteModes), + }, + }); + const changedRestored = activeQuestion(page, commuteDuration); + await expect(changedRestored.locator(`#${carDuration}_248303092`)).toBeChecked(); + await expect(changedRestored.locator(`#${walkingDuration}_638092100`)).toBeChecked(); + await expect(changedRestored.locator(`input[name="${taxiDuration}"]`).first().locator('xpath=..')).toBeHidden(); + await expectConditionalCompoundRadioStructure( + changedRestored, + expectedPrompts, + [carDuration, walkingDuration], + ); await expectHealthyHarness(page); }); - test('Diet Screener choices expose both the food subgroup and answer in accessible context @known-defect', async ({ page }) => { - await openParticipant(page, { - markdown: readLockedMarkdown('moduleDietScreener'), - persistedData: { treeJSON: treeAt(dietQuestion) }, + for (const runtimeCase of conditionalRuntimeCases) { + test(`Module 4 exposes ${runtimeCase.description} as labelled groups`, async ({ page }) => { + const markdown = readLockedMarkdown('module4'); + const expectedPrompts = authoredConditionalCompoundPrompts(markdown, runtimeCase.questionId); + expect(Object.keys(expectedPrompts)).toHaveLength(8); + + await openParticipant(page, { + markdown, + previousResults: runtimeCase.previousResults, + persistedData: { + ...runtimeCase.persistedData, + treeJSON: treeAt(runtimeCase.questionId), + }, + }); + + const question = activeQuestion(page, runtimeCase.questionId); + await expect(question).toBeVisible(); + await expectConditionalCompoundRadioStructure( + question, + expectedPrompts, + runtimeCase.visibleGroups, + ); + await expectHealthyHarness(page); }); + } - const question = activeQuestion(page, dietQuestion); - await expect(question).toBeVisible(); - expect(await question.locator('input[type="radio"]').count()).toBeGreaterThan(0); - test.fail(true, `${accessibilityDefects.compoundQuestionContext.localDefectId}: ${accessibilityDefects.compoundQuestionContext.reason}`); - const intendedChoices = [ - { groupName: dietVegetables, value: '192247286', prompt: 'Vegetables', answer: '1 per week' }, - { groupName: dietFruit, value: '351464854', prompt: 'Fruit', answer: '1 per day' }, - ]; - for (const expected of intendedChoices) { - const contract = await compoundChoiceContract(question, expected); - expect.soft(contract.answerNamed, `${expected.groupName} must retain its response-option name`).toBe(true); - expect.soft(contract.promptContext, `${expected.groupName} must expose its subgroup prompt`).toBe(true); - } +}); + +test.describe('locked compound-radio accessibility @canonical @windows-a11y @corpus', () => { + test.beforeEach(async ({}, testInfo) => { + test.skip( + !compoundAccessibilityProjects.has(testInfo.project.name), + 'Compound-radio semantics run in each desktop engine and the Windows user-agent project.', + ); }); - test('QoL choices expose each subgroup prompt with its answer @known-defect', async ({ page }) => { - await openParticipant(page, { - markdown: readLockedMarkdown('moduleQoL'), - persistedData: { treeJSON: treeAt(qualityOfLifeQuestion) }, + for (const locale of ['en', 'es']) { + test(`manual compound-radio fixture ${locale} remains a valid acceptance target`, async ({ page }) => { + const fixture = locale === 'es' ? 'compoundRadioGroupsSpanish.txt' : 'compoundRadioGroups.txt'; + await page.goto(`/tests/harness/participant.html?fixture=${fixture}`); + await page.waitForFunction(() => ( + document.querySelector('#questionnaireRoot form.question.active#COMPOUND_RADIOS') + )); + + const question = activeQuestion(page, 'COMPOUND_RADIOS'); + await expect(question).toBeVisible(); + await expectCompoundRadioStructure(question, ['MEAL', 'WALK'], 6); + await expectCompoundChoice(question, { + groupName: 'MEAL', + value: '1', + prompt: locale === 'es' ? 'preparar' : 'prepare', + answer: locale === 'es' ? 'Sin dificultad' : 'Without difficulty', + }); + await expectHealthyHarness(page); }); - const question = activeQuestion(page, qualityOfLifeQuestion); - await expect(question).toBeVisible(); - await expect(question.locator('input[type="radio"]')).toHaveCount(20); - expect(await question.locator('input[type="radio"]').evaluateAll((inputs) => ( - new Set(inputs.map((input) => input.name)).size - ))).toBe(4); - - test.fail(true, `${accessibilityDefects.compoundQuestionContext.localDefectId}: ${accessibilityDefects.compoundQuestionContext.reason}`); - const intendedChoices = [ - { groupName: 'D_559540891', value: '367964536', prompt: 'chores', answer: 'Without any difficulty' }, - { groupName: 'D_917425212', value: '367964536', prompt: 'stairs', answer: 'Without any difficulty' }, - { groupName: 'D_783201540', value: '367964536', prompt: 'walk', answer: 'Without any difficulty' }, - { groupName: 'D_780866928', value: '367964536', prompt: 'errands', answer: 'Without any difficulty' }, - ]; - for (const expected of intendedChoices) { - const contract = await compoundChoiceContract(question, expected); - expect.soft(contract.answerNamed, `${expected.groupName} must retain its response-option name`).toBe(true); - expect.soft(contract.promptContext, `${expected.groupName} must expose its subgroup prompt`).toBe(true); - } - }); + test(`manual conditional compound-radio fixture ${locale} updates group context through Back`, async ({ page }, testInfo) => { + const fixture = locale === 'es' + ? 'conditionalCompoundRadioGroupsSpanish.txt' + : 'conditionalCompoundRadioGroups.txt'; + await page.goto(`/tests/harness/participant.html?fixture=${fixture}`); + await page.waitForFunction(() => ( + document.querySelector('#questionnaireRoot form.question.active#TRAVEL_MODES') + )); + + const modes = activeQuestion(page, 'TRAVEL_MODES'); + await modes.locator('label[for="TRAVEL_MODES_1"]').click(); + await modes.locator('label[for="TRAVEL_MODES_2"]').click(); + await goNext(page); + + let duration = activeQuestion(page, 'TRAVEL_DURATION'); + const expectedPrompts = locale === 'es' + ? { CAR_DURATION: 'Automóvil', WALK_DURATION: 'A pie' } + : { CAR_DURATION: 'Car', WALK_DURATION: 'Walking' }; + await expectConditionalCompoundRadioStructure( + duration, + expectedPrompts, + ['CAR_DURATION', 'WALK_DURATION'], + 3, + ); + await expect(duration.locator('#CAR_DURATION_2')).toHaveAccessibleName(exactTextPattern( + locale === 'es' ? 'De 15 a 30 minutos' : '15 to 30 minutes', + )); + + if (locale === 'en' && conditionalKeyboardProjects.has(testInfo.project.name)) { + await waitInHarness(page, 550); + await duration.locator('#CAR_DURATION_1').focus(); + await page.keyboard.press('Space'); + await expect(duration.locator('#CAR_DURATION_1')).toBeFocused(); + await expect(duration.locator('#CAR_DURATION_1')).toBeChecked(); + await page.keyboard.press('ArrowDown'); + await expect(duration.locator('#CAR_DURATION_2')).toBeFocused(); + await expect(duration.locator('#CAR_DURATION_2')).toBeChecked(); + await expect(duration.locator('#CAR_DURATION_1')).not.toBeChecked(); + await page.keyboard.press('Tab'); + await expect(duration.locator('#WALK_DURATION_1')).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(duration.locator('#CAR_DURATION_2')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(duration.locator('#WALK_DURATION_1')).toBeFocused(); + } + + await goBack(page); + await activeQuestion(page, 'TRAVEL_MODES').locator('label[for="TRAVEL_MODES_1"]').click(); + await goNext(page); + duration = activeQuestion(page, 'TRAVEL_DURATION'); + await expectConditionalCompoundRadioStructure( + duration, + expectedPrompts, + ['WALK_DURATION'], + 3, + ); + await expectHealthyHarness(page); + }); + + test(`Module 4 conditional commute groups ${locale} expose only selected modes`, async ({ page }) => { + const markdown = readLockedMarkdown('module4', locale); + const expectedPrompts = authoredConditionalCompoundPrompts(markdown, commuteDuration); + expect(Object.keys(expectedPrompts).sort()).toEqual([...commuteDurationGroups].sort()); + + await openParticipant(page, { + markdown, + lang: locale, + persistedData: { + [commuteModes]: ['767755239', '385609081'], + treeJSON: treeAt(commuteDuration, commuteModes), + }, + }); + + const question = activeQuestion(page, commuteDuration); + await expect(question).toBeVisible(); + await expectConditionalCompoundRadioStructure( + question, + expectedPrompts, + [carDuration, taxiDuration], + ); + await expect(question.locator(`#${carDuration}_248303092`)).toHaveAccessibleName( + exactTextPattern(locale === 'es' ? 'de 16 a 30 minutos' : '16 to 30 minutes'), + ); + await expect(question.locator(`#${taxiDuration}_638092100`)).toHaveAccessibleName( + exactTextPattern(locale === 'es' ? '1 hora' : '1 hour'), + ); + await expectHealthyHarness(page); + }); + + test(`Diet Screener ${locale} exposes every food as a labelled native radio subgroup`, async ({ page }) => { + const markdown = readLockedMarkdown('moduleDietScreener', locale); + await openParticipant(page, { + markdown, + lang: locale, + persistedData: { treeJSON: treeAt(dietQuestion) }, + }); + + const question = activeQuestion(page, dietQuestion); + await expect(question).toBeVisible(); + await expectCompoundRadioStructure( + question, + dietGroupNames, + 157, + authoredCompoundPrompts(markdown, dietQuestion), + ); + + const intendedChoices = locale === 'es' + ? [ + { groupName: dietVegetables, value: '192247286', prompt: 'Verduras', answer: '1 por semana' }, + { groupName: dietFruit, value: '351464854', prompt: 'Fruta', answer: '1 por día' }, + ] + : [ + { groupName: dietVegetables, value: '192247286', prompt: 'Vegetables', answer: '1 per week' }, + { groupName: dietFruit, value: '351464854', prompt: 'Fruit', answer: '1 per day' }, + ]; + for (const expected of intendedChoices) { + await expectCompoundChoice(question, expected); + } + await expectHealthyHarness(page); + }); + + test(`QoL ${locale} exposes every activity as a labelled native radio subgroup`, async ({ page }) => { + const markdown = readLockedMarkdown('moduleQoL', locale); + await openParticipant(page, { + markdown, + lang: locale, + persistedData: { treeJSON: treeAt(qualityOfLifeQuestion) }, + }); + + const question = activeQuestion(page, qualityOfLifeQuestion); + await expect(question).toBeVisible(); + await expectCompoundRadioStructure( + question, + qualityOfLifeGroupNames, + 20, + authoredCompoundPrompts(markdown, qualityOfLifeQuestion), + ); + + const prompts = locale === 'es' + ? ['tareas', 'escaleras', 'caminar', 'mandados'] + : ['chores', 'stairs', 'walk', 'errands']; + const answer = locale === 'es' ? 'Sin dificultad' : 'Without any difficulty'; + const intendedChoices = [ + { groupName: 'D_559540891', value: '367964536', prompt: prompts[0], answer }, + { groupName: 'D_917425212', value: '367964536', prompt: prompts[1], answer }, + { groupName: 'D_783201540', value: '367964536', prompt: prompts[2], answer }, + { groupName: 'D_780866928', value: '367964536', prompt: prompts[3], answer }, + ]; + for (const expected of intendedChoices) { + await expectCompoundChoice(question, expected); + } + await expectHealthyHarness(page); + }); + } }); diff --git a/tests/e2e/productionMarkupFidelity.spec.js b/tests/e2e/productionMarkupFidelity.spec.js index a5df96a..fe035ad 100644 --- a/tests/e2e/productionMarkupFidelity.spec.js +++ b/tests/e2e/productionMarkupFidelity.spec.js @@ -4,8 +4,10 @@ import { test, expect } from './support/test.js'; import { activeQuestion, expectHealthyHarness, + flushHarness, goBack, goNext, + harnessSnapshot, openParticipant, } from './support/harness.js'; import { readLockedMarkdown, repositoryRoot, treeAt } from './support/corpus.js'; @@ -287,3 +289,131 @@ test.describe('locked Module 4 rich response markup fidelity @canonical @corpus' }); } }); + +test.describe('locked Module 1 conditional scalar semantics @canonical @corpus', () => { + test.beforeEach(async ({}, testInfo) => { + test.skip( + !DESKTOP_ENGINES.has(testInfo.project.name), + 'Conditional scalar semantics run in the desktop browser matrix.', + ); + }); + + for (const scalarCase of [ + { + language: 'en', + names: [ + 'a. 18 years old, Pounds (lbs)', + 'b. 25 years old, Pounds (lbs)', + 'c. 35 years old, Pounds (lbs)', + 'd. 45 years old, Pounds (lbs)', + 'e. 55 years old, Pounds (lbs)', + ], + }, + { + language: 'es', + names: [ + 'a. 18 años, NÚM. DE LIBRAS (lbs)', + 'b. 25 años, NÚM. DE LIBRAS (lbs)', + 'c. 35 años, NÚM. DE LIBRAS (lbs)', + 'd. 45 años, NÚM. DE LIBRAS (lbs)', + 'e. 55 años, NÚM. DE LIBRAS (lbs)', + ], + }, + ]) { + test(`gives every ${scalarCase.language} weight-history field distinct conditional context`, async ({ page }, testInfo) => { + await openParticipant(page, { + markdown: readLockedMarkdown('module1', scalarCase.language), + lang: scalarCase.language, + previousResults: { + ...profilePersona.previousResults, + ...profilePersona.userProfile, + age: '60', + }, + persistedData: { treeJSON: treeAt('D_912857732') }, + }); + + const question = activeQuestion(page, 'D_912857732'); + const weightInputs = question.locator('input[type="number"]:visible'); + await expect(weightInputs).toHaveCount(scalarCase.names.length); + await expect(question).not.toContainText('|displayif='); + for (const [index, accessibleName] of scalarCase.names.entries()) { + await expect(weightInputs.nth(index)).toHaveAccessibleName(accessibleName); + } + expect(await weightInputs.evaluateAll((inputs) => ( + new Set(inputs.map((input) => input.getAttribute('aria-label'))).size + ))).toBe(scalarCase.names.length); + + const firstWeight = question.locator('#D_821387277'); + await firstWeight.pressSequentially('18.5'); + await expect(firstWeight).toHaveValue('185'); + await firstWeight.fill(''); + + if (testInfo.project.name === 'chromium-desktop') { + const thirdWeight = question.locator('#D_950080618'); + const expectedWeights = { + D_821387277: '180', + D_950080618: '175', + }; + await firstWeight.pressSequentially('180'); + await firstWeight.blur(); + await thirdWeight.pressSequentially('175'); + await thirdWeight.blur(); + await flushHarness(page); + let snapshot = await harnessSnapshot(page); + expect(snapshot.state.active.D_912857732).toEqual(expectedWeights); + + await goNext(page); + await flushHarness(page); + snapshot = await harnessSnapshot(page); + expect(snapshot.state.survey.D_912857732).toEqual(expectedWeights); + const responseKey = 'D_726699695_V2.D_912857732'; + const storeCall = snapshot.logs.storeCalls.find(({ changes }) => ( + Object.hasOwn(changes, responseKey) + )); + expect(storeCall?.changes[responseKey]).toEqual(expectedWeights); + expect(Object.keys(storeCall.changes)).toEqual(expect.arrayContaining([ + responseKey, + 'D_726699695_V2.treeJSON', + ])); + + await goBack(page); + await expect(activeQuestion(page, 'D_912857732')).toBeVisible(); + await expect(firstWeight).toHaveValue('180'); + await expect(thirdWeight).toHaveValue('175'); + } + + await expectHealthyHarness(page); + }); + } + + test('keeps inapplicable Spanish weight fields hidden while the visible fields accept whole numbers', async ({ page }, testInfo) => { + test.skip( + testInfo.project.name !== 'chromium-desktop', + 'One real-browser condition-boundary check is sufficient. The full naming matrix runs in every desktop engine.', + ); + + await openParticipant(page, { + markdown: readLockedMarkdown('module1', 'es'), + lang: 'es', + previousResults: { + ...profilePersona.previousResults, + ...profilePersona.userProfile, + age: '30', + }, + persistedData: { treeJSON: treeAt('D_912857732') }, + }); + + const question = activeQuestion(page, 'D_912857732'); + await expect(question.locator('input[type="number"]:visible')).toHaveCount(2); + await expect(question.locator('#D_821387277')).toBeVisible(); + await expect(question.locator('#D_121646540')).toBeVisible(); + await expect(question.locator('#D_950080618')).toBeHidden(); + await expect(question.locator('#D_407167089')).toBeHidden(); + await expect(question.locator('#D_503154158')).toBeHidden(); + + const firstWeight = question.locator('#D_821387277'); + await firstWeight.pressSequentially('18.5'); + await expect(firstWeight).toHaveValue('185'); + await expectHealthyHarness(page); + }); +}); diff --git a/tests/e2e/responsive.spec.js b/tests/e2e/responsive.spec.js index e305380..d7e8ec1 100644 --- a/tests/e2e/responsive.spec.js +++ b/tests/e2e/responsive.spec.js @@ -85,6 +85,10 @@ test.describe('participant responsive layout @responsive @canonical', () => { const table = question.locator('table.quest-grid'); await expect(table).toBeVisible(); await expect(table).toHaveCSS('margin-top', '10px'); + const cornerSpacer = table.locator('thead tr > :first-child'); + await expect(cornerSpacer).toHaveClass(/grid-corner-spacer/); + expect(await cornerSpacer.evaluate((element) => element.tagName)).toBe('TD'); + await expect(table.locator('thead th:not([scope="col"])')).toHaveCount(0); if (testInfo.project.name === 'chromium-phone') { await expect(table.locator('thead')).toHaveCSS('display', 'none'); @@ -129,7 +133,12 @@ test.describe('participant responsive layout @responsive @canonical', () => { await goNext(page); await page.mouse.move(0, 0); - const row = activeQuestion(page, 'GRID_RATE').locator('tr[data-question-id="GRID_WALK"]'); + const question = activeQuestion(page, 'GRID_RATE'); + const row = question.locator('tr[data-question-id="GRID_WALK"]'); + await expect(question.getByRole('radio', { + name: 'Walking Sometimes', + exact: true, + })).toHaveCount(1); const expectedLabels = ['Never', 'Sometimes', 'Often']; for (const [index, text] of expectedLabels.entries()) { @@ -203,6 +212,10 @@ test.describe('participant responsive layout @responsive @canonical', () => { await waitInHarness(page, 550); const question = activeQuestion(page, 'GRID_CHECK'); + await expect(question.getByRole('checkbox', { + name: 'First need Phone', + exact: true, + })).toHaveCount(1); const firstRow = question.locator('tr[data-question-id="GRID_CHECK_ROW_A"]'); const firstCell = firstRow.locator('td.response').first(); const firstCheckbox = firstCell.locator('input[type="checkbox"]'); diff --git a/tests/e2e/support/authoring.js b/tests/e2e/support/authoring.js index 8c888a9..110ed7f 100644 --- a/tests/e2e/support/authoring.js +++ b/tests/e2e/support/authoring.js @@ -90,16 +90,22 @@ export const GITHUB_BLOB_MODULE_URL = 'https://github.com/episphere/questionnair export const GITHUB_WEB_RAW_MODULE_URL = 'https://github.com/episphere/questionnaire/raw/locked/prod/canonical.txt'; export const GITHUB_RAW_MODULE_URL = 'https://raw.githubusercontent.com/episphere/questionnaire/locked/prod/canonical.txt'; -const LOCALFORAGE_FALLBACK_SCRIPT = ` - window.localforage = { - createInstance() { - throw new Error('Synthetic LocalForage initialization failure'); - }, - clear() { - return Promise.resolve(); - } - }; -`; +function localForageFallbackScript(mode) { + const clearImplementation = mode === 'initialization-and-clear-failure' + ? "return Promise.reject(new Error('Synthetic global LocalForage clear failure'));" + : 'return Promise.resolve();'; + + return ` + window.localforage = { + createInstance() { + throw new Error('Synthetic LocalForage initialization failure'); + }, + clear() { + ${clearImplementation} + } + }; + `; +} function normalizeModuleResponse(response) { if (typeof response === 'string') { @@ -161,11 +167,14 @@ export async function installAuthoringRoutes(page, diagnostics, { await page.route(asset.url, async (route) => { diagnostics.fulfilledExternalRequests.push(asset.url); - if (asset.url.includes('localforage') && localForageMode === 'initialization-failure') { + if (asset.url.includes('localforage') && [ + 'initialization-failure', + 'initialization-and-clear-failure', + ].includes(localForageMode)) { await route.fulfill({ status: 200, contentType: 'application/javascript', - body: LOCALFORAGE_FALLBACK_SCRIPT, + body: localForageFallbackScript(localForageMode), }); return; } diff --git a/tests/e2e/visual.spec.js b/tests/e2e/visual.spec.js index e015318..419c456 100644 --- a/tests/e2e/visual.spec.js +++ b/tests/e2e/visual.spec.js @@ -12,6 +12,7 @@ import { renderAuthoringMarkdown, waitForAuthoringReady, } from './support/authoring.js'; +import { readLockedMarkdown, treeAt } from './support/corpus.js'; async function stabilizeVisual(page, testInfo) { testInfo.snapshotSuffix = ''; @@ -125,6 +126,91 @@ test.describe('stable participant styling @visual', () => { ); }); + test('keeps the compound-radio subgroup layout stable', async ({ page }, testInfo) => { + await openParticipant(page, { + markdown: readLockedMarkdown('moduleQoL'), + persistedData: { treeJSON: treeAt('D_284353934') }, + }); + const question = activeQuestion(page, 'D_284353934'); + await question.locator('label[for="D_559540891_367964536"]').click(); + await expect(question.locator('#D_559540891_367964536')).toBeChecked(); + await stabilizeVisual(page, testInfo); + + await expect(question).toHaveScreenshot( + 'participant-compound-radio-layout.png', + screenshotOptions, + ); + + const wrapperLayoutDelta = await question.evaluate((form) => { + const capture = () => ({ + formHeight: form.getBoundingClientRect().height, + responses: Array.from(form.querySelectorAll('.response'), (response) => { + const rect = response.getBoundingClientRect(); + return { id: response.querySelector('input')?.id, top: rect.top, height: rect.height }; + }), + }); + const withGroups = capture(); + form.querySelectorAll('.compound-radio-group').forEach((group) => { + group.replaceWith(...group.children); + }); + const withoutGroups = capture(); + const responseDelta = Math.max(0, ...withGroups.responses.map((response, index) => ( + Math.max( + Math.abs(response.top - withoutGroups.responses[index].top), + Math.abs(response.height - withoutGroups.responses[index].height), + ) + ))); + return Math.max(responseDelta, Math.abs(withGroups.formHeight - withoutGroups.formHeight)); + }); + expect(wrapperLayoutDelta).toBeLessThanOrEqual(0.5); + }); + + test('keeps conditional compound-radio prompts and responses visually unchanged', async ({ page }, testInfo) => { + await openParticipant(page, { + markdown: readLockedMarkdown('module4'), + persistedData: { + D_421586693: ['767755239', '385609081'], + treeJSON: treeAt('D_733638576', 'D_421586693'), + }, + }); + const question = activeQuestion(page, 'D_733638576'); + await question.locator('label[for="D_583216333_248303092"]').click(); + await expect(question.locator('#D_583216333_248303092')).toBeChecked(); + await stabilizeVisual(page, testInfo); + + await expect(question).toHaveScreenshot( + 'participant-conditional-compound-radio-layout.png', + screenshotOptions, + ); + + const semanticLayoutDelta = await question.evaluate((form) => { + const capture = () => ({ + formHeight: form.getBoundingClientRect().height, + elements: Array.from(form.querySelectorAll('.displayif, .response'), (element) => { + const rect = element.getBoundingClientRect(); + return { top: rect.top, left: rect.left, width: rect.width, height: rect.height }; + }), + }); + const withSemantics = capture(); + form.querySelectorAll('[role="radiogroup"][aria-owns]').forEach((group) => { + group.removeAttribute('role'); + group.removeAttribute('aria-label'); + group.removeAttribute('aria-owns'); + }); + const withoutSemantics = capture(); + return Math.max( + Math.abs(withSemantics.formHeight - withoutSemantics.formHeight), + ...withSemantics.elements.map((element, index) => Math.max( + Math.abs(element.top - withoutSemantics.elements[index].top), + Math.abs(element.left - withoutSemantics.elements[index].left), + Math.abs(element.width - withoutSemantics.elements[index].width), + Math.abs(element.height - withoutSemantics.elements[index].height), + )), + ); + }); + expect(semanticLayoutDelta).toBeLessThanOrEqual(0.5); + }); + test('keeps the authoring workspace layout stable', async ({ page, diagnostics }, testInfo) => { await installAuthoringRoutes(page, diagnostics); await page.goto('/index.html'); diff --git a/tests/e2e/visual.spec.js-snapshots/participant-compound-radio-layout-chromium-desktop.png b/tests/e2e/visual.spec.js-snapshots/participant-compound-radio-layout-chromium-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..3ee0f7e1bf107ac3a10b07b8b02d4d7c6af34a0e GIT binary patch literal 10501 zcmeHNX;4#F7`+h@Dk@^NGA@M~9IaAJ{ZX;9L>v^TVr*ULu=uRD+JY<=h+rfy18q^; zis($WT0o^}T?lmvC@~2X%VNZc79t@bV3Y_UykN+l_g-&o{o5HG1$5rO%*$Ku{m%W) zch9-szO`#2CXSym9sn>gDl+_209@_>;M(Cn244BBqjn_#lR;E?*!rwr`;|&waC^3F zkbf?Q$xN(ovbY{zQh4a1?_#&G$TaWpC7L;b-0-Wklw=FstW6{>bq5#2(VoDmj%o&+O{JUfG!^oWlM z9ZGueVdA*i+B{JkCL;0{T_K0i6jB=@Im)>-L=K8&3{p~}lx=$YfitaAbepXMNyLh^ zS!Z!XWKj2NRrV&e7#}R*p{Bg(WEW6_v^Vy8wfdF&kPe%f6O1<%6z4~Y$kK8+0!EeC z^7xlF)YNj9QyqeQojNd=2DpCjOQ$OymD;6>x>1wH(O6MQb54pY02Zt8w9$^OzE>ea zISzF?Rs^X}pKe#!rB?{PS$a=J-gQ_SVmPIE-*#qa`qL1UwIcTZ-t%JOLLaIxGs|N_ z$$``y{p~6=3pK~X-v_Fc8c3K91;=i{h;nJdrb%fe5yP-|yJ1rS=<@a9q*>>QasREq z3(XCE;v91WLUuBVAv*R9aQm&Gdllr9AO!zYAU6Dk?A}43kmx{uy64N`z!RpW0fNi~ zHU^`RC5TN*(p_h#*aldFpZ;D5K#kW*medezvpp4S#i$P?{dRhHdfGjDNbh0gkirPRi62qDch`Q zOah>BCxyXt(kz9+$nRDt3@8k!&GV>{3{(N83h?l0+LulKcUR>0Kl1{p4bk(P5ic7L z3WGNrgFT!<#gJ0Rnt3(>>Mg$?`-?T%LuN_gA+7OTB@J{f^ZTP=m2S}$J1$C&b1Iq- zkFjsC;(jiJN{!}MdAx28%+^cEasJR%TZt({osb;UPH)E+&J34jR zwfO&ZTA~2z=vjTA$mu@Ow+$Dr{X5f$Z3QPyoj&Kktm0|46EiC;01LBMM5Lr$UtF0VemhFHLo8pQCvj1LNDqH zCAx-2i%dog*OC@pDo0~$VamqTAQfi1PJ$YQ42#f{?2ZwwJ)myCJ?~Bfsv~3rd-gk8 z0!1X9Xhs(T$0=Nb!X6p(i5}Aq&D$vs`Rw`K>mDL_e(E$3N+a}1`5^~GI;`K$6tilZ z&ol;#**O!43od$(LEA1PX%)tOw0C<@$!Z^xA5;hbk6>gM>(0A!5pHaWXBTN#m(99y zxrlkXfRqcnM|ty`Vg3_;aRz6C5a>PhHVeKv^hjP-rJ-qP5D|3stb15R)H}QrXL*~A;3{uS VMbqq)`{5N3wPH;;Z~5lje*lQL=C%L; literal 0 HcmV?d00001 diff --git a/tests/e2e/visual.spec.js-snapshots/participant-conditional-compound-radio-layout-chromium-desktop.png b/tests/e2e/visual.spec.js-snapshots/participant-conditional-compound-radio-layout-chromium-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..6403255fdf8e36ee869ac53eb73a30e7ca1cdd2d GIT binary patch literal 6250 zcmeHLTU1kL7XA@hQ5Vvdsi=j-nRd`l5v-ypB;c(@#^F*>kst=E3_`dm62c`&bt>c1 z0xc~l3TROQnGi09fPsV>CE5&)NQf8+kp=}JArpaw5JF}j$@9E4YmHsYhdlgQ=RfD$ z``edq@3X;yes+sjE=CC1`S02pf{^t?_$AmbfPZusZ2o}IJIH_Mwmr1V%6_ssJ}gq8 zVQH0HLa7mJg5O=d==(KmU6wnv-L2BOoLL?9qmLDnZaZ+1_Q_d|WF&NJ7Mk1bde>&% zDciS5!i%xqjcLVW?3kC0Y~S0yFZ_QQ`{DADPF;VkvQ^|mVb-(uA(V6eDt!3UQy(C7 z%El8Rn?;ET*}m5gFg|LwLaTNqTcfQ7Pv1s4tA4UWXxp0y-?-;ZQ2Y*4;Tv6zFs^J^ zLb5^#S1a+#Q)H)Jy4CaI#AtOSl!3LA%CZ~PI-Ts>?-ZJP{h4No+< zzJ*Zlo}_VHpW@l^j}h9sHDA?r=sie<@S1FEG2(_OUlQQGf zVeGT5Q2U&87Ak`))nTTY3O3K!oyIju^csB|)O|vKO8yxUeX^^@-Dl#5Ao`G3HG_4q z_oRv8$<;k?f%+Ul(|^r>%$>c{Xc!#d^*}yn5Q#S-6hFdA;u_MJZC#EEU!&*+Z{YT1 zk9V;aZz7r|ht;LSSb@*Y^pFJk_|A^*R%|dx$#EuO{s8V^rm=KPPB$!^u$S-0UNw6s zBT!d09!odsrDI9<@{?oK3qz}u)`G95+q1RPMH1hvtQGReMCL8Wv_kODGB>tk|1*uA zXerlNpy%)#4G}470k||PSr=>UuQ)V4nPM2r4nK9)s(mGol0t(R8IfAx?F!}Nj@;3zeqPuiF9n40ef>4d1hz> z@8wy(%xaw3Hlryf)AoRyziFMlGpOTD)^n>Etg#V!_N;U0q3qq;GoI zm|1dTK%VCJ*E~Um6uxOU)jsadGJHuW7YQ3cctnM^wPeQU7YnaL$XOAa{Br#vd5(k^ z)x&uOW8-VY^bQ7V9%HU7F$}z)gX`lDCa*DP{Q*e&sL;ocxQ3(0OI$jE-Va8Nc{><>g9*j`#B|BX5qjk|Xy`5ZDqZH~&dyNsc+`YWl7* z0xLsS3HO<@c@Y*U$u}b zQqGsDhLT}@HryucVz4T;pa||$5W0WN{>>!tyPSWnXtGlwvHD4`8cq*MaIBx1SlHxM z8c1XgDD7zg_G4XM}N@v3pfiGdE64bIsgz?8OXAU8~~cyHv(L zf@z`dDz2Be`3P+L13w&qQlSwoTpI0rN};v;w(4@ z9Y;EP!fQ(G23=}d1~sT#Y%qYf*3kC!%|kO@r;^VPPo#$zfd%n5J=5}4n&T~?ldySW zWF3J0hpTY?GB{VTo?9w(e*1pqXcY^?1Q_f3ROXo3?6lO($N3t}m{F264IdELeXV7F z(98V0?z03&q+f9lL8NnlmbopCL1ck&*?ADwKY5uJ+629sLCt*pzh_X4X9f!*fpCD4 z>Lh>y7+cf*LLAD0^oSUFLJSC1t2rP3S)lkL!8{%pOX;7MiH4)Y!GgN#fqr6>K==&P zNuazd_#Z*qH@FJ-!}+|V#8Tn%+gFiSu1ZMr82O28=!w|gVla8LIfvKPzA}aqE2n#G z2`GGS9*Hi>99b-?Gp6F@L2!FvHA^cVU?w`GgIeG68p-efy|2AeZ_;;An5HRDo~{({ zjOTef($*8wmh+~CqB)J4JKM)>-QRSWZJqT`LSK4D>q++GB(8R*e6G{ikrraSho9>R z^UQEFDcIRxvaJb=u!kmPK3PUQH{$6cZJ4|_{+v{Wf^0?9G8~<4FV|}{GZpXCPTivR zeP`0oRL9>(JC*-v#uQ z!Nd3` { expect(fieldset.querySelectorAll('br').length).toBeLessThanOrEqual(3); }); + it.each([ + ['textarea', '', '#DETAILS'], + ['select', '', '#STATE'], + ])('keeps a standalone %s after the question focus boundary', async (_, controlMarkup, selector) => { + const { quest, accessibility } = await loadAccessibilityFixture(` +
+
Describe the response.${controlMarkup}
+ + `); + const fieldset = quest.root.querySelector('fieldset'); + + accessibility.manageAccessibleQuestion(fieldset, false); + + const legend = fieldset.querySelector(':scope > legend'); + const focusTarget = fieldset.querySelector(':scope > .screen-reader-focus'); + const control = fieldset.querySelector(selector); + expect(legend.contains(control)).toBe(false); + expect(focusTarget.compareDocumentPosition(control) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + it('turns subsequent prompts in a multi-question fieldset into focusable alerts', async () => { const { quest, accessibility } = await loadAccessibilityFixture(`
@@ -47,6 +67,152 @@ describe('accessible question text construction', () => { expect(fieldset.querySelectorAll('.response')).toHaveLength(2); }); + it('associates each compound radio subgroup with its visible prompt without changing answer labels', async () => { + const { quest, accessibility } = await loadAccessibilityFixture(` + +
+ For each activity, choose one answer.
How difficult are household chores? +
+
+ How difficult is climbing stairs?
+
+
+
+ + `); + const fieldset = quest.root.querySelector('fieldset'); + + accessibility.manageAccessibleQuestion(fieldset, false); + accessibility.manageAccessibleQuestion(fieldset, false); + + const groups = [...fieldset.querySelectorAll(':scope > [role="radiogroup"]')]; + expect(groups).toHaveLength(2); + expect(groups.map((group) => [...new Set( + [...group.querySelectorAll('input[type="radio"]')].map(({ name }) => name), + )])).toEqual([['CHORES'], ['STAIRS']]); + + const [choresGroup, stairsGroup] = groups; + const choresPrompt = document.getElementById(choresGroup.getAttribute('aria-labelledby')); + const stairsPrompt = document.getElementById(stairsGroup.getAttribute('aria-labelledby')); + expect(choresPrompt).toBe(fieldset.querySelector(':scope > legend')); + expect(choresPrompt.textContent).toContain('How difficult are household chores?'); + expect(stairsPrompt.textContent).toContain('How difficult is climbing stairs?'); + expect(stairsPrompt.hasAttribute('role')).toBe(false); + expect(stairsPrompt.hasAttribute('tabindex')).toBe(false); + + expect(fieldset.querySelector('#CHORES_1').labels[0].textContent).toBe('No difficulty'); + expect(fieldset.querySelector('#STAIRS_2').labels[0].textContent).toBe('Some difficulty'); + expect(fieldset.querySelectorAll('[id="COMPOUND_RADIOS-compound-radio-CHORES-label"]')).toHaveLength(1); + expect(fieldset.querySelectorAll('[id="COMPOUND_RADIOS-compound-radio-STAIRS-label"]')).toHaveLength(1); + }); + + it('leaves ordinary single-group radio questions structurally unchanged', async () => { + const { quest, accessibility } = await loadAccessibilityFixture(` +
+
Choose one answer. +
+
+
+ + `); + const fieldset = quest.root.querySelector('fieldset'); + + accessibility.manageAccessibleQuestion(fieldset, false); + + expect(fieldset.querySelectorAll('[role="radiogroup"]')).toHaveLength(0); + expect(fieldset.querySelectorAll(':scope > .response')).toHaveLength(2); + }); + + it('labels conditional compound radio subgroups without reparenting responses', async () => { + const { quest, accessibility } = await loadAccessibilityFixture(` +
+
How long does each visible trip take?
+ Car duration?
+
+
+
+ + +
+ + `); + const fieldset = quest.root.querySelector('fieldset'); + + accessibility.manageAccessibleQuestion(fieldset, false); + const initialCarPrompt = fieldset.querySelector('[role="radiogroup"]'); + expect(initialCarPrompt.getAttribute('aria-label')).toBe('Car duration?'); + initialCarPrompt.textContent = 'Updated car duration?'; + accessibility.manageAccessibleQuestion(fieldset, false); + + const groups = [...fieldset.querySelectorAll('[role="radiogroup"]')]; + expect(groups).toHaveLength(2); + expect(fieldset.querySelectorAll(':scope > .response')).toHaveLength(4); + expect(fieldset.querySelectorAll(':scope > .compound-radio-group')).toHaveLength(0); + + const [carPrompt, walkingPrompt] = groups; + expect(carPrompt.textContent).toBe('Updated car duration?'); + expect(walkingPrompt.textContent).toBe('Walking duration?'); + expect(carPrompt.getAttribute('aria-label')).toBe('Updated car duration?'); + expect(walkingPrompt.getAttribute('aria-label')).toBe('Walking duration?'); + expect(carPrompt.getAttribute('aria-owns')).toBe('CAR_1 CAR_2'); + expect(walkingPrompt.getAttribute('aria-owns')).toBe('WALK_1 WALK_2'); + expect(carPrompt.hasAttribute('tabindex')).toBe(false); + expect(walkingPrompt.hasAttribute('tabindex')).toBe(false); + + expect(fieldset.querySelector('#CAR_1').closest('.response').parentElement).toBe(fieldset); + expect(fieldset.querySelector('#WALK_1').closest('.response').parentElement).toBe(fieldset); + + for (const radio of fieldset.querySelectorAll('input[type="radio"]')) { + expect(radio.hasAttribute('aria-label')).toBe(false); + expect(radio.hasAttribute('aria-labelledby')).toBe(false); + expect(radio.labels).toHaveLength(1); + } + }); + + it('does not partially annotate an ambiguous conditional compound form', async () => { + const { quest, accessibility } = await loadAccessibilityFixture(` +
+
How long does each visible trip take?
+ Car duration?
+
+
+
+ + +
+ + `); + const fieldset = quest.root.querySelector('fieldset'); + + accessibility.manageAccessibleQuestion(fieldset, false); + + expect(fieldset.querySelectorAll('[role="radiogroup"]')).toHaveLength(0); + expect(fieldset.querySelectorAll('[aria-owns]')).toHaveLength(0); + expect(fieldset.querySelectorAll(':scope > .response')).toHaveLength(4); + }); + + it('does not publish conditional ownership references for duplicate radio IDs', async () => { + const { quest, accessibility } = await loadAccessibilityFixture(` +
+
How long does each visible trip take?
+ Car duration?
+
+
+
+ + +
+ + `); + const fieldset = quest.root.querySelector('fieldset'); + + accessibility.manageAccessibleQuestion(fieldset, false); + + expect(fieldset.querySelectorAll('[role="radiogroup"]')).toHaveLength(0); + expect(fieldset.querySelectorAll('[aria-owns]')).toHaveLength(0); + expect(fieldset.querySelectorAll(':scope > .response')).toHaveLength(4); + }); + it('retains the first formatted fragment after a punctuated primary prompt', async () => { const { quest, accessibility } = await loadAccessibilityFixture(`
@@ -161,6 +327,57 @@ describe('accessible selection announcements and defensive paths', () => { expect(document.activeElement).toBe(input); }); + it.each([ + { + description: 'list', + delay: 100, + markup: ` +
+
+ `, + update: (accessibility, response) => accessibility.updateAriaLiveSelectionAnnouncer(response), + }, + { + description: 'table', + delay: 250, + markup: ` +
+
${header_text} - +
+ `, + update: (accessibility, response) => accessibility.updateAriaLiveSelectionAnnouncerTable(response), + }, + ])('cancels a pending $description announcement when the lifecycle is cleared', async ({ delay, markup, update }) => { + vi.useFakeTimers(); + const { quest, accessibility } = await loadAccessibilityFixture(markup); + const liveRegion = quest.root.querySelector('#ariaLiveSelectionAnnouncer'); + + update(accessibility, quest.root.querySelector('.response')); + accessibility.clearSelectionAnnouncement(); + await vi.advanceTimersByTimeAsync(delay); + + expect(liveRegion.textContent).toBe(''); + }); + + it('publishes only the latest response during rapid successive selections', async () => { + vi.useFakeTimers(); + const { quest, accessibility } = await loadAccessibilityFixture(` +
+
+
+ `); + const liveRegion = quest.root.querySelector('#ariaLiveSelectionAnnouncer'); + + accessibility.updateAriaLiveSelectionAnnouncer(quest.root.querySelector('#FIRST_RESPONSE')); + await vi.advanceTimersByTimeAsync(50); + accessibility.updateAriaLiveSelectionAnnouncer(quest.root.querySelector('#SECOND_RESPONSE')); + + await vi.advanceTimersByTimeAsync(50); + expect(liveRegion.textContent).toBe(''); + + await vi.advanceTimersByTimeAsync(50); + expect(liveRegion.textContent).toBe('Second Selected.'); + }); + it('returns safely when announcer dependencies are absent and clears an existing region', async () => { const { quest, accessibility } = await loadAccessibilityFixture('
'); const response = quest.root.querySelector('.response'); diff --git a/tests/integration/covidDurationNavigation.spec.js b/tests/integration/covidDurationNavigation.spec.js new file mode 100644 index 0000000..ad9a834 --- /dev/null +++ b/tests/integration/covidDurationNavigation.spec.js @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { readLockedMarkdown, treeAtPath } from '../e2e/support/corpus.js'; +import { renderFreshQuest } from '../helpers/questRuntime.js'; + +const durationGridId = 'D_114280729'; +const otherSymptomsId = 'D_110872086'; +const noResponse = '104430631'; +const symptomRows = { + D_847578001: [ + 'D_488415137', + 'D_167695804', + 'D_730334054', + 'D_215996690', + 'D_462737492', + 'D_469675296', + ], + D_136730307: [ + 'D_962475128', + 'D_989576239', + 'D_338613869', + 'D_126794793', + 'D_218793117', + ], + D_751358419: [ + 'D_524096053', + 'D_814101706', + 'D_635026188', + 'D_238135048', + 'D_632714520', + ], +}; +const symptomResponseIds = Object.values(symptomRows) + .flat() + .flatMap((id) => [`${id}_0`, `${id}_1`]); +const correctedDurationCondition = `someSelected("${symptomResponseIds.join('","')}")`; + +function withCorrectedDurationCondition(markdown) { + const durationHeader = /(\|grid\?\|id="D_114280729"\s+displayif=)[^|]+/; + if (!durationHeader.test(markdown)) { + throw new Error('Locked COVID Markdown is missing the D_114280729 grid header'); + } + return markdown.replace(durationHeader, `$1${correctedDurationCondition}`); +} + +function symptomState(selectedSymptomId = null, selectedSymptomValue = '724612102') { + return Object.fromEntries(Object.entries(symptomRows).map(([gridId, rowIds]) => [ + gridId, + Object.fromEntries(rowIds.map((rowId) => [ + rowId, + rowId === selectedSymptomId ? selectedSymptomValue : '244354126', + ])), + ])); +} + +async function resumeAtOtherSymptoms( + markdown, + locale, + selectedSymptomId, + selectedSymptomValue, +) { + return renderFreshQuest({ + markdown, + persistedData: { + D_860011428: '1', + D_694503437_1_1: '353358909', + ...symptomState(selectedSymptomId, selectedSymptomValue), + treeJSON: treeAtPath([otherSymptomsId]), + }, + params: { lang: locale }, + }); +} + +async function selectNoOtherSymptomsAndAdvance(quest) { + const otherSymptoms = quest.root.querySelector(`form.question.active#${otherSymptomsId}`); + expect(otherSymptoms).not.toBeNull(); + otherSymptoms.querySelector(`#${otherSymptomsId}_${noResponse}`).click(); + otherSymptoms.querySelector('button.next').click(); +} + +describe('locked production COVID symptom-duration navigation', () => { + const variants = ['en', 'es'].flatMap((locale) => { + const locked = readLockedMarkdown('moduleCOVID19', locale); + return [ + [locale, 'locked source', locked], + [locale, 'corrected condition', withCorrectedDurationCondition(locked)], + ]; + }); + + it.each(variants)( + '%s %s reaches D_114280729 and exposes only the selected symptom row', + async (locale, _sourceLabel, markdown) => { + const quest = await resumeAtOtherSymptoms( + markdown, + locale, + 'D_524096053', + '178780048', + ); + + await selectNoOtherSymptomsAndAdvance(quest); + await vi.waitFor(() => expect( + quest.root.querySelector('form.question.active')?.id, + ).toBe(durationGridId)); + + const grid = quest.root.querySelector(`#${durationGridId}`); + const exposedRowIds = Array.from(grid.querySelectorAll('tr[data-gridrow="true"]')) + .filter((row) => row.style.display !== 'none' && row.dataset.hidden !== 'true') + .map((row) => row.dataset.questionId); + + expect(grid.dataset.grid).toBe('true'); + expect(exposedRowIds).toEqual(['D_336856410']); + expect(grid.querySelectorAll('tr[data-gridrow="true"][data-hidden="true"]')).toHaveLength(15); + expect(quest.errors).toEqual([]); + }, + ); + + it.each(['en', 'es'])('%s all-no symptoms bypass D_114280729', async (locale) => { + const quest = await resumeAtOtherSymptoms( + readLockedMarkdown('moduleCOVID19', locale), + locale, + null, + ); + + await selectNoOtherSymptomsAndAdvance(quest); + await vi.waitFor(() => expect( + quest.root.querySelector('form.question.active')?.id, + ).toBe('COV20_SKIP')); + expect(quest.root.querySelector(`#${durationGridId}`)).toBeNull(); + + quest.root.querySelector('#COV20_SKIP button.next').click(); + await vi.waitFor(() => expect( + quest.root.querySelector('form.question.active')?.id, + ).toBe('COV20A17_SKIP')); + expect(quest.root.querySelector(`#${durationGridId}`)).toBeNull(); + expect(quest.errors).toEqual([]); + }); +}); diff --git a/tests/integration/eventHandlers.spec.js b/tests/integration/eventHandlers.spec.js index 89d3ddb..4baff99 100644 --- a/tests/integration/eventHandlers.spec.js +++ b/tests/integration/eventHandlers.spec.js @@ -44,6 +44,14 @@ const POPOVER_SURVEY = ` [END,end] Done. `; +const COMPOUND_DELETION_SURVEY = ` +{"name":"EVENT_COMPOUND_DELETE"} +[Q1?] Select a response or add detail. +[1:CHOICE] First response +|__|id=DETAIL| +[END,end] Done. +`; + describe('delegated runtime event handling', () => { afterEach(() => { vi.clearAllTimers(); @@ -117,6 +125,27 @@ describe('delegated runtime event handling', () => { expect(checkbox.checked).toBe(false); }); + it('persists a question-level deletion after the final compound response is unchecked', async () => { + const quest = await renderFreshQuest({ markdown: COMPOUND_DELETION_SURVEY }); + const checkbox = quest.root.querySelector('#CHOICE_1'); + + checkbox.click(); + expect(quest.state.getActiveQuestionState()).toEqual({ + Q1: { CHOICE: ['1'] }, + }); + + checkbox.click(); + const activeState = quest.state.getActiveQuestionState(); + expect(Object.prototype.hasOwnProperty.call(activeState, 'Q1')).toBe(true); + expect(activeState.Q1).toBeUndefined(); + + quest.state.syncToStore(quest.root.querySelector('#Q1 .next')); + await vi.waitFor(() => expect(quest.store).toHaveBeenCalledOnce()); + const payload = quest.store.mock.calls[0][0]; + expect(payload).toHaveProperty('EVENT_COMPOUND_DELETE.Q1', undefined); + expect(payload).toHaveProperty('EVENT_COMPOUND_DELETE.treeJSON', expect.any(String)); + }); + it('formats SSN and telephone keystrokes through delegated keyup listeners', async () => { const quest = await renderFreshQuest({ markdown: TEXT_SURVEY }); const form = quest.root.querySelector('#TEXT'); @@ -162,15 +191,46 @@ describe('delegated runtime event handling', () => { it('removes a standalone textarea response when its form is reset programmatically', async () => { const quest = await renderFreshQuest({ markdown: NATIVE_ARROW_SURVEY }); const textarea = quest.root.querySelector('#notes'); + const resetButton = textarea.form.querySelector('[data-click-type="reset"]'); + expect(resetButton).not.toBeNull(); textarea.value = 'Remove this response'; textarea.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); const { resetChildren } = await import('../../eventHandlers.js'); expect(() => resetChildren(textarea.form)).not.toThrow(); + expect(textarea.value).toBe(''); expect(quest.state.getActiveQuestionState().NOTES).toBeUndefined(); }); + it('clears a choice-linked textarea together with its owning response', async () => { + vi.useFakeTimers(); + const quest = await renderFreshQuest({ markdown: CHOICE_LINKED_TEXT_SURVEY }); + const textarea = quest.root.querySelector('#OTHER_TEXT'); + const choice = quest.root.querySelector('#OTHER_GROUP_1'); + textarea.value = 'Remove this linked response'; + textarea.dispatchEvent(new InputEvent('input', { + bubbles: true, + data: 'e', + inputType: 'insertText', + })); + await vi.advanceTimersByTimeAsync(250); + expect(choice.checked).toBe(true); + expect(textarea.dataset.lastValue).toBe('Remove this linked response'); + + const { resetChildren } = await import('../../eventHandlers.js'); + resetChildren(textarea.form); + + expect(choice.checked).toBe(false); + expect(textarea.value).toBe(''); + expect(textarea.dataset).not.toHaveProperty('lastValue'); + expect(quest.state.getActiveQuestionState().OTHER).toBeUndefined(); + + choice.click(); + expect(choice.checked).toBe(true); + expect(textarea.value).toBe(''); + }); + it('does not cancel native select navigation, activation, or dismissal keys (CONNECT-1587)', async () => { const quest = await renderFreshQuest({ markdown: NATIVE_SELECT_SURVEY }); const select = quest.root.querySelector('#home_state'); diff --git a/tests/integration/gridDeepCoverage.spec.js b/tests/integration/gridDeepCoverage.spec.js index db22cb0..a04a5a5 100644 --- a/tests/integration/gridDeepCoverage.spec.js +++ b/tests/integration/gridDeepCoverage.spec.js @@ -140,7 +140,10 @@ describe('deep grid state coverage', () => { expect(phone.checked).toBe(false); expect(email.checked).toBe(true); expect(quest.state.getCache()['ROW_PHONE.GRID_CHECKBOX_STATE']).toBeUndefined(); - expect(quest.state.getResponseToQuestionMapping()['ROW_PHONE.GRID_CHECKBOX_STATE']).toBeUndefined(); + // Condition lookups without a question ID must not fall back to a restored value that the participant has cleared. + expect(quest.state.getResponseToQuestionMapping()['ROW_PHONE.GRID_CHECKBOX_STATE']) + .toBe('GRID_CHECKBOX_STATE.ROW_PHONE'); + expect(quest.state.findResponseValue('ROW_PHONE')).toBeUndefined(); grid.querySelector('button.reset').click(); expect(grid.querySelectorAll('input[type="checkbox"]:checked')).toHaveLength(0); @@ -161,13 +164,22 @@ describe('deep grid state coverage', () => { const visibleRow = grid.querySelector('[data-question-id="D_374567479"]'); const hiddenRow = grid.querySelector('[data-question-id="D_966214244"]'); + expect(visibleRow.dataset.displayif).toBe('equals(SHOW_TASTE%2C1)'); + expect(decodeURIComponent(visibleRow.dataset.displayif)).toBe('equals(SHOW_TASTE,1)'); + expect(hiddenRow.dataset.displayif).toBe('equals(SHOW_FATIGUE%2C1)'); expect(visibleRow.style.display).not.toBe('none'); expect(hiddenRow.style.display).toBe('none'); expect(hiddenRow.dataset.hidden).toBe('true'); visibleRow.querySelector('input[value="232063618"]').click(); + const expectedRows = { D_374567479: '232063618' }; + expect(quest.state.getActiveQuestionState()).toEqual({ GRID_CONDITIONAL: expectedRows }); grid.querySelector('button.next').click(); await vi.waitFor(() => expect(quest.root.querySelector('form.question.active')?.id).toBe('END')); + expect(quest.state.getSurveyState()).toMatchObject({ GRID_CONDITIONAL: expectedRows }); + expect(quest.calls.store.at(-1)['TEST_CONDITIONAL_GRID.GRID_CONDITIONAL']).toEqual(expectedRows); + expect(quest.calls.store.at(-1)['TEST_CONDITIONAL_GRID.GRID_CONDITIONAL']) + .not.toHaveProperty('D_966214244'); expect(quest.root.querySelector('#softModal').classList.contains('show')).toBe(false); expect(quest.errors).toEqual([]); }); diff --git a/tests/integration/hostFailures.spec.js b/tests/integration/hostFailures.spec.js index dbe98f7..f074d48 100644 --- a/tests/integration/hostFailures.spec.js +++ b/tests/integration/hostFailures.spec.js @@ -40,8 +40,10 @@ describe('host boundary success, delay, and failure behavior', () => { await answerAndAdvance(quest); await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q1')); + await vi.waitFor(() => expect( + quest.root.querySelector('#storeErrorModal').classList.contains('show'), + ).toBe(true)); - expect(quest.root.querySelector('#storeErrorModal').classList).toContain('show'); expect(quest.errors.flat().join(' ')).toContain('Error syncing state to store'); }); diff --git a/tests/integration/mainRender.spec.js b/tests/integration/mainRender.spec.js index ab09847..2328396 100644 --- a/tests/integration/mainRender.spec.js +++ b/tests/integration/mainRender.spec.js @@ -34,6 +34,51 @@ describe('transform.render', () => { expect(quest.root.querySelector('#progressBar')).toBeNull(); }); + it.each([ + ['en', 'Survey progress'], + ['es', 'Progreso de la encuesta'], + ])('exposes the %s progress bar with its localized name and a valid initial value', async (lang, accessibleName) => { + const quest = await renderFreshQuest({ params: { lang } }); + const progress = quest.root.querySelector('#progressBar'); + + expect(progress.getAttribute('role')).toBe('progressbar'); + expect(progress.getAttribute('aria-label')).toBe(accessibleName); + expect(progress.getAttribute('aria-valuenow')).toBe('0'); + expect(progress.getAttribute('aria-valuemin')).toBe('0'); + expect(progress.getAttribute('aria-valuemax')).toBe('100'); + expect(progress.querySelector('#progressBarText').textContent).toBe('0%'); + }); + + it('keeps numeric progress synchronized while advancing and returning', async () => { + const quest = await renderFreshQuest(); + const radio = quest.root.querySelector('#Q1_1'); + const progress = quest.root.querySelector('#progressBar'); + + radio.click(); + radio.dispatchEvent(new Event('change', { bubbles: true })); + quest.root.querySelector('#Q1 .next').click(); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q2')); + + expect(progress.getAttribute('aria-valuenow')).toBe('33'); + expect(progress.style.width).toBe('33%'); + + const text = quest.root.querySelector('#Q2_TEXT'); + text.value = 'ok'; + text.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + quest.root.querySelector('#Q2 .next').click(); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('END')); + expect(progress.getAttribute('aria-valuenow')).toBe('100'); + expect(progress.style.width).toBe('100%'); + + quest.root.querySelector('#END .previous').click(); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q2')); + expect(progress.getAttribute('aria-valuenow')).toBe('33'); + + quest.root.querySelector('#Q2 .previous').click(); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q1')); + expect(progress.getAttribute('aria-valuenow')).toBe('0'); + }); + it('uses prefetched survey data without invoking retrieve', async () => { const quest = await renderFreshQuest({ persistedData: { Q1: '2', treeJSON: resumedAtQ1 }, @@ -100,6 +145,271 @@ describe('transform.render', () => { expect(quest.calls.store[0]['TEST_MODULE.treeJSON']).toBeTypeOf('string'); }); + it.each(['non200', 'reject'])( + 'restores an unsaved response after a %s store failure and retries the exact payload', + async (storeMode) => { + const store = vi.fn(); + if (storeMode === 'reject') { + store.mockRejectedValueOnce(new Error('Synthetic store rejection')); + } else { + store.mockResolvedValueOnce({ code: 503 }); + } + store.mockResolvedValue({ code: 200 }); + + const quest = await renderFreshQuest({ params: { store } }); + quest.root.querySelector('#Q1_2').click(); + quest.root.querySelector('#Q1 .next').click(); + + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q1')); + await vi.waitFor(() => expect( + quest.root.querySelector('#storeErrorModal').classList.contains('show'), + ).toBe(true)); + + const failedPayload = store.mock.calls[0][0]; + expect(Object.keys(failedPayload).sort()).toEqual([ + 'TEST_MODULE.Q1', + 'TEST_MODULE.treeJSON', + ]); + expect(failedPayload['TEST_MODULE.Q1']).toBe('2'); + expect(JSON.parse(failedPayload['TEST_MODULE.treeJSON']).currentNode).toBe('Q1?'); + expect(quest.state.getSurveyState()).toEqual({}); + expect(quest.state.getActiveQuestionState()).toEqual({ Q1: '2' }); + expect(quest.state.findResponseValue('Q1')).toBe('2'); + expect(quest.root.querySelector('#Q1_2').checked).toBe(true); + expect(quest.errors).toHaveLength(1); + + globalThis.bootstrap.Modal.getInstance(quest.root.querySelector('#storeErrorModal')).hide(); + quest.root.querySelector('#Q1 .next').click(); + + await vi.waitFor(() => expect(store).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q2')); + + const retryPayload = store.mock.calls[1][0]; + expect(retryPayload).toEqual(failedPayload); + expect(quest.state.getSurveyState()).toEqual({ + Q1: '2', + treeJSON: retryPayload['TEST_MODULE.treeJSON'], + }); + expect(quest.state.getActiveQuestionState()).toEqual({}); + expect(quest.errors).toHaveLength(1); + }, + ); + + it.each(['non200', 'reject'])( + 'restores the committed response after a failed %s Back deletion and permits retry', + async (storeMode) => { + const resumedAtQ2 = JSON.stringify({ + rootNode: { + value: null, + children: [{ + value: 'Q1?', + children: [{ value: 'Q2', children: [] }], + }], + }, + currentNode: 'Q2', + }); + const store = vi.fn(); + if (storeMode === 'reject') { + store.mockRejectedValueOnce(new Error('Synthetic store rejection')); + } else { + store.mockResolvedValueOnce({ code: 503 }); + } + store.mockResolvedValue({ code: 200 }); + + const quest = await renderFreshQuest({ + persistedData: { Q1: '1', Q2: 'saved', treeJSON: resumedAtQ2 }, + params: { store }, + }); + expect(quest.root.querySelector('#Q2_TEXT').value).toBe('saved'); + + quest.root.querySelector('#Q2 .previous').click(); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q2')); + await vi.waitFor(() => expect( + quest.root.querySelector('#storeErrorModal').classList.contains('show'), + ).toBe(true)); + + const failedPayload = store.mock.calls[0][0]; + expect(failedPayload).toHaveProperty('TEST_MODULE.Q2', undefined); + expect(JSON.parse(failedPayload['TEST_MODULE.treeJSON']).currentNode).toBe('Q1?'); + expect(quest.state.getSurveyState()).toEqual({ + Q1: '1', + Q2: 'saved', + treeJSON: resumedAtQ2, + }); + expect(quest.state.getActiveQuestionState()).toEqual({ Q2: 'saved' }); + expect(quest.state.findResponseValue('Q2')).toBe('saved'); + expect(quest.root.querySelector('#Q2_TEXT').value).toBe('saved'); + + globalThis.bootstrap.Modal.getInstance(quest.root.querySelector('#storeErrorModal')).hide(); + quest.root.querySelector('#Q2 .previous').click(); + + await vi.waitFor(() => expect(store).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q1')); + + expect(store.mock.calls[1][0]).toHaveProperty('TEST_MODULE.Q2', undefined); + expect(quest.state.getSurveyState()).toMatchObject({ Q1: '1', Q2: undefined }); + expect(quest.state.getActiveQuestionState()).toEqual({ Q1: '1' }); + expect(quest.errors).toHaveLength(1); + }, + ); + + it('does not retain rollback-only tree metadata after failed Back from an unanswered question', async () => { + const resumedAtQ2 = JSON.stringify({ + rootNode: { + value: null, + children: [{ + value: 'Q1?', + children: [{ value: 'Q2', children: [] }], + }], + }, + currentNode: 'Q2', + }); + const store = vi.fn() + .mockResolvedValueOnce({ code: 503 }) + .mockResolvedValue({ code: 200 }); + const quest = await renderFreshQuest({ + persistedData: { Q1: '1', treeJSON: resumedAtQ2 }, + params: { store }, + }); + + expect(quest.root.querySelector('#Q2_TEXT').value).toBe(''); + expect(quest.state.getActiveQuestionState()).toEqual({}); + + quest.root.querySelector('#Q2 .previous').click(); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q2')); + await vi.waitFor(() => expect( + quest.root.querySelector('#storeErrorModal').classList.contains('show'), + ).toBe(true)); + + const failedPayload = store.mock.calls[0][0]; + expect(Object.keys(failedPayload)).toEqual(['TEST_MODULE.treeJSON']); + expect(JSON.parse(failedPayload['TEST_MODULE.treeJSON']).currentNode).toBe('Q1?'); + expect(quest.state.getSurveyState()).toEqual({ Q1: '1', treeJSON: resumedAtQ2 }); + expect(quest.state.getActiveQuestionState()).toEqual({}); + expect(quest.state.findResponseValue('Q2')).toBeUndefined(); + expect(quest.root.querySelector('#Q2_TEXT').value).toBe(''); + + globalThis.bootstrap.Modal.getInstance(quest.root.querySelector('#storeErrorModal')).hide(); + quest.root.querySelector('#Q2 .previous').click(); + await vi.waitFor(() => expect(store).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q1')); + + expect(store.mock.calls[1][0]).toEqual(failedPayload); + expect(quest.state.getActiveQuestionState()).toEqual({ Q1: '1' }); + }); + + it('discards later-page live indexes when an earlier delayed write fails', async () => { + let resolveFirstStore; + const store = vi.fn() + .mockImplementationOnce(() => new Promise((resolve) => { + resolveFirstStore = resolve; + })) + .mockResolvedValue({ code: 200 }); + const quest = await renderFreshQuest({ params: { store } }); + + quest.root.querySelector('#Q1_1').click(); + quest.root.querySelector('#Q1 .next').click(); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q2')); + + const laterInput = quest.root.querySelector('#Q2_TEXT'); + laterInput.value = 'later'; + laterInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + expect(quest.state.getActiveQuestionState()).toEqual({ Q2: 'later' }); + expect(quest.state.findResponseValue('Q2')).toBe('later'); + + resolveFirstStore({ code: 503 }); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q1')); + await vi.waitFor(() => expect( + quest.root.querySelector('#storeErrorModal').classList.contains('show'), + ).toBe(true)); + + expect(quest.state.getSurveyState()).toEqual({}); + expect(quest.state.getActiveQuestionState()).toEqual({ Q1: '1' }); + expect(quest.state.getResponseToQuestionMapping()).toEqual({ Q1: 'Q1' }); + expect(quest.state.getCache()).toEqual({ Q1: '1' }); + expect(quest.state.findResponseValue('Q2')).toBeUndefined(); + expect(quest.root.querySelector('#Q1_1').checked).toBe(true); + }); + + it('keeps committed compound state isolated while restoring an edited response after store failure', async () => { + const compoundTree = JSON.stringify({ + rootNode: { value: null, children: [{ value: 'MULTI', children: [] }] }, + currentNode: 'MULTI', + }); + const committedResponse = { + CHECK_GROUP: ['1'], + RADIO_GROUP: '7', + DETAIL: 'saved detail', + }; + const editedResponse = { + ...committedResponse, + DETAIL: 'edited detail', + }; + let resolveStore; + let payloadBeforeHostMutation; + const store = vi.fn((changes) => { + payloadBeforeHostMutation = structuredClone(changes['ROLLBACK_COMPOUND.MULTI']); + changes['ROLLBACK_COMPOUND.MULTI'].CHECK_GROUP.push('host mutation'); + changes['ROLLBACK_COMPOUND.MULTI'].DETAIL = 'host mutation'; + return new Promise((resolve) => { + resolveStore = resolve; + }); + }); + const quest = await renderFreshQuest({ + markdown: ` + {"name":"ROLLBACK_COMPOUND"} + [MULTI?] Supply several values. + [1:CHECK_GROUP] First + [2:CHECK_GROUP] Second + (7:RADIO_GROUP) Seven + (8:RADIO_GROUP) Eight + |__|id=DETAIL| + [END,end] Done. + `, + persistedData: { + MULTI: committedResponse, + treeJSON: compoundTree, + }, + params: { store }, + }); + + const detail = quest.root.querySelector('#DETAIL'); + detail.value = editedResponse.DETAIL; + detail.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + // A live nested edit must not mutate the last committed host snapshot. + expect(quest.state.getSurveyState().MULTI).toEqual(committedResponse); + expect(quest.state.getActiveQuestionState().MULTI).toEqual(editedResponse); + + quest.root.querySelector('#MULTI .next').click(); + await vi.waitFor(() => expect(store).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('END')); + + expect(payloadBeforeHostMutation).toEqual(editedResponse); + expect(store.mock.calls[0][0]['ROLLBACK_COMPOUND.MULTI']).toEqual({ + ...editedResponse, + CHECK_GROUP: ['1', 'host mutation'], + DETAIL: 'host mutation', + }); + expect(quest.state.getSurveyState().MULTI).toEqual(editedResponse); + + resolveStore({ code: 503 }); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('MULTI')); + await vi.waitFor(() => expect( + quest.root.querySelector('#storeErrorModal').classList.contains('show'), + ).toBe(true)); + + expect(quest.state.getSurveyState()).toEqual({ + MULTI: committedResponse, + treeJSON: compoundTree, + }); + expect(quest.state.getActiveQuestionState()).toEqual({ MULTI: editedResponse }); + expect(quest.state.findResponseValue('DETAIL', 'MULTI')).toBe(editedResponse.DETAIL); + expect(quest.root.querySelector('#CHECK_GROUP_1').checked).toBe(true); + expect(quest.root.querySelector('#RADIO_GROUP_7').checked).toBe(true); + expect(quest.root.querySelector('#DETAIL').value).toBe(editedResponse.DETAIL); + }); + it('returns false and reports malformed survey input without throwing to the host', async () => { const quest = await renderFreshQuest({ markdown: 'not a questionnaire' }); @@ -107,6 +417,20 @@ describe('transform.render', () => { expect(quest.errors.length).toBeGreaterThan(0); }); + it.each([null, undefined, false, 0])( + 'ignores a non-response persisted value (%s) without dirtying startup', + async (persistedValue) => { + const quest = await renderFreshQuest({ + persistedData: { Q1: persistedValue }, + }); + + expect(quest.rendered).toBe(true); + expect(quest.root.querySelector('form.active')?.id).toBe('Q1'); + expect(quest.root.querySelectorAll('#Q1 input:checked')).toHaveLength(0); + expect(quest.errors).toEqual([]); + }, + ); + it('keeps previous-result lookups available to conditions without merging them into survey state', async () => { const markdown = ` {"name":"PREVIOUS_RESULTS"} @@ -125,10 +449,11 @@ describe('transform.render', () => { expect(quest.state.getActiveQuestionState()).toEqual({ Q1: '1' }); document.body.innerHTML = '
'; + const secondStore = vi.fn(async () => ({ code: 200 })); const rendered = await quest.transform.render({ activate: true, text: SIMPLE_SURVEY.replace('TEST_MODULE', 'SECOND_MODULE'), - store: vi.fn(async () => ({ code: 200 })), + store: secondStore, errorLogger: () => {}, }, 'secondRoot'); @@ -137,6 +462,239 @@ describe('transform.render', () => { expect(document.querySelector('#secondRoot form.active')?.id).toBe('Q1'); expect(quest.state.getSurveyState()).toEqual({}); expect(quest.state.getActiveQuestionState()).toEqual({}); + + document.querySelector('#secondRoot #Q1_2').click(); + document.querySelector('#secondRoot #Q1 .next').click(); + await vi.waitFor(() => expect(secondStore).toHaveBeenCalledOnce()); + + expect(quest.store).not.toHaveBeenCalled(); + expect(secondStore.mock.calls[0][0]).toMatchObject({ + 'SECOND_MODULE.Q1': '2', + 'SECOND_MODULE.treeJSON': expect.any(String), + }); + expect(Object.keys(secondStore.mock.calls[0][0]).some((key) => key.startsWith('TEST_MODULE.'))).toBe(false); + }); + + it('restores a sequential render only inside its current Quest root', async () => { + const quest = await renderFreshQuest({ rootId: 'firstRoot' }); + const firstRoot = quest.root; + firstRoot.querySelector('#Q1_1').click(); + expect(firstRoot.querySelector('#Q1_1').checked).toBe(true); + + const secondRoot = document.createElement('div'); + secondRoot.id = 'secondRoot'; + document.body.append(secondRoot); + const rendered = await quest.transform.render({ + activate: true, + text: SIMPLE_SURVEY.replace('TEST_MODULE', 'SECOND_ROOT'), + surveyDataPrefetch: { + Q1: '2', + treeJSON: JSON.stringify({ + rootNode: { value: null, children: [{ value: 'Q1?', children: [] }] }, + currentNode: 'Q1?', + }), + }, + store: vi.fn(async () => ({ code: 200 })), + errorLogger: () => {}, + }, 'secondRoot'); + + expect(rendered).toBe(true); + expect(firstRoot.querySelector('#Q1_1').checked).toBe(true); + expect(firstRoot.querySelector('#Q1_2').checked).toBe(false); + expect(secondRoot.querySelector('input[name="Q1"][value="1"]').checked).toBe(false); + expect(secondRoot.querySelector('input[name="Q1"][value="2"]').checked).toBe(true); + expect(quest.state.getActiveQuestionState()).toEqual({ Q1: '2' }); + }); + + it('uses the current render store for submission and supports a later hostless render', async () => { + const quest = await renderFreshQuest(); + const secondStore = vi.fn(async () => ({ code: 200 })); + document.body.innerHTML = '
'; + await quest.transform.render({ + activate: true, + text: SIMPLE_SURVEY.replace('TEST_MODULE', 'SECOND_SUBMIT'), + store: secondStore, + errorLogger: () => {}, + }, 'secondRoot'); + + await quest.state.submitSurvey(); + expect(quest.store).not.toHaveBeenCalled(); + expect(secondStore).toHaveBeenCalledOnce(); + expect(secondStore.mock.calls[0][0]).toMatchObject({ + 'SECOND_SUBMIT.COMPLETED': true, + 'SECOND_SUBMIT.COMPLETED_TS': expect.any(Date), + 'SECOND_SUBMIT.treeJSON': expect.any(String), + }); + + document.body.innerHTML = '
'; + await quest.transform.render({ + activate: true, + text: SIMPLE_SURVEY.replace('TEST_MODULE', 'HOSTLESS_RENDER'), + errorLogger: () => {}, + }, 'hostlessRoot'); + document.querySelector('#hostlessRoot #Q1_1').click(); + document.querySelector('#hostlessRoot #Q1 .next').click(); + await Promise.resolve(); + + expect(secondStore).toHaveBeenCalledOnce(); + await expect(quest.state.submitSurvey()).resolves.toBeUndefined(); + expect(secondStore).toHaveBeenCalledOnce(); + }); + + it.each(['non200', 'reject'])( + 'ignores a delayed %s store failure from an obsolete render', + async (storeMode) => { + const quest = await renderFreshQuest({ storeMode, storeDelay: 1_000 }); + const secondErrors = vi.fn(); + + vi.useFakeTimers(); + try { + quest.root.querySelector('#Q1_1').click(); + quest.root.querySelector('#Q1 .next').click(); + expect(quest.store).toHaveBeenCalledOnce(); + + document.body.innerHTML = '
'; + await quest.transform.render({ + activate: true, + text: SIMPLE_SURVEY.replace('TEST_MODULE', 'SECOND_AFTER_DELAY'), + store: vi.fn(async () => ({ code: 200 })), + errorLogger: secondErrors, + }, 'secondRoot'); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(document.querySelector('#secondRoot form.active')?.id).toBe('Q1'); + expect(quest.state.getSurveyState()).toEqual({}); + expect(quest.state.getActiveQuestionState()).toEqual({}); + expect(secondErrors).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }, + ); + + it('invalidates an obsolete store failure before awaiting URL Markdown', async () => { + const quest = await renderFreshQuest({ storeMode: 'reject', storeDelay: 1_000 }); + const secondErrors = vi.fn(); + const secondStore = vi.fn(async () => ({ code: 200 })); + let resolveSurveyFetch; + const surveyFetch = new Promise((resolve) => { + resolveSurveyFetch = resolve; + }); + + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn((url) => { + if (String(url).endsWith('/survey.txt')) return surveyFetch; + return Promise.resolve({ text: async () => '' }); + })); + try { + quest.root.querySelector('#Q1_1').click(); + quest.root.querySelector('#Q1 .next').click(); + expect(quest.store).toHaveBeenCalledOnce(); + + document.body.innerHTML = '
'; + const secondRender = quest.transform.render({ + activate: true, + url: 'https://example.test/survey.txt', + store: secondStore, + errorLogger: secondErrors, + }, 'secondRoot'); + + await vi.advanceTimersByTimeAsync(1_000); + expect(secondErrors).not.toHaveBeenCalled(); + + resolveSurveyFetch({ + text: async () => SIMPLE_SURVEY.replace('TEST_MODULE', 'URL_SECOND'), + }); + await expect(secondRender).resolves.toBe(true); + expect(document.querySelector('#secondRoot form.active')?.id).toBe('Q1'); + expect(quest.state.getSurveyState()).toEqual({}); + expect(quest.state.getActiveQuestionState()).toEqual({}); + expect(secondErrors).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + + it('does not rerun a cached async question or mutate a sequential render during store rollback', async () => { + const asyncSurvey = ` + {"name":"ROLLBACK_ASYNC"} + [ASYNC?] + [Q2?] Later question. + (1) Later response + [END,end] Done. + `; + let resolveStore; + let resolveRollbackAsync; + let asyncCallCount = 0; + const store = vi.fn(() => new Promise((resolve) => { + resolveStore = resolve; + })); + const fetchAsyncQuestion = vi.fn(async () => { + const callNumber = ++asyncCallCount; + if (callNumber > 1) { + await new Promise((resolve) => { + resolveRollbackAsync = resolve; + }); + } + + // Match Connect's host callback, which appends results to whichever + // Quest question is active when the asynchronous request settles. + const fieldset = document.querySelector('form.question.active fieldset'); + if (!fieldset) return; + fieldset.innerHTML = callNumber === 1 + ? ` + Choose the host-provided option. +
+ + +
+ ` + : 'Stale async mutation'; + }); + const quest = await renderFreshQuest({ + markdown: asyncSurvey, + params: { + store, + asyncQuestionsMap: { + '[ASYNC?]': { func: 'loadSyntheticOptions', args: [] }, + }, + fetchAsyncQuestion, + }, + }); + + await vi.waitFor(() => expect(quest.root.querySelector('#ASYNC_1')).not.toBeNull()); + quest.root.querySelector('#ASYNC_1').click(); + quest.root.querySelector('#ASYNC .next').click(); + await vi.waitFor(() => expect(store).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q2')); + + resolveStore({ code: 503 }); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('ASYNC')); + + const secondErrors = vi.fn(); + const rendered = await quest.transform.render({ + activate: true, + text: SIMPLE_SURVEY.replace('TEST_MODULE', 'SECOND_AFTER_ASYNC_ROLLBACK'), + store: vi.fn(async () => ({ code: 200 })), + errorLogger: secondErrors, + }, 'questionnaireRoot'); + + // Resolve a redundant rollback fetch if a regression starts one. Its + // production-shaped callback must never reach the replacement survey. + resolveRollbackAsync?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(rendered).toBe(true); + expect(fetchAsyncQuestion).toHaveBeenCalledOnce(); + expect(document.querySelector('#questionnaireRoot form.active')?.id).toBe('Q1'); + expect(document.querySelector('#questionnaireRoot #staleAsyncMutation')).toBeNull(); + expect(document.querySelector('#questionnaireRoot #Q1_1')).not.toBeNull(); + expect(document.querySelector('#questionnaireRoot #storeErrorModal').classList.contains('show')).toBe(false); + expect(quest.state.getSurveyState()).toEqual({}); + expect(quest.state.getActiveQuestionState()).toEqual({}); + expect(secondErrors).not.toHaveBeenCalled(); }); it('does not let a pending delegated input debounce mutate a sequential render', async () => { @@ -183,4 +741,36 @@ describe('transform.render', () => { } }); + it('does not let a pending selection announcement survive a sequential render', async () => { + const quest = await renderFreshQuest(); + const oldLiveRegion = quest.root.querySelector('#ariaLiveSelectionAnnouncer'); + + vi.useFakeTimers(); + try { + const oldRadio = quest.root.querySelector('#Q1_1'); + oldRadio.click(); + oldRadio.dispatchEvent(new Event('change', { bubbles: true })); + oldLiveRegion.textContent = 'Existing selection status.'; + + document.body.insertAdjacentHTML('beforeend', '
'); + const rendered = await quest.transform.render({ + activate: true, + text: SIMPLE_SURVEY.replace('TEST_MODULE', 'SECOND_ANNOUNCEMENT'), + store: vi.fn(async () => ({ code: 200 })), + errorLogger: () => {}, + }, 'secondRoot'); + + expect(rendered).toBe(true); + const newLiveRegion = document.querySelector('#secondRoot #ariaLiveSelectionAnnouncer'); + expect(oldLiveRegion.isConnected).toBe(true); + expect(oldLiveRegion.textContent).toBe(''); + await vi.advanceTimersByTimeAsync(100); + + expect(oldLiveRegion.textContent).toBe(''); + expect(newLiveRegion.textContent).toBe(''); + } finally { + vi.useRealTimers(); + } + }); + }); diff --git a/tests/integration/questionProcessor.spec.js b/tests/integration/questionProcessor.spec.js index 22ac1d4..403b1e0 100644 --- a/tests/integration/questionProcessor.spec.js +++ b/tests/integration/questionProcessor.spec.js @@ -17,6 +17,8 @@ const PRECALCULATED = { firstName: 'Synthetic', }; +const WHOLE_NUMBER_KEYPRESS_HANDLER = 'return (event.charCode == 8 || event.charCode == 0 || event.charCode == 13) ? null : event.charCode >= 48 && event.charCode <= 57'; + async function createProcessor(markdown, initialState = {}, options = {}) { vi.resetModules(); const questionnaire = await import('../../questionnaire.js'); @@ -104,10 +106,280 @@ describe('QuestionProcessor constructs', () => { expect(all.querySelector('#STATE_VALUE').querySelectorAll('option').length).toBeGreaterThan(50); expect(all.querySelector('#DATE_VALUE').getAttribute('aria-describedby')).toBe('DATE_VALUE-desc'); expect(all.querySelector('#MONTH_VALUE').dataset.minDateUneval).toBe('2020-01'); - expect(all.querySelector('#TIME_VALUE').getAttribute('aria-label')).toBe('Enter Time'); + expect(all.querySelector('#EMAIL_VALUE').getAttribute('aria-label')).toBe('Email'); + expect(all.querySelector('#PHONE_VALUE').getAttribute('aria-label')).toBe('Phone'); + expect(all.querySelector('#FULL_SSN').getAttribute('aria-label')).toBe('SSN'); + expect(all.querySelector('#SMALL_SSN').getAttribute('aria-label')).toBe('Last four'); + expect(all.querySelector('#ZIP_VALUE').getAttribute('aria-label')).toBe('Zip'); + expect(all.querySelector('#STATE_VALUE').getAttribute('aria-label')).toBe('State'); + expect(all.querySelector('#DATE_VALUE').getAttribute('aria-label')).toBe('Date'); + expect(all.querySelector('#MONTH_VALUE').getAttribute('aria-label')).toBe('Month'); + const time = all.querySelector('#TIME_VALUE'); + expect(time.hasAttribute('aria-label')).toBe(false); + expect(time.labels).toHaveLength(1); + expect(time.labels[0].htmlFor).toBe('TIME_VALUE'); + expect(time.labels[0].textContent).toBe('Time'); + expect(all.querySelector('#NUMBER_VALUE').getAttribute('aria-label')).toBe('Number'); + expect(all.querySelector('#TEXT_VALUE').getAttribute('aria-label')).toBe('Text'); + expect(all.querySelector('#NOTES').getAttribute('aria-label')).toBe('Notes'); + expect(all.querySelector('#AREA [data-click-type="reset"]')).not.toBeNull(); expect(all.querySelector('#HIDDEN_VALUE').dataset.hidden).toBe('true'); }); + it('renders Reset for actual textarea tags without matching response-free prose', async () => { + const { processor } = await createProcessor(` + {"name":"RESET_CONTROL_DETECTION"} + [PROSE] Your input will help, but this page has no response control. + [UPPERCASE_TEXTAREA?] Enter notes. + [END,end] Done. + `); + + expect(processById(processor, 'PROSE').querySelector('[data-click-type="reset"]')).toBeNull(); + expect(processById(processor, 'UPPERCASE_TEXTAREA').querySelector('#UPPER_NOTES')).not.toBeNull(); + expect(processById(processor, 'UPPERCASE_TEXTAREA').querySelector('[data-click-type="reset"]')) + .not.toBeNull(); + }); + + it.each([ + { + language: 'en', + labels: ['Age at diagnosis', 'Year at diagnosis'], + fallbackLabels: { + FALLBACK_EMAIL: 'Enter a value', + FALLBACK_PHONE: 'Enter a value', + FALLBACK_SSN: 'Enter a value', + FALLBACK_SSN_LAST_FOUR: 'Enter a value', + FALLBACK_ZIP: 'Enter a value', + FALLBACK_STATE: 'Choose a State', + FALLBACK_DATE: 'Enter a value', + FALLBACK_MONTH: 'Enter a value', + FALLBACK_TIME: 'Enter a value', + FALLBACK_NUMBER: 'Enter a value', + FALLBACK_TEXT: 'Enter a value', + FALLBACK_TEXTAREA: 'Enter a value', + }, + rangedDescription: 'Value must be greater than or equal to 1. Value must be less than or equal to 10', + unboundedDescription: 'Enter a value', + dateDescription: 'Enter a value', + monthDescription: 'Format should match YYYY-MM', + }, + { + language: 'es', + labels: ['Edad al momento del diagnóstico', 'Año del diagnóstico'], + fallbackLabels: { + FALLBACK_EMAIL: 'Introduzca un valor', + FALLBACK_PHONE: 'Introduzca un valor', + FALLBACK_SSN: 'Introduzca un valor', + FALLBACK_SSN_LAST_FOUR: 'Introduzca un valor', + FALLBACK_ZIP: 'Introduzca un valor', + FALLBACK_STATE: 'Elija un Estado', + FALLBACK_DATE: 'Introduzca un valor', + FALLBACK_MONTH: 'Introduzca un valor', + FALLBACK_TIME: 'Introduzca un valor', + FALLBACK_NUMBER: 'Introduzca un valor', + FALLBACK_TEXT: 'Introduzca un valor', + FALLBACK_TEXTAREA: 'Introduzca un valor', + }, + rangedDescription: 'El valor debe ser mayor o igual a 1. El valor debe ser menor o igual a 10', + unboundedDescription: 'Introduzca un valor', + dateDescription: 'Introduzca un valor', + monthDescription: 'Debe tener el formato AAAA-MM', + }, + ])('gives generated scalar controls distinct names and localized fallback guidance in $language', async ({ + language, + labels, + fallbackLabels, + rangedDescription, + unboundedDescription, + dateDescription, + monthDescription, + }) => { + const [ageLabel, yearLabel] = labels; + const { processor } = await createProcessor(` + {"name":"NUMERIC_NAMES_${language.toUpperCase()}"} + [COMPOUND?] Diagnosis details. + |__|__|id=AGE_VALUE min=1 max=10| ${ageLabel} + |__|__|__|__|id=YEAR_VALUE| ${yearLabel} + [FALLBACK?] Prompts on their own lines. + |@|id=FALLBACK_EMAIL| + |tel|id=FALLBACK_PHONE| + |SSN|id=FALLBACK_SSN| + |SSNsm|id=FALLBACK_SSN_LAST_FOUR| + |zip|id=FALLBACK_ZIP| + |state|id=FALLBACK_STATE| + |date|id=FALLBACK_DATE| + |month|id=FALLBACK_MONTH| + |time|id=FALLBACK_TIME| + |__|__|id=FALLBACK_NUMBER| + |__|id=FALLBACK_TEXT| + |___|FALLBACK_TEXTAREA| + [END,end] Done. + `, {}, { language }); + + const compound = processById(processor, 'COMPOUND'); + const fallback = processById(processor, 'FALLBACK'); + + expect(compound.querySelector('#AGE_VALUE')).toMatchObject({ + type: 'number', + name: 'COMPOUND', + min: '1', + max: '10', + }); + expect(compound.querySelector('#AGE_VALUE').getAttribute('aria-label')).toBe(ageLabel); + expect(compound.querySelector('#YEAR_VALUE').getAttribute('aria-label')).toBe(yearLabel); + expect(new Set(Array.from(compound.querySelectorAll('input[type="number"]'), (input) => input.getAttribute('aria-label'))).size).toBe(2); + expect(compound.querySelector('#AGE_VALUE-desc').textContent).toBe(rangedDescription); + expect(compound.querySelector('#YEAR_VALUE-desc').textContent).toBe(unboundedDescription); + for (const [id, accessibleName] of Object.entries(fallbackLabels)) { + const control = fallback.querySelector(`#${id}`); + const nameSource = control.getAttribute('aria-label') + || Array.from(control.labels ?? [], (label) => label.textContent).join(' '); + expect(nameSource, id).toBe(accessibleName); + } + expect(fallback.querySelector('#FALLBACK_NUMBER').getAttribute('aria-describedby')).toBe('FALLBACK_NUMBER-desc'); + expect(fallback.querySelector('#FALLBACK_DATE-desc').textContent).toBe(dateDescription); + expect(fallback.querySelector('#FALLBACK_MONTH-desc').textContent).toBe(monthDescription); + }); + + it('preserves authored accessible names and never mistakes metadata or choice markup for a name', async () => { + const { processor } = await createProcessor(` + {"name":"SCALAR_NAME_BOUNDARIES"} + [NUMBER?] Number. + |__|__|id=AUTHORED_NUMBER min=1 max=2 aria-label='Authored number'| + [DATE?] Authored date + |date|id=AUTHORED_DATE aria-labelledby='AUTHORED_DATE_LABEL'| + [METADATA?] Metadata is not a label. + |__|id=METADATA_TEXT data-aria-label=metadata| + [HASH?] Duration. + |__|__|id=HASH_NUMBER| # of Hours + [RADIO_SCALAR?] Pick one. + (1) Email |@|id=CHOICE_EMAIL| + [CHECK_SCALAR?] Pick any. + [1] Date |date|id=CHOICE_DATE| + [PREFIX_NUMBER?] Pick one. + (1) times per day |__|__|id=PREFIX_NUMBER_VALUE| + [SUFFIX_NUMBER?] Elija una opción. + (1) |__|__|id=SUFFIX_NUMBER_VALUE| veces al día + [PAREN_CAPTION?] Contact option (1) |@|id=PAREN_EMAIL| + [BRACKET_CAPTION?] Appointment [2] |date|id=BRACKET_DATE| + [END,end] Done. + `); + + const number = processById(processor, 'NUMBER').querySelector('#AUTHORED_NUMBER'); + expect(number.id).toBe('AUTHORED_NUMBER'); + expect(number.getAttribute('aria-label')).toBe('Authored number'); + expect(number.getAttribute('aria-describedby')).toBe('AUTHORED_NUMBER-desc'); + + const date = processById(processor, 'DATE').querySelector('#AUTHORED_DATE'); + expect(date.id).toBe('AUTHORED_DATE'); + expect(date.hasAttribute('aria-label')).toBe(false); + expect(date.getAttribute('aria-labelledby')).toBe('AUTHORED_DATE_LABEL'); + expect(date.getAttribute('aria-describedby')).toBe('AUTHORED_DATE-desc'); + + const metadata = processById(processor, 'METADATA').querySelector('#METADATA_TEXT'); + expect(metadata.dataset.ariaLabel).toBe('metadata'); + expect(metadata.getAttribute('aria-label')).toBe('Enter a value'); + + const hashNumber = processById(processor, 'HASH').querySelector('#HASH_NUMBER'); + expect(hashNumber.getAttribute('aria-label')).toBe('# of Hours'); + + const choiceEmail = processById(processor, 'RADIO_SCALAR').querySelector('#CHOICE_EMAIL'); + const choiceDate = processById(processor, 'CHECK_SCALAR').querySelector('#CHOICE_DATE'); + expect(choiceEmail.getAttribute('aria-label')).toBe('Email'); + expect(choiceDate.getAttribute('aria-label')).toBe('Date'); + expect(processById(processor, 'PREFIX_NUMBER').querySelector('#PREFIX_NUMBER_VALUE').getAttribute('aria-label')) + .toBe('times per day'); + expect(processById(processor, 'SUFFIX_NUMBER').querySelector('#SUFFIX_NUMBER_VALUE').getAttribute('aria-label')) + .toBe('veces al día'); + expect(processById(processor, 'PAREN_CAPTION').querySelector('#PAREN_EMAIL').getAttribute('aria-label')) + .toBe('Contact option (1)'); + expect(processById(processor, 'BRACKET_CAPTION').querySelector('#BRACKET_DATE').getAttribute('aria-label')) + .toBe('Appointment [2]'); + expect(`${choiceEmail.getAttribute('aria-label')} ${choiceDate.getAttribute('aria-label')}`).not.toMatch( + /<|>|class=|response|label=/i, + ); + }); + + it('protects accessible names from later choice parsing', async () => { + const { processor } = await createProcessor(` + {"name":"AUTHORED_SCALAR_CHOICE_DELIMITERS"} + [QUESTA11YOPTION_0_END?] Authored scalar names. + |@|id=AUTHORED_EMAIL aria-label='Contact option (1) [2] & more'| + |date|id=AUTHORED_DATE aria-label='Appointment [2]'| + |time|id=AUTHORED_TIME aria-label='Preferred time (3)'| + |__|__|id=AUTHORED_NUMBER aria-label='Amount [4]'| + [END,end] Done. + `); + const question = processById(processor, 'QUESTA11YOPTION_0_END'); + + expect(question.querySelector('#AUTHORED_EMAIL').getAttribute('aria-label')).toBe('Contact option (1) [2] & more'); + expect(question.querySelector('#AUTHORED_DATE').getAttribute('aria-label')).toBe('Appointment [2]'); + expect(question.querySelector('#AUTHORED_TIME').getAttribute('aria-label')).toBe('Preferred time (3)'); + expect(question.querySelector('#AUTHORED_NUMBER').getAttribute('aria-label')).toBe('Amount [4]'); + expect(question.querySelector('#AUTHORED_NUMBER').name).toBe('QUESTA11YOPTION_0_END'); + expect(question.querySelectorAll('input')).toHaveLength(4); + expect(question.querySelectorAll('input[type="radio"], input[type="checkbox"]')).toHaveLength(0); + expect(question.querySelectorAll('.response')).toHaveLength(0); + expect(question.querySelector('label[for="AUTHORED_TIME"]')).toBeNull(); + }); + + it('includes conditional caption text only when it matches the scalar condition', async () => { + const { processor } = await createProcessor(` + {"name":"CONDITIONAL_SCALAR_NAMES"} + [MATCHED?] Weight history. + |displayif=equals(D_TRIGGER,1)|18 years old| + |__|__|id=MATCHED_NUMBER displayif=equals(D_TRIGGER,1)||displayif=equals(D_TRIGGER,1)|Pounds| + [MISMATCHED?] Stable caption |displayif=equals(D_TRIGGER,1)|optional qualifier| |__|__|id=MISMATCHED_NUMBER displayif=equals(D_TRIGGER,2)| + [ALTERNATES?] Number of times |displayif=equals(D_TRIGGER,1)|fills||displayif=equals(D_TRIGGER,2)|filled| |__|__|id=ALTERNATE_NUMBER| + [END,end] Done. + `); + + expect(processById(processor, 'MATCHED').querySelector('#MATCHED_NUMBER').getAttribute('aria-label')) + .toBe('18 years old, Pounds'); + expect(processById(processor, 'MISMATCHED').querySelector('#MISMATCHED_NUMBER').getAttribute('aria-label')) + .toBe('Enter a value'); + expect(processById(processor, 'ALTERNATES').querySelector('#ALTERNATE_NUMBER').getAttribute('aria-label')) + .toBe('Enter a value'); + }); + + it('keeps generated number handlers intact inside authored display conditions', async () => { + const { processor } = await createProcessor(` + {"name":"CONDITIONAL_NUMBER_HANDLER"} + [WEIGHT?] Weight history. + |displayif=equals(SHOW_WEIGHT,1)|18 years old| + |displayif=equals(SHOW_WEIGHT,1)||__|__|__|id=WEIGHT_VALUE min=0 max=999||displayif=equals(SHOW_WEIGHT,1)|Pounds| + [END,end] Done. + `); + + const weight = processById(processor, 'WEIGHT'); + const input = weight.querySelector('#WEIGHT_VALUE'); + const handler = input.getAttribute('onkeypress'); + + expect(handler).toBe(WHOLE_NUMBER_KEYPRESS_HANDLER); + expect(input.closest('.displayif')?.getAttribute('displayif')).toBe('equals(SHOW_WEIGHT,1)'); + expect(weight.querySelectorAll('.displayif')).toHaveLength(3); + expect(weight.textContent).not.toContain('|displayif='); + expect(weight.textContent).toContain('18 years old'); + expect(weight.textContent).toContain('Pounds'); + }); + + it('does not add a visible delimiter to an already-closed conditional number', async () => { + const { processor } = await createProcessor(` + {"name":"CLOSED_CONDITIONAL_NUMBER"} + [WEIGHT?] Enter a weight. + |displayif=equals(SHOW_WEIGHT,1)||__|__|id=WEIGHT_VALUE|| + [END,end] Done. + `); + + const weight = processById(processor, 'WEIGHT'); + const input = weight.querySelector('#WEIGHT_VALUE'); + + expect(input.getAttribute('onkeypress')).toBe(WHOLE_NUMBER_KEYPRESS_HANDLER); + expect(input.getAttribute('aria-label')).toBe('Enter a value'); + expect(input.closest('.displayif')?.getAttribute('displayif')).toBe('equals(SHOW_WEIGHT,1)'); + expect(weight.textContent).not.toContain('|'); + }); + it('renders radios, checkboxes, reset choices, named groups, labels, and yes/no macros', async () => { const { processor } = await createProcessor(` {"name":"CHOICES"} @@ -261,6 +533,7 @@ describe('QuestionProcessor constructs', () => { expect(root.querySelector('#CHECKBOX input[data-reset="true"]')).not.toBeNull(); expect(root.querySelector('#COMBINED_807835037')?.getAttribute('skipto')).toBe('EMAIL'); expect(root.querySelector('#COMBINED_TEXT')?.type).toBe('text'); + expect(root.querySelector('#COMBINED_TEXT')?.getAttribute('aria-label')).toBe('Other'); expect(root.querySelector('#COMBINED_TEXTAREA_GROUP_1')?.getAttribute('skipto')).toBe('EMAIL'); expect(root.querySelector('#COMBINED_TEXTAREA_VALUE')?.tagName).toBe('TEXTAREA'); expect(root.querySelector('#CONFIRM_COPY')?.getAttribute('confirm')).toBeNull(); diff --git a/tests/integration/restoreResponses.spec.js b/tests/integration/restoreResponses.spec.js index eeacfc7..9681055 100644 --- a/tests/integration/restoreResponses.spec.js +++ b/tests/integration/restoreResponses.spec.js @@ -42,6 +42,31 @@ describe('response restoration', () => { expect(quest.state.getActiveQuestionState().CHECK).toEqual(['1', '3']); }); + it.each([ + ['soft', 'SOFT?'], + ['hard', 'HARD!'], + ])('restores a %s question from its raw tree token', async (_, treeToken) => { + const questionID = treeToken.slice(0, -1); + const quest = await renderFreshQuest({ + markdown: ` + {"name":"RESTORE_MARKED"} + [${treeToken}] Choose one. + (1) Selected response + (2) Other response + [END,end] Done. + `, + persistedData: { + [questionID]: '1', + treeJSON: treeAt(treeToken), + }, + }); + + expect(quest.root.querySelector('form.active')?.id).toBe(questionID); + expect(quest.root.querySelector(`#${questionID}_1`).checked).toBe(true); + expect(quest.state.getActiveQuestionState()).toEqual({ [questionID]: '1' }); + expect(JSON.parse(quest.questionQueue.toJSON()).currentNode).toBe(treeToken); + }); + it('restores compound strings, radio values, and checkbox arrays by response key', async () => { const quest = await renderFreshQuest({ markdown: ` @@ -119,6 +144,8 @@ describe('response restoration', () => { const { restoreResponses } = await import('../../restoreResponses.js'); expect(() => restoreResponses({}, 'Q1')).not.toThrow(); + expect(() => restoreResponses({ Q1: undefined }, 'Q1')).not.toThrow(); + expect(() => restoreResponses({ Q1: null }, 'Q1')).not.toThrow(); expect(() => restoreResponses({ MISSING: 'value' }, 'MISSING')).not.toThrow(); expect(quest.errors).toEqual([]); }); diff --git a/tests/knownDefects/registry.js b/tests/knownDefects/registry.js index df34162..4702ab5 100644 --- a/tests/knownDefects/registry.js +++ b/tests/knownDefects/registry.js @@ -23,78 +23,31 @@ export const runtimeDefects = Object.freeze({ treeDepthFirst: defect('QD-TREE-001', 'Tree.next depth-first traversal across root siblings', 'Traversal stops after the first root branch instead of continuing to later siblings.'), treePrune: defect('QD-TREE-002', 'Tree.prune branch removal', 'Pruning does not return to the authored predecessor with the expected sibling structure intact.'), treeHasNext: defect('QD-TREE-003', 'Tree.hasNext non-mutating lookahead', 'Lookahead reports false for the first root child even though a next value exists.'), - gridRowCondition: defect('QD-GRID-001', 'Radio-grid row displayif encoding', 'The row condition is encoded more than once and cannot be decoded to the authored expression.'), - mathDotValue: defect('QD-MATH-001', 'MathJS dot-notation value lookup', 'A valid leaf in a one-property response object is not returned.'), - mathDotExists: defect('QD-MATH-002', 'MathJS dot-notation existence lookup', 'exists() does not recognize a valid nested response leaf.'), - mathMonthRange: defect('QD-MATH-003', 'dateCompare documented month range', 'dateCompare accepts month 12 even though its contract documents zero through eleven.'), - legacyQuotedString: defect('QD-COND-001', 'Legacy condition quoted-string fallback', 'Fallback parsing drops or misinterprets quoted string literals.'), - corpusMalformedCondition: defect('QD-CORPUS-001', 'Malformed production condition fallback', 'The truncated COVID-19 grid condition falls back to a truthy function-name string, so its false outcome and hidden state are unreachable.', { + corpusMalformedCondition: defect('QD-CORPUS-001', 'Malformed production grid condition', 'The COVID-19 grid condition is missing its closing parenthesis and contains an incomplete/invalid response-ID complement, so its intended display logic cannot be evaluated as authored.', { owner: 'Questionnaire maintainers', corpusCommit: '7ae99a22af325cf0e14be047a7636462db9bfd50', sourcePaths: ['prod/moduleCOVID19.txt', 'prod/moduleCOVID19Spanish.txt'], questionId: 'D_114280729', }), - storeRollback: defect('QD-STORE-001', 'Store failure state rollback', 'Navigation rolls back after non-200/rejected writes, but the pre-write response snapshot is not restored.'), - sequentialHostCallbacks: defect('QD-STATE-001', 'Sequential render host callback rebinding', 'Module-level state keeps the first render host callbacks when a second survey is rendered in the same document.'), - finalCompoundResponseRemoval: defect('QD-STATE-002', 'Final compound response removal', 'Removing the final key assigns undefined without deleting the enumerable key, leaving a stale response object in active state.'), - clearedRestoredResponseLookup: defect('QD-STATE-003', 'Cleared restored response lookup', 'Clearing a restored scalar removes its live index entries, but lookup falls back to the old value retained in survey state.'), - unsyncedArrayResponseLookup: defect('QD-STATE-004', 'Unsynced array response lookup', 'Live cache lookup excludes arrays and objects, so an unsynced checkbox-array response cannot be found until it reaches survey state.'), - backResumeResponseRestoration: defect('QD-STATE-005', 'Back-generated tree token response restoration on resume', 'Back persists the authored question token with its ? or ! marker, but startup looks up the retained response under that raw token instead of the normalized form ID, leaving the resumed answer visually unselected.'), + corpusDuplicateScalarId: defect('QD-CORPUS-002', 'Duplicate production scalar response ID', 'The Module 1 esophageal-cancer question gives its age and year inputs the same response/DOM ID, so the values cannot be independently stored or restored.', { + owner: 'Questionnaire maintainers', + corpusCommit: '7ae99a22af325cf0e14be047a7636462db9bfd50', + sourcePaths: ['prod/module1.txt', 'prod/module1Spanish.txt'], + questionId: 'D_317093647', + }), + overlappingStoreFailure: defect('QD-STORE-002', 'Overlapping store failure reconciliation', 'If an earlier write fails after a later dependent write succeeds, Quest cannot reconcile the host and local response states causally.'), malformedLoopContinuation: defect('QD-QP-001', 'Malformed loop-continuation target handling', 'A malformed _CONTINUE target dereferences a failed regular-expression match instead of logging the invalid target and returning no question.'), currentQuestionUpperBoundary: defect('QD-QP-002', 'Current-question upper-bound guard', 'An index equal to the question count passes the range guard and attempts to process an undefined question.'), missingConfirmationTarget: defect('QD-QP-003', 'Missing confirmation target handling', 'A confirmation input that references a missing peer removes its invalid attribute but then dereferences the missing peer.'), missingQuestionId: defect('QD-QP-004', 'Missing question-ID lookup', 'findQuestion logs a missing ID but then calls startsWith on the absent value instead of returning its documented not-found result.'), explicitCombinedChoiceName: defect('QD-QP-005', 'Explicit name metadata on legacy combined choices', 'The combined-choice parser interpolates the full regular-expression match array, producing a duplicated comma-separated name instead of the authored name.'), - staleSelectionAnnouncement: defect('QD-A11Y-003', 'Selection announcement after navigation', 'A delayed selection announcement can repopulate the live region after Next or Back explicitly clears it.'), - textareaReset: defect('QD-RESET-001', 'Standalone textarea Reset behavior', 'Standalone textarea questions do not receive a Reset action, and the existing reset routine does not clear textarea values.', { - sourcePaths: [ - 'prod/module1.txt', - 'prod/module1Spanish.txt', - 'prod/module2026ROIPreferences.txt', - 'prod/module2026ROIPreferencesSpanish.txt', - ], - questionIds: ['D_868232409', 'D_233198706', 'D_395168461'], - automatedContract: 'A standalone textarea question offers Reset, and keyboard activation clears both its visible value and active response state.', - }), - authoringFallbackClear: defect('QD-AUTHOR-001', 'Authoring clear-memory operation after localforage fallback', 'The fallback storage adapter has no removeItem method, so Clear Memory throws after initialization falls back.', { - source: 'index.html authoring interface', - }), }); export const axeDefects = Object.freeze({ - progressbarName: defect('QD-AXE-001', '#progressBar accessible name', 'The progressbar has no accessible name.', { ruleId: 'aria-progressbar-name', impact: 'serious', targets: ['#progressBar'] }), - progressbarValue: defect('QD-AXE-002', '#progressBar ARIA value', 'The progressbar exposes an invalid ARIA attribute value.', { ruleId: 'aria-valid-attr-value', impact: 'critical', targets: ['#progressBar'] }), - emptyGridHeader: defect('QD-AXE-003', 'Grid row-header spacer', 'The responsive grid contains an empty table header cell.', { ruleId: 'empty-table-header', impact: 'minor', targets: ['.nr.hr'] }), - validationContrast: defect('QD-AXE-004', 'Validation message contrast', 'The visible validation message does not meet the required color contrast.', { ruleId: 'color-contrast', impact: 'serious', targets: ['.validation-container > span'] }), - validationLabel: defect('QD-AXE-005', 'Bounded numeric input label', 'The input relies on a title-only label relationship.', { ruleId: 'label-title-only', impact: 'serious', targets: ['#bounded'] }), imageAlt: defect('QD-AXE-006', 'Question image text alternative', 'QuestionProcessor emits an image without an alternative text attribute.', { ruleId: 'image-alt', impact: 'critical', targets: ['#PLAIN img'] }), - actionHoverContrast: defect('QD-AXE-007', 'Participant action hover contrast', 'The white action-button text does not retain sufficient contrast against the lighter hover background.', { ruleId: 'color-contrast', impact: 'serious', targets: ['.next'] }), -}); - -export const accessibilityDefects = Object.freeze({ - compoundQuestionContext: defect('QD-A11Y-005', 'Compound-question radio context', 'Radio choices in a multi-subgroup form are named only by their response option, not the subgroup prompt that gives the choice its meaning.', { - sourcePaths: [ - 'prod/moduleDietScreener', - 'prod/moduleDietScreenerSpanish.txt', - 'prod/moduleQoL.txt', - 'prod/moduleQoLSpanish.txt', - ], - questionIds: ['D_916948380', 'D_284353934'], - automatedContract: 'Each radio choice exposes both its subgroup prompt and its response option in its accessible name or equivalent accessible context.', - manualContract: 'Verify that VoiceOver and JAWS announce the food or sub-question prompt together with each response option when moving across a compound form.', - }), - 1079: defect('CONNECT-1079', 'Quest 2 participant choice semantics', 'Choice inputs are not consistently exposed to assistive technology with their native role, accessible name, and checked state.', { - issue: 'https://github.com/episphere/connect/issues/1079', - title: 'Quest2 - JAWS and VoiceOver for Quest2', - scope: 'Quest 2 participant runtime only; Quest 1 is intentionally out of scope.', - automatedContract: 'Each choice remains discoverable by native radio role and accessible name, and exposes its checked state.', - manualContract: 'Recheck real VoiceOver/Safari and JAWS/Chrome or Edge announcement, focus, and activation after the PWA redesign.', - limitation: 'Playwright accessibility trees do not run VoiceOver or JAWS and cannot validate their command-routing modes.', - }), }); export const allKnownDefects = Object.freeze([ ...Object.values(runtimeDefects), ...Object.values(axeDefects), - ...Object.values(accessibilityDefects), ]); diff --git a/tests/knownDefects/runtime.spec.js b/tests/knownDefects/runtime.spec.js index 362a44f..b6cc951 100644 --- a/tests/knownDefects/runtime.spec.js +++ b/tests/knownDefects/runtime.spec.js @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { parseGrid } from '../../buildGrid.js'; import { Tree } from '../../tree.js'; -import { renderFreshQuest, SIMPLE_SURVEY } from '../helpers/questRuntime.js'; +import { renderFreshQuest } from '../helpers/questRuntime.js'; +import { readLockedMarkdown } from '../e2e/support/corpus.js'; import { runtimeDefects } from './registry.js'; const expectedCovidGridResponseIds = [ @@ -11,16 +11,36 @@ const expectedCovidGridResponseIds = [ 'D_814101706', 'D_635026188', 'D_238135048', 'D_632714520', ].flatMap((id) => [`${id}_0`, `${id}_1`]); -// Reviewed against the locked English and Spanish COVID Markdown. Both files -// contain this identical, truncated display condition for D_114280729. -const MALFORMED_COVID_GRID_COMPLEMENT = 'someSelected("D_488415137_0","D_488415137_1","D_167695804_0","D_167695804_1","D_730334054_0","D_730334054_1","D_215996690_0","D_215996690_1","D_462737492_0","D_462737492_1","D_469675296_0","D_469675296_1","D_962475128_0","D_962475128_1","D_989576239_0","D_989576239_1","D_338613869_0","D_338613869_1","D_126794793_0","D_126794793_1","D_218793117_0","D_218793117_1","D_524096053_0","SRVCOV_COV19C1_V1R0_1,1","D_814101706_0","D_814101706_1","D_635026188_0","D_238135048_0","D_238135048_1","D_632714520_0","D_632714520_1'; +function lockedCovidGridCondition(locale) { + const gridLine = readLockedMarkdown('moduleCOVID19', locale) + .split(/\r?\n/) + .find((line) => line.includes('id="D_114280729"')); + const condition = gridLine?.match(/\bdisplayif=(.*?)\|/)?.[1]; + if (!condition) throw new Error(`Missing locked ${locale} D_114280729 grid condition`); + return condition; +} -const STANDALONE_TEXTAREA_SURVEY = ` -{"name":"TEXTAREA_RESET"} -[NOTES?] Enter two short notes. -|___|notes| -[END,end] Done. -`; +function lockedQuestionMarkdown(module, locale, questionId) { + const markdown = readLockedMarkdown(module, locale); + const lines = markdown.split(/\r?\n/); + const questionStart = lines.findIndex((line) => ( + line.trimStart().startsWith(`[${questionId}?`) + )); + const nextQuestion = lines.findIndex((line, index) => ( + index > questionStart && /^\s*\[[^\]]+\]/.test(line) + )); + const metadata = lines.find((line) => /^\s*\{.*"name".*\}\s*$/.test(line)); + + if (questionStart < 0 || !metadata) { + throw new Error(`Missing locked ${module} ${locale} question ${questionId}`); + } + + const questionLines = lines.slice( + questionStart, + nextQuestion < 0 ? lines.length : nextQuestion, + ); + return `${metadata}\n${questionLines.join('\n')}\n[KNOWN_DEFECT_END,end] Done.`; +} function buildBranchedTree() { const tree = new Tree(); @@ -33,40 +53,6 @@ function buildBranchedTree() { return tree; } -async function loadStateManager(initialState = {}) { - vi.resetModules(); - const questionnaire = await import('../../questionnaire.js'); - questionnaire.moduleParams.errorLogger = vi.fn(); - questionnaire.moduleParams.previousResults = {}; - const stateModule = await import('../../stateManager.js'); - stateModule.initializeStateManager(); - const appState = stateModule.getStateManager(); - appState.loadInitialSurveyState(initialState); - appState.setQuestionProcessor({ - findQuestion: () => ({ question: null }), - findGridRadioCheckboxEle: () => null, - }); - return appState; -} - -async function loadMathWithState(initialState = {}) { - const appState = await loadStateManager(initialState); - const mathModule = await import('../../customMathJSImplementation.js'); - mathModule.customMathJSFunctions.appState = appState; - return mathModule.customMathJSFunctions; -} - -async function loadEvaluator(state = {}, previousResults = {}) { - vi.resetModules(); - const questionnaire = await import('../../questionnaire.js'); - questionnaire.moduleParams.errorLogger = vi.fn(); - questionnaire.moduleParams.previousResults = previousResults; - const stateModule = await import('../../stateManager.js'); - stateModule.initializeStateManager(); - stateModule.getStateManager().loadInitialSurveyState(state); - return (await import('../../evaluateConditions.js')).evaluateCondition; -} - async function loadQuestionProcessor(markdown) { vi.resetModules(); const questionnaire = await import('../../questionnaire.js'); @@ -99,12 +85,6 @@ async function loadQuestionProcessor(markdown) { return { processor, errorLogger: questionnaire.moduleParams.errorLogger }; } -async function answerAndAdvance(quest) { - quest.root.querySelector('#Q1_1').click(); - quest.root.querySelector('#Q1 .next').click(); - await vi.waitFor(() => expect(quest.store).toHaveBeenCalled()); -} - describe('characterized Quest runtime defects', () => { beforeEach(() => vi.useRealTimers()); @@ -134,117 +114,73 @@ describe('characterized Quest runtime defects', () => { expect(tree.currentNode).toBe(tree.rootNode); }); - it.fails(`${runtimeDefects.gridRowCondition.localDefectId}: encodes a row condition once`, () => { - const html = parseGrid( - '|grid?|id=GRID|Shared|[ROW,displayif=equals(SHOW,1)]Conditional row;|(1:Yes)|', - '
', - ); - const template = document.createElement('template'); - template.innerHTML = html; - expect(decodeURIComponent(template.content.querySelector('[data-displayif]').dataset.displayif)).toBe('equals(SHOW,1)'); - }); - - it.fails(`${runtimeDefects.mathDotValue.localDefectId}: resolves a dot-notation leaf`, async () => { - const fn = await loadMathWithState({ OBJECT: { NESTED: 'value' } }); - expect(fn.getKeyedValue('OBJECT.NESTED')).toBe('value'); - }); - - it.fails(`${runtimeDefects.mathDotExists.localDefectId}: recognizes an existing dot-notation leaf`, async () => { - const fn = await loadMathWithState({ OBJECT: { NESTED: 'value' } }); - expect(fn.exists('OBJECT.NESTED')).toBe(true); + it.fails.each([ + ['English', 'en'], + ['Spanish', 'es'], + ])(`${runtimeDefects.corpusMalformedCondition.localDefectId}: %s COVID Markdown keeps the complete grid complement`, (_, locale) => { + const authoredCondition = lockedCovidGridCondition(locale); + const expectedCondition = `someSelected("${expectedCovidGridResponseIds.join('","')}")`; + expect(authoredCondition).toBe(expectedCondition); }); - it.fails(`${runtimeDefects.mathMonthRange.localDefectId}: rejects month 12`, async () => { - const fn = await loadMathWithState(); - expect(() => fn.dateCompare(12, 2026, 1, 2027)).toThrow('months need to be'); - }); - - it.fails(`${runtimeDefects.legacyQuotedString.localDefectId}: preserves quoted fallback literals`, async () => { - const evaluateCondition = await loadEvaluator({}, { PRIOR: 'yes' }); - expect(evaluateCondition('equals(PRIOR,"yes")')).toBe(true); - }); - - it.fails(`${runtimeDefects.textareaReset.localDefectId}: renders Reset for a standalone textarea`, async () => { - const quest = await renderFreshQuest({ markdown: STANDALONE_TEXTAREA_SURVEY }); - expect(quest.root.querySelector('#NOTES [data-click-type="reset"]')).not.toBeNull(); - }); - - it.fails(`${runtimeDefects.textareaReset.localDefectId}: clears a standalone textarea's visible value`, async () => { - const quest = await renderFreshQuest({ markdown: STANDALONE_TEXTAREA_SURVEY }); - const textarea = quest.root.querySelector('#notes'); - textarea.value = 'Clear this response'; - - const { resetChildren } = await import('../../eventHandlers.js'); - resetChildren(textarea.form); - - expect(textarea.value).toBe(''); - }); - - it.fails(`${runtimeDefects.corpusMalformedCondition.localDefectId}: rejects a truncated function expression`, async () => { - const evaluateCondition = await loadEvaluator(); - expect(evaluateCondition('someSelected("D_632714520_1')).toBe(false); - }); - - it.fails.each(['English', 'Spanish'])(`${runtimeDefects.corpusMalformedCondition.localDefectId}: %s COVID Markdown keeps the complete grid complement`, async () => { - const authoredResponseIds = [...MALFORMED_COVID_GRID_COMPLEMENT.matchAll(/"([^"]+)"/g)] - .map(([, responseId]) => responseId); - - expect(authoredResponseIds).toEqual(expectedCovidGridResponseIds); - - const evaluateCondition = await loadEvaluator(); - expect(evaluateCondition(MALFORMED_COVID_GRID_COMPLEMENT)).toBe(false); - }); - - it.fails.each(['non200', 'reject'])( - `${runtimeDefects.storeRollback.localDefectId}: restores the snapshot after a %s store result`, - async (storeMode) => { - const quest = await renderFreshQuest({ storeMode }); - await answerAndAdvance(quest); - await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q1')); - expect(quest.state.getSurveyState()).not.toHaveProperty('Q1'); - }, - ); - - it.fails(`${runtimeDefects.sequentialHostCallbacks.localDefectId}: rebinds second-render callbacks`, async () => { - const first = await renderFreshQuest(); - const secondStore = vi.fn(async () => ({ code: 200 })); - document.body.innerHTML = '
'; - await first.transform.render({ - activate: true, - text: SIMPLE_SURVEY.replace('TEST_MODULE', 'SECOND_MODULE'), - store: secondStore, - errorLogger: () => {}, - }, 'secondRoot'); - document.querySelector('#Q1_1').click(); - document.querySelector('#Q1 .next').click(); - await vi.waitFor(() => expect(secondStore).toHaveBeenCalled()); - }); - - it.fails(`${runtimeDefects.finalCompoundResponseRemoval.localDefectId}: removes the question after its final compound response is cleared`, async () => { - const appState = await loadStateManager(); - appState.setResponse('Q1', 'ONLY', 2, 'selected'); - - appState.removeResponseItem('Q1', 'ONLY', 2); - - expect(appState.getActiveQuestionState()).not.toHaveProperty('Q1'); - expect(appState.getResponseToQuestionMapping()).not.toHaveProperty('ONLY.Q1'); - expect(appState.getCache()).not.toHaveProperty('ONLY.Q1'); - }); - - it.fails(`${runtimeDefects.clearedRestoredResponseLookup.localDefectId}: does not return a restored scalar after it is cleared`, async () => { - const appState = await loadStateManager({ Q1: 'restored' }); - appState.setActiveQuestionState('Q1'); - - appState.setResponse('Q1', 'Q1', 1, ''); + it.fails.each([ + ['English', 'en'], + ['Spanish', 'es'], + ])(`${runtimeDefects.corpusDuplicateScalarId.localDefectId}: %s Module 1 age and year fields have independent response IDs`, async ( + _, + locale, + ) => { + const { processor } = await loadQuestionProcessor( + lockedQuestionMarkdown('module1', locale, 'D_317093647'), + ); + const inputs = Array.from( + processor.findQuestion('D_317093647').question.querySelectorAll('input[type="number"]'), + ); - expect(appState.findResponseValue('Q1')).toBeUndefined(); + expect(inputs).toHaveLength(2); + expect(new Set(inputs.map(({ id }) => id)).size).toBe(inputs.length); }); - it.fails(`${runtimeDefects.unsyncedArrayResponseLookup.localDefectId}: returns a live checkbox array before storage`, async () => { - const appState = await loadStateManager(); - appState.setResponse('Q1', 'CHECKBOX', 2, ['one', 'two']); - - expect(appState.findResponseValue('CHECKBOX', 'Q1')).toEqual(['one', 'two']); + it.fails(`${runtimeDefects.overlappingStoreFailure.localDefectId}: keeps local and host responses consistent when an earlier write fails late`, async () => { + let resolveFirstStore; + const hostResponses = {}; + const applySuccessfulChanges = (changes) => { + Object.entries(changes).forEach(([namespacedKey, value]) => { + const key = namespacedKey.replace(/^TEST_MODULE\./, ''); + if (key === 'treeJSON') return; + if (value === undefined) delete hostResponses[key]; + else hostResponses[key] = value; + }); + }; + const store = vi.fn((changes) => { + if (store.mock.calls.length === 1) { + return new Promise((resolve) => { + resolveFirstStore = resolve; + }); + } + applySuccessfulChanges(changes); + return Promise.resolve({ code: 200 }); + }); + const quest = await renderFreshQuest({ params: { store } }); + + quest.root.querySelector('#Q1_1').click(); + quest.root.querySelector('#Q1 .next').click(); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('Q2')); + const secondResponse = quest.root.querySelector('#Q2_TEXT'); + secondResponse.value = 'later'; + secondResponse.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + quest.root.querySelector('#Q2 .next').click(); + await vi.waitFor(() => expect(store).toHaveBeenCalledTimes(2)); + + resolveFirstStore({ code: 503 }); + await vi.waitFor(() => expect(quest.errors).toHaveLength(1)); + await vi.waitFor(() => expect( + quest.root.querySelector('#storeErrorModal').classList.contains('show'), + ).toBe(true)); + + expect(hostResponses).toEqual({ Q2: 'later' }); + const { treeJSON, ...localResponses } = quest.state.getSurveyState(); + expect(localResponses).toEqual(hostResponses); }); it.fails(`${runtimeDefects.malformedLoopContinuation.localDefectId}: rejects a malformed loop continuation without throwing`, async () => { diff --git a/tests/unit/buildGrid.spec.js b/tests/unit/buildGrid.spec.js index dcd91a9..5c02123 100644 --- a/tests/unit/buildGrid.spec.js +++ b/tests/unit/buildGrid.spec.js @@ -20,11 +20,27 @@ describe('parseGrid', () => { expect(form.querySelectorAll('tbody tr')).toHaveLength(2); expect(form.querySelectorAll('input[type="radio"]')).toHaveLength(4); expect(form.querySelector('#srFocusHelper')).toBeNull(); + const cornerSpacer = form.querySelector('thead tr > :first-child'); + expect(cornerSpacer.tagName).toBe('TD'); + expect(cornerSpacer.classList.contains('grid-corner-spacer')).toBe(true); + expect(cornerSpacer.textContent).toBe(''); + expect(form.querySelectorAll('thead th:not([scope="col"])')).toHaveLength(0); expect(form.querySelector('#ROW1_0').value).toBe('1'); + expect(form.querySelector('#ROW1_0').getAttribute('aria-labelledby')).toBe( + 'qtextROW1 labelROW1_0', + ); + expect(form.querySelector('#ROW2_1').getAttribute('aria-labelledby')).toBe( + 'qtextROW2 labelROW2_1', + ); + expect(form.querySelector('#qtextROW1').textContent).toContain('First'); + expect(form.querySelector('#labelROW1_0').textContent).toBe('Yes'); expect(form.querySelector('span[data-gridreplace="name"]')).not.toBeNull(); expect(form.querySelector('span[data-gridreplace="firstName"]')).not.toBeNull(); expect(form.querySelector('.grid-displayif')).not.toBeNull(); - expect(form.querySelector('[data-displayif]')).not.toBeNull(); + const conditionalRow = form.querySelector('[data-displayif]'); + expect(conditionalRow.dataset.displayif).toBe('equals(SHOW%2C1)'); + expect(decodeURIComponent(conditionalRow.dataset.displayif)).toBe('equals(SHOW,1)'); + expect(conditionalRow.dataset.displayif).not.toContain('%25'); expect(form.querySelector('.question-buttons')).not.toBeNull(); }); @@ -42,6 +58,12 @@ describe('parseGrid', () => { expect(form.querySelectorAll('input[type="checkbox"]')).toHaveLength(2); expect(form.querySelectorAll('th[scope="col"]')).toHaveLength(2); expect(form.querySelector('th[scope="row"]').textContent).toContain('A row'); + expect(form.querySelector('#ROW_0').getAttribute('aria-labelledby')).toBe( + 'qtextROW labelROW_0', + ); + expect(form.querySelector('#ROW_1').getAttribute('aria-labelledby')).toBe( + 'qtextROW labelROW_1', + ); }); it('uses a plain prompt when no edit marker is authored', () => { @@ -63,4 +85,19 @@ describe('parseGrid', () => { expect(template.content.querySelector('[data-gridreplacetype="eval"]')).not.toBeNull(); expect(template.content.querySelector('[data-gridreplacetype="_val"]')).not.toBeNull(); }); + + it('encodes production-style quoted row conditions exactly once', () => { + const authoredCondition = 'valueOrDefault("AGE","DEFAULT")>=18 and someSelected("ROW_1","ROW_2")'; + const html = parseGrid( + `|grid|id=CONDITIONAL|Question|[ROW,displayif=${authoredCondition}]Text;|(1:One)|`, + buttons, + ); + const template = document.createElement('template'); + template.innerHTML = html; + const serializedCondition = template.content.querySelector('[data-displayif]').dataset.displayif; + + expect(serializedCondition).toBe(encodeURIComponent(authoredCondition)); + expect(decodeURIComponent(serializedCondition)).toBe(authoredCondition); + expect(serializedCondition).not.toContain('%25'); + }); }); diff --git a/tests/unit/customMath.spec.js b/tests/unit/customMath.spec.js index bb350e7..5417f6f 100644 --- a/tests/unit/customMath.spec.js +++ b/tests/unit/customMath.spec.js @@ -138,6 +138,14 @@ describe('Quest MathJS extensions', () => { expect(fn.dateCompare(0, 2026, 1, 2026)).toBe(-1); expect(fn.dateCompare(1, 2026, 1, 2026)).toBe(0); expect(fn.dateCompare(2, 2026, 1, 2026)).toBe(1); + expect(fn.dateCompare('0', 2026, '11', 2026)).toBe(-1); + expect(fn.dateCompare(11, 2026, 0, 2027)).toBe(-1); + expect(() => fn.dateCompare(-1, 2026, 1, 2026)).toThrow('months need to be'); + expect(() => fn.dateCompare(1, 2026, 12, 2026)).toThrow('months need to be'); + expect(() => fn.dateCompare('not-a-month', 2026, 1, 2026)).toThrow('months need to be'); + expect(() => fn.dateCompare(1.5, 2026, 1, 2026)).toThrow('months need to be'); + expect(() => fn.dateCompare(' ', 2026, 1, 2026)).toThrow('months need to be'); + expect(() => fn.dateCompare(false, 2026, 1, 2026)).toThrow('months need to be'); expect(fn.yearMonth('2026-04').toString()).toBe('2026-04'); expect(fn.yearMonth('not-a-month')).toBe(false); expect(fn.isSelected('ROW_VALUE_0')).toBe(false); diff --git a/tests/unit/stateManager.spec.js b/tests/unit/stateManager.spec.js index e7fec17..5aca557 100644 --- a/tests/unit/stateManager.spec.js +++ b/tests/unit/stateManager.spec.js @@ -50,7 +50,7 @@ describe('state manager', () => { expect(manager.getActiveQuestionState()).toEqual({ Q1: undefined, Q2: { ROW_A: '1', ROW_B: ['2', '3'] } }); manager.removeResponseItem('Q2', 'ROW_A', 2); - expect(manager.getActiveQuestionState().Q2).toEqual({ ROW_A: undefined, ROW_B: ['2', '3'] }); + expect(manager.getActiveQuestionState().Q2).toEqual({ ROW_B: ['2', '3'] }); manager.removeResponse('Q2'); expect(manager.getActiveQuestionState().Q2).toBeUndefined(); @@ -60,7 +60,7 @@ describe('state manager', () => { expect(manager.getActiveQuestionState().Q3).toBeUndefined(); }); - it('keeps live response mappings and cache entries coherent through removals', async () => { + it('keeps live mappings and explicit cache tombstones coherent through removals', async () => { const { manager } = await createManager(); manager.setResponse('SINGLE', 'SINGLE', 1, 'yes'); @@ -85,9 +85,13 @@ describe('state manager', () => { manager.removeResponseItem('MISSING', 'MISSING', 1); expect(manager.getResponseToQuestionMapping()).toEqual({ + SINGLE: 'SINGLE', + 'FIRST.MULTI': 'MULTI.FIRST', 'SECOND.MULTI': 'MULTI.SECOND', }); expect(manager.getCache()).toEqual({ + SINGLE: undefined, + 'FIRST.MULTI': undefined, 'SECOND.MULTI': ['two', 'three'], }); expect(manager.findResponseValue('SINGLE')).toBeUndefined(); @@ -239,6 +243,90 @@ describe('state manager', () => { expect(manager.getSurveyState()).toHaveProperty('Q1', undefined); }); + it('collapses the final compound removal into a question-level store deletion', async () => { + const { manager, store } = await createManager(); + manager.setResponse('Q1', 'FIRST', 2, 'one'); + manager.setResponse('Q1', 'SECOND', 2, 'two'); + + manager.removeResponseItem('Q1', 'FIRST', 2); + expect(manager.getActiveQuestionState().Q1).toEqual({ SECOND: 'two' }); + expect(manager.getActiveQuestionState().Q1).not.toHaveProperty('FIRST'); + expect(manager.getCache()).toHaveProperty('FIRST.Q1', undefined); + + manager.removeResponseItem('Q1', 'SECOND', 2); + const deletionState = manager.getActiveQuestionState(); + expect(Object.prototype.hasOwnProperty.call(deletionState, 'Q1')).toBe(true); + expect(deletionState.Q1).toBeUndefined(); + expect(manager.getCache()).toMatchObject({ + 'FIRST.Q1': undefined, + 'SECOND.Q1': undefined, + }); + + manager.syncToStore(document.querySelector('.next')); + await vi.waitFor(() => expect(store).toHaveBeenCalledOnce()); + + expect(store.mock.calls[0][0]).toHaveProperty('STATE_TEST.Q1', undefined); + expect(manager.getActiveQuestionState()).toEqual({}); + expect(manager.getSurveyState()).toHaveProperty('Q1', undefined); + }); + + it('keeps a cleared restored response absent before and after synchronization', async () => { + const { manager, store } = await createManager(); + manager.loadInitialSurveyState({ Q1: 'restored' }); + manager.setActiveQuestionState('Q1'); + + manager.setResponse('Q1', 'Q1', 1, ''); + + expect(manager.getSurveyState().Q1).toBe('restored'); + expect(manager.getActiveQuestionState()).toHaveProperty('Q1', undefined); + expect(manager.getCache()).toHaveProperty('Q1', undefined); + expect(manager.findResponseValue('Q1')).toBeUndefined(); + + manager.setResponse('Q1', 'Q1', 1, 'replacement'); + expect(manager.findResponseValue('Q1')).toBe('replacement'); + manager.setResponse('Q1', 'Q1', 1, ''); + + manager.syncToStore(document.querySelector('.next')); + await vi.waitFor(() => expect(store).toHaveBeenCalledOnce()); + + expect(store.mock.calls[0][0]).toHaveProperty('STATE_TEST.Q1', undefined); + expect(manager.getSurveyState()).toHaveProperty('Q1', undefined); + expect(manager.findResponseValue('Q1')).toBeUndefined(); + }); + + it('returns every live compound value shape before synchronization', async () => { + const { manager } = await createManager(); + manager.setResponse('Q1', 'PRIMITIVE', 3, 'one'); + manager.setResponse('Q1', 'ARRAY', 3, ['two', 'three']); + manager.setResponse('Q1', 'OBJECT', 3, { nested: 'four' }); + + expect(manager.findResponseValue('PRIMITIVE', 'Q1')).toBe('one'); + expect(manager.findResponseValue('ARRAY', 'Q1')).toEqual(['two', 'three']); + expect(manager.findResponseValue('OBJECT', 'Q1')).toEqual({ nested: 'four' }); + expect(manager.findResponseValue('PRIMITIVE')).toBe('one'); + expect(manager.findResponseValue('ARRAY')).toEqual(['two', 'three']); + expect(manager.findResponseValue('OBJECT')).toEqual({ nested: 'four' }); + + manager.removeResponseItem('Q1', 'ARRAY', 3); + expect(manager.findResponseValue('ARRAY', 'Q1')).toBeUndefined(); + expect(manager.findResponseValue('ARRAY')).toBeUndefined(); + expect(manager.findResponseValue('PRIMITIVE')).toBe('one'); + }); + + it('keeps untouched restored compound siblings available after a live clear', async () => { + const { manager } = await createManager(); + manager.loadInitialSurveyState({ Q1: { FIRST: 'old', SECOND: 'retained' } }); + manager.setActiveQuestionState('Q1'); + + manager.setResponse('Q1', 'FIRST', 2, ''); + + expect(manager.getActiveQuestionState().Q1).toEqual({ SECOND: 'retained' }); + expect(manager.findResponseValue('FIRST', 'Q1')).toBeUndefined(); + expect(manager.findResponseValue('FIRST')).toBeUndefined(); + expect(manager.findResponseValue('SECOND', 'Q1')).toBe('retained'); + expect(manager.findResponseValue('SECOND')).toBe('retained'); + }); + it('submits completion metadata without mutating the production payload contract', async () => { const { manager, store } = await createManager(); @@ -290,16 +378,16 @@ describe('state manager', () => { expect(manager.getQuestionProcessor()).toBeNull(); }); - it('clears the existing manager when the module graph is initialized again', async () => { + it('reinitializes the existing manager with the next render state', async () => { const { manager } = await createManager(); manager.loadInitialSurveyState({ Q1: 'saved' }); manager.setActiveQuestionState('Q1'); const stateModule = await import('../../stateManager.js'); - stateModule.initializeStateManager(vi.fn(async () => ({ code: 200 })), { Q2: 'ignored' }); + stateModule.initializeStateManager(vi.fn(async () => ({ code: 200 })), { Q2: 'next render' }); expect(stateModule.getStateManager()).toBe(manager); - expect(manager.getSurveyState()).toEqual({}); + expect(manager.getSurveyState()).toEqual({ Q2: 'next render' }); expect(manager.getActiveQuestionState()).toEqual({}); }); diff --git a/validate.js b/validate.js index dd4525a..a91a2a6 100644 --- a/validate.js +++ b/validate.js @@ -63,8 +63,6 @@ export function validationError(inputElement, errorMsg) { // styling should be performed by CSS errDiv.style.minHeight = "30px"; errSpan.style.height = "inherit"; - errSpan.style.color = "red"; - errDiv.appendChild(errSpan); inputElement.insertAdjacentElement("afterend", errDiv); } From 955322807c2149cf15ac2f81fffa6c5cc6c63f9b Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:18:43 -0400 Subject: [PATCH 2/6] update popover handling and related tests --- questionnaire.js | 11 +++-- tests/e2e/popoverAccessibility.spec.js | 11 +++++ tests/integration/eventHandlers.spec.js | 31 ++++++++++---- tests/setup/jsdom.js | 54 +++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/questionnaire.js b/questionnaire.js index 7fc3ca7..400ac27 100644 --- a/questionnaire.js +++ b/questionnaire.js @@ -1225,11 +1225,16 @@ function handlePopoverKeydown(event) { event.preventDefault(); getOrCreatePopover(event.currentTarget).toggle(); } else if (event.key === 'Escape') { - const popover = bootstrap.Popover.getInstance(event.currentTarget); - if (popover) { + const trigger = event.currentTarget; + const popover = bootstrap.Popover.getInstance(trigger); + // Popover instances are created eagerly, so require this trigger's rendered tip to be open. + const popoverId = trigger.getAttribute('aria-describedby'); + const popoverElement = popoverId && trigger.ownerDocument.getElementById(popoverId); + + if (popover && popoverElement?.classList.contains('show')) { event.preventDefault(); popover.hide(); - event.currentTarget.focus({ preventScroll: true }); + trigger.focus({ preventScroll: true }); } } } diff --git a/tests/e2e/popoverAccessibility.spec.js b/tests/e2e/popoverAccessibility.spec.js index 0088349..42b6bb1 100644 --- a/tests/e2e/popoverAccessibility.spec.js +++ b/tests/e2e/popoverAccessibility.spec.js @@ -86,6 +86,17 @@ test.describe('production-shaped popover accessibility @canonical', () => { await expect(trigger).toBeFocused(); await expect(helpPopover(page)).toHaveCount(0); + const closedEscape = await trigger.evaluate((element) => { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }); + const dispatchResult = element.dispatchEvent(event); + return { + defaultPrevented: event.defaultPrevented, + dispatchResult, + }; + }); + expect(closedEscape).toEqual({ defaultPrevented: false, dispatchResult: true }); + await expect(helpPopover(page)).toHaveCount(0); + await page.keyboard.press('Enter'); const popover = helpPopover(page); await expect(popover).toBeVisible(); diff --git a/tests/integration/eventHandlers.spec.js b/tests/integration/eventHandlers.spec.js index 4baff99..d242b53 100644 --- a/tests/integration/eventHandlers.spec.js +++ b/tests/integration/eventHandlers.spec.js @@ -298,29 +298,42 @@ describe('delegated runtime event handling', () => { const trigger = quest.root.querySelector('[data-bs-toggle="popover"]'); const instance = bootstrap.Popover.getInstance(trigger); expect(instance).not.toBeNull(); + const hidePopover = vi.spyOn(instance, 'hide'); expect(trigger.dataset.bsTrigger).toBe('manual'); trigger.focus(); - expect(trigger.classList.contains('show')).toBe(false); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); + + const closedEscape = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }); + expect(trigger.dispatchEvent(closedEscape)).toBe(true); + expect(closedEscape.defaultPrevented).toBe(false); + expect(hidePopover).not.toHaveBeenCalled(); const space = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: ' ' }); expect(trigger.dispatchEvent(space)).toBe(false); expect(space.defaultPrevented).toBe(true); - expect(trigger.classList.contains('show')).toBe(true); + const popoverId = trigger.getAttribute('aria-describedby'); + const popoverElement = document.getElementById(popoverId); + expect(popoverElement).not.toBeNull(); + expect(popoverElement.classList.contains('show')).toBe(true); const escape = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' }); expect(trigger.dispatchEvent(escape)).toBe(false); expect(escape.defaultPrevented).toBe(true); - expect(trigger.classList.contains('show')).toBe(false); + expect(hidePopover).toHaveBeenCalledOnce(); + expect(popoverElement.classList.contains('show')).toBe(false); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); expect(document.activeElement).toBe(trigger); const click = new MouseEvent('click', { bubbles: true, cancelable: true }); expect(trigger.dispatchEvent(click)).toBe(false); expect(click.defaultPrevented).toBe(true); - expect(trigger.classList.contains('show')).toBe(true); + const reopenedPopoverElement = document.getElementById(trigger.getAttribute('aria-describedby')); + expect(reopenedPopoverElement).not.toBeNull(); + expect(reopenedPopoverElement.classList.contains('show')).toBe(true); trigger.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); - expect(trigger.classList.contains('show')).toBe(false); + expect(reopenedPopoverElement.classList.contains('show')).toBe(false); }); it('disposes an open popover after its hidden lifecycle event', async () => { @@ -333,15 +346,17 @@ describe('delegated runtime event handling', () => { trigger.addEventListener('hidden.bs.modal', hiddenModal); instance.show(); - trigger.setAttribute('aria-describedby', 'synthetic-popover'); - expect(trigger.classList.contains('show')).toBe(true); + const popoverElement = document.getElementById(trigger.getAttribute('aria-describedby')); + expect(popoverElement).not.toBeNull(); + expect(popoverElement.classList.contains('show')).toBe(true); const { disposePopovers } = await import('../../questionnaire.js'); disposePopovers(quest.root); expect(hiddenPopover).toHaveBeenCalledOnce(); expect(hiddenModal).not.toHaveBeenCalled(); - expect(trigger.classList.contains('show')).toBe(false); + expect(popoverElement.classList.contains('show')).toBe(false); + expect(trigger.hasAttribute('aria-describedby')).toBe(false); expect(bootstrap.Popover.getInstance(trigger)).toBeNull(); }); diff --git a/tests/setup/jsdom.js b/tests/setup/jsdom.js index 2ce7b3f..ace3cbb 100644 --- a/tests/setup/jsdom.js +++ b/tests/setup/jsdom.js @@ -101,6 +101,60 @@ beforeEach(() => { Popover: class PopoverStub extends BootstrapComponentStub { static instances = new WeakMap(); static eventNamespace = 'popover'; + + static nextTipId = 1; + + constructor(element) { + super(element); + this._tip = null; + } + + _getTipElement() { + if (!this._tip) { + this._tip = this._element.ownerDocument.createElement('div'); + this._tip.id = `test-popover-${this.constructor.nextTipId++}`; + this._tip.classList.add('popover'); + this._tip.setAttribute('role', 'tooltip'); + } + + return this._tip; + } + + show() { + const tip = this._getTipElement(); + if (tip.classList.contains('show')) return; + + this._element.setAttribute('aria-describedby', tip.id); + this._element.ownerDocument.body.append(tip); + tip.classList.add('show'); + this._element.dispatchEvent(new Event(`shown.bs.${this.constructor.eventNamespace}`)); + } + + hide() { + const tip = this._tip; + if (!tip?.classList.contains('show')) return; + + tip.classList.remove('show'); + this._element.removeAttribute('aria-describedby'); + tip.remove(); + this._tip = null; + this._element.dispatchEvent(new Event(`hidden.bs.${this.constructor.eventNamespace}`)); + } + + toggle() { + if (this._tip?.classList.contains('show')) { + this.hide(); + } else { + this.show(); + } + } + + dispose() { + this._element.removeAttribute('aria-describedby'); + this._tip?.remove(); + this._tip = null; + super.dispose(); + } }, }; From 0101c53893fe733db701876650ac64f5cf36b7b8 Mon Sep 17 00:00:00 2001 From: Joe Armani <93854858+JoeArmani@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:24:34 -0400 Subject: [PATCH 3/6] Accessibility & Testing Updates - VoiceOver/JAWS --- accessibleQuestionTextBuilder.js | 119 +++++- common.js | 26 +- docs/accessibilityManualTest.md | 70 +++- eventHandlers.js | 47 ++- main.js | 30 +- questionProcessor.js | 89 +++- questionnaire.js | 167 +++++--- stateManager.js | 7 +- tests/corpus/README.md | 2 +- tests/corpus/corpusIntegrity.spec.js | 2 +- tests/corpus/module4AddressStructure.spec.js | 6 +- ...odule4ConditionalCompoundStructure.spec.js | 46 +-- .../corpus/productionMarkupEdgeCases.spec.js | 2 +- tests/corpus/responsePipingStructure.spec.js | 16 +- .../corpus/scripts/lib/structuralCatalog.mjs | 6 +- tests/corpus/scripts/verifyCorpus.mjs | 2 +- tests/corpus/structuralCatalog.json | 266 ++++++------ tests/corpus/structuralCatalog.spec.js | 6 +- tests/e2e/accessibility.spec.js | 387 +++++++++++++++--- ...accessibilityLifecycleKnownDefects.spec.js | 4 +- tests/e2e/authoring.spec.js | 8 +- tests/e2e/constructs.spec.js | 2 +- tests/e2e/keyboardNavigation.spec.js | 25 +- tests/e2e/manualAccessibilityUrls.spec.js | 105 +++++ tests/e2e/module4AddressPaths.spec.js | 11 - tests/e2e/participantAsync.spec.js | 30 +- tests/e2e/participantControls.spec.js | 29 +- tests/e2e/participantNavigationState.spec.js | 27 +- tests/e2e/popoverAccessibility.spec.js | 7 - tests/e2e/productionCompoundForms.spec.js | 16 +- tests/e2e/productionFanoutXor.spec.js | 2 +- tests/e2e/productionMarkupFidelity.spec.js | 74 +++- tests/e2e/productionPiping.spec.js | 2 +- tests/e2e/responsive.spec.js | 4 - tests/e2e/visual.spec.js | 4 - tests/fixtures/canonical/allConstructs.txt | 2 +- .../canonical/responseConfirmation.txt | 5 + .../canonical/responseConfirmationSpanish.txt | 5 + tests/harness/participant.js | 34 +- tests/harness/participantScenarios.js | 45 ++ .../integration/accessibilityBehavior.spec.js | 68 ++- tests/integration/eventHandlers.spec.js | 238 ++++++++++- tests/integration/gridDeepCoverage.spec.js | 13 +- tests/integration/mainRender.spec.js | 99 +++++ ...idelity.spec.js => markupFidelity.spec.js} | 10 +- .../productionLoopCoverage.spec.js | 2 +- tests/integration/questionProcessor.spec.js | 164 ++++++-- .../integration/questionnaireHelpers.spec.js | 60 ++- tests/integration/restoreResponses.spec.js | 2 +- tests/integration/validation.spec.js | 76 +++- tests/knownDefects/registry.js | 26 +- tests/knownDefects/runtime.spec.js | 11 +- tests/setup/jsdom.js | 22 + tests/unit/buildGrid.spec.js | 10 +- tests/unit/customMath.spec.js | 4 +- tests/unit/stateManager.spec.js | 7 +- validate.js | 104 ++++- 57 files changed, 2088 insertions(+), 565 deletions(-) create mode 100644 tests/e2e/manualAccessibilityUrls.spec.js create mode 100644 tests/fixtures/canonical/responseConfirmation.txt create mode 100644 tests/fixtures/canonical/responseConfirmationSpanish.txt create mode 100644 tests/harness/participantScenarios.js rename tests/integration/{authoredMarkupFidelity.spec.js => markupFidelity.spec.js} (97%) diff --git a/accessibleQuestionTextBuilder.js b/accessibleQuestionTextBuilder.js index 621260e..c9dcee8 100644 --- a/accessibleQuestionTextBuilder.js +++ b/accessibleQuestionTextBuilder.js @@ -1,29 +1,96 @@ import { evaluateCondition } from './evaluateConditions.js'; import { handleForIDAttributes, moduleParams } from './questionnaire.js'; -const QUESTION_TRANSITION_FOCUS_DELAY_MS = 500; -const MODAL_RETURN_FOCUS_DELAY_MS = 100; +const QUESTION_FOCUS_CANCEL_EVENTS = ['focusin', 'keydown', 'pointerdown', 'click']; +let pendingQuestionFocusHandoff = null; let selectionAnnouncementTimeout = null; +/** + * Begin the focus handoff for a newly activated question. + * Participant or host interaction before the next animation frame cancels it. + * @param {Document} ownerDocument - The document containing the active question. + * @returns {{schedule: (focusableEle: HTMLElement) => void, cancel: () => void} | null} + */ +export function beginQuestionFocusHandoff(ownerDocument) { + clearQuestionFocusHandoff(); + + const ownerWindow = ownerDocument?.defaultView; + if (moduleParams.isRenderer || !ownerWindow?.requestAnimationFrame) return null; + + let active = true; + let animationFrameId = null; + let handoff; + + const clear = ({ cancelFrame = true } = {}) => { + if (!active) return; + active = false; + + if (cancelFrame && animationFrameId !== null) { + ownerWindow.cancelAnimationFrame(animationFrameId); + } + animationFrameId = null; + + QUESTION_FOCUS_CANCEL_EVENTS.forEach((eventName) => { + ownerDocument.removeEventListener(eventName, handoff.cancel, true); + }); + + if (pendingQuestionFocusHandoff === handoff) { + pendingQuestionFocusHandoff = null; + } + }; + + handoff = { + schedule(focusableEle) { + if (!active || pendingQuestionFocusHandoff !== handoff || animationFrameId !== null) return; + + animationFrameId = ownerWindow.requestAnimationFrame(() => { + if (!active || pendingQuestionFocusHandoff !== handoff) return; + + // Remove the focusin listener before moving focus so the handoff + // does not interpret its own focus event as participant activity. + clear({ cancelFrame: false }); + focusAccessibleQuestionTarget(focusableEle); + }); + }, + cancel() { + clear(); + }, + }; + + pendingQuestionFocusHandoff = handoff; + QUESTION_FOCUS_CANCEL_EVENTS.forEach((eventName) => { + ownerDocument.addEventListener(eventName, handoff.cancel, true); + }); + + return handoff; +} + +/** + * Cancel any question-focus handoff left by the current render or transition. + */ +export function clearQuestionFocusHandoff() { + pendingQuestionFocusHandoff?.cancel(); +} + /** * Initialize the question text and focus management for screen readers. * This drives the screen reader's question announcement and focus when a question is loaded. - * Set the focus after a brief timeout to ensure the screen reader has time to process the new content. + * Schedule focus at the next rendering opportunity after question preparation completes. * @param {HTMLElement} fieldsetEle - The fieldset element containing the question text. * @param {Boolean} questionFocusSet - The flag to manage screen reader focus. + * @param {{schedule: (focusableEle: HTMLElement) => void, cancel: () => void} | null} [questionFocusHandoff] - The transition's cancellable focus handoff. * @returns {Boolean} - The updated questionFocusSet flag. */ -export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { +export function manageAccessibleQuestion(fieldsetEle, questionFocusSet, questionFocusHandoff) { if (fieldsetEle && !questionFocusSet) { // Build the question text and get the focusable element let focusableEle = buildQuestionText(fieldsetEle); - // Focus the hidden, focusable element + // Focus the hidden, programmatic target on the next animation frame. if (!moduleParams.isRenderer) { - setTimeout(() => { - focusAccessibleQuestionTarget(focusableEle); - }, QUESTION_TRANSITION_FOCUS_DELAY_MS); + const handoff = questionFocusHandoff ?? beginQuestionFocusHandoff(fieldsetEle.ownerDocument); + handoff?.schedule(focusableEle); } questionFocusSet = true; @@ -766,12 +833,19 @@ function createFocusableElement(fieldsetEle, focusNode) { * Restore question context after an unanswered-response modal closes. * Focus the question target after Bootstrap finishes hiding the modal. */ -export function closeModalAndFocusQuestion() { +export function closeModalAndFocusQuestion(event) { if (moduleParams.isRenderer) return; + if (event?.currentTarget?._questRenderDisposal) return; - // Retain the short modal-settle buffer. For a soft-modal continuation, the newly - // activated question is already in the DOM when Bootstrap's hidden event runs. - const activeQuestion = moduleParams.questDiv.querySelector('.question.active'); + const questDiv = moduleParams.questDiv; + // An obsolete modal can finish hiding after a sequential question render. + // Never let its lifecycle move focus inside the replacement Quest instance. + if (event?.currentTarget && !questDiv?.contains(event.currentTarget)) return; + + // For a soft-modal continuation, the newly activated question is already in + // the DOM when Bootstrap's hidden event runs. Its normal handoff is replaced + // here so the question receives focus only once. + const activeQuestion = questDiv?.querySelector('.question.active'); if (!activeQuestion) return; const accessibleQuestion = activeQuestion.querySelector('fieldset') || activeQuestion; @@ -781,9 +855,24 @@ export function closeModalAndFocusQuestion() { // final markup is available. if (!focusableEle) return; - setTimeout(() => { - focusAccessibleQuestionTarget(focusableEle); - }, MODAL_RETURN_FOCUS_DELAY_MS); + // Bootstrap has finished hiding the dialog before this event fires, and + // the question markup is already prepared. Cancel any transition handoff + // so no later task can pull focus away from the participant's next action. + clearQuestionFocusHandoff(); + + const ownerDocument = accessibleQuestion.ownerDocument; + const dismissedModal = event?.currentTarget; + const activeElement = ownerDocument.activeElement; + const focusStillBelongsToDismissal = !activeElement + || activeElement === ownerDocument.body + || activeElement === ownerDocument.documentElement + || dismissedModal?.contains(activeElement); + + // A participant, host, or future Bootstrap trigger may have already moved + // focus while the dialog was closing. Respect that newer focus decision. + if (!focusStillBelongsToDismissal) return; + + focusAccessibleQuestionTarget(focusableEle); } function scheduleSelectionAnnouncement(liveRegion, announcementText, delay) { diff --git a/common.js b/common.js index c67c5c2..74f4a81 100644 --- a/common.js +++ b/common.js @@ -33,15 +33,15 @@ export const progressBar = () => { export const responseRequestedModal = () => { return ` -