diff --git a/ActiveLogic.css b/ActiveLogic.css index 64f9e50..e83439b 100644 --- a/ActiveLogic.css +++ b/ActiveLogic.css @@ -36,6 +36,20 @@ clear: left; } +/* Native compound subgroup legends replace plain prompt blocks. Neutralize + Bootstrap's larger, floated legend styling so participant layout is stable. */ +.question .compound-radio-group > legend.compound-radio-group-legend { + float: none; + width: auto; + margin-bottom: 0; + font-size: inherit; + line-height: inherit; +} + +.question .compound-radio-group.compound-radio-group-first > legend.compound-radio-group-legend { + margin-bottom: 1.5rem; +} + /* this is an answer with a text area...*/ .freeresponse { display: flex; @@ -126,7 +140,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..dbf7df6 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; @@ -15,6 +19,20 @@ input[type="checkbox"] + label { clear: left; } +/* Native compound subgroup legends replace plain prompt blocks. Neutralize + Bootstrap's larger, floated legend styling so participant layout is stable. */ +.question .compound-radio-group > legend.compound-radio-group-legend { + float: none; + width: auto; + margin-bottom: 0; + font-size: inherit; + line-height: inherit; +} + +.question .compound-radio-group.compound-radio-group-first > legend.compound-radio-group-legend { + margin-bottom: 1.5rem; +} + /* CSS for grids */ .quest-grid.table-layout { width: 100%; @@ -59,7 +77,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..5a46484 100644 --- a/accessibleQuestionTextBuilder.js +++ b/accessibleQuestionTextBuilder.js @@ -1,28 +1,131 @@ 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, options?: {onInteractionCancel?: () => void}) => void, cancel: (event?: Event) => 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 interactionCancelHandler = null; + let wasCancelledByInteraction = false; + 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; + } + }; + + const notifyInteractionCancel = (handler = interactionCancelHandler) => { + interactionCancelHandler = null; + wasCancelledByInteraction = false; + handler?.(); + }; + + handoff = { + schedule(focusableEle, { onInteractionCancel } = {}) { + if (!active) { + if (wasCancelledByInteraction) notifyInteractionCancel(onInteractionCancel); + return; + } + if (pendingQuestionFocusHandoff !== handoff || animationFrameId !== null) return; + + interactionCancelHandler = onInteractionCancel; + + 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. + interactionCancelHandler = null; + clear({ cancelFrame: false }); + focusAccessibleQuestionTarget(focusableEle); + }); + }, + cancel(event) { + const cancelledByInteraction = Boolean(event?.type); + if (cancelledByInteraction) wasCancelledByInteraction = true; + const handler = cancelledByInteraction ? interactionCancelHandler : null; + clear(); + if (cancelledByInteraction && handler) notifyInteractionCancel(handler); + }, + }; + + 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, options?: {onInteractionCancel?: () => void}) => void, cancel: (event?: Event) => void} | null} [questionFocusHandoff] - The transition's cancellable focus handoff. + * @param {HTMLElement | null} [preferredFocusTarget] - Static feedback that should receive the transition focus instead of the generated question target. * @returns {Boolean} - The updated questionFocusSet flag. */ -export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { +export function manageAccessibleQuestion( + fieldsetEle, + questionFocusSet, + questionFocusHandoff, + preferredFocusTarget = null, +) { if (fieldsetEle && !questionFocusSet) { + const questionLiveRegion = moduleParams.questDiv?.querySelector('#ariaLiveQuestionAnnouncer'); + if (questionLiveRegion) questionLiveRegion.textContent = ''; + // Build the question text and get the focusable element let focusableEle = buildQuestionText(fieldsetEle); + const transitionFocusTarget = preferredFocusTarget?.isConnected + ? preferredFocusTarget + : focusableEle; - // 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(transitionFocusTarget, { + onInteractionCancel: preferredFocusTarget + ? () => announcePreferredFocusTarget(preferredFocusTarget) + : undefined, + }); } questionFocusSet = true; @@ -31,6 +134,26 @@ export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) { return questionFocusSet; } +function announcePreferredFocusTarget(preferredFocusTarget) { + const activeQuestion = preferredFocusTarget?.closest('form.question.active'); + const openModal = moduleParams.questDiv?.querySelector('.modal.show'); + if ( + !preferredFocusTarget?.isConnected + || !activeQuestion + || !moduleParams.questDiv?.contains(preferredFocusTarget) + || openModal + ) return; + + const announcementText = ( + preferredFocusTarget.innerText + || preferredFocusTarget.firstElementChild?.innerText + || preferredFocusTarget.textContent + || '' + ).replace(/\s+/g, ' ').trim(); + const liveRegion = moduleParams.questDiv.querySelector('#ariaLiveQuestionAnnouncer'); + if (liveRegion && announcementText) liveRegion.textContent = announcementText; +} + function focusAccessibleQuestionTarget(focusableEle) { // A response or submit dialog may open before a scheduled question-focus // handoff runs. Keep focus in the active modal instead of returning it to @@ -54,12 +177,14 @@ function focusAccessibleQuestionTarget(focusableEle) { function buildQuestionText(fieldsetEle) { let focusNode = null; let multiQuestionStartIndex = null; + const staticCompoundPlan = createStaticCompoundRadioPlan(fieldsetEle); + const staticCompoundFirstPrompt = staticCompoundPlan?.firstPrompt ?? null; // The conditions for building textContent (survey questions) for the screen reader. 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) => { @@ -76,6 +201,18 @@ function buildQuestionText(fieldsetEle) { for (let nodeIndex = 0; nodeIndex < childNodes.length; nodeIndex++) { const node = childNodes[nodeIndex]; + + // A static compound question has an overall instruction followed by a + // distinct prompt for each native radio subgroup. Stop before the + // first subgroup prompt so it does not become part of the outer + // fieldset's legend. The multi-question pass below will preserve it as + // the first subgroup's visible label. + if (node === staticCompoundFirstPrompt) { + focusNode = node; + multiQuestionStartIndex = nodeIndex; + break; + } + if (textNodeConditional(node)) { // Special
handling to retain spacing for top headings with question text below. if (node.tagName === 'B' && nodeIndex <= 1 && (nodeIndex === 0 || (childNodes[nodeIndex - 1].nodeType === Node.TEXT_NODE && !childNodes[nodeIndex - 1].textContent.trim()))) { @@ -161,7 +298,94 @@ 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, Boolean(staticCompoundPlan)); + return focusableEle; +} + +/** + * Validate the complete source structure for an unconditional compound-radio + * question before separating its first subgroup prompt from the outer legend. + * Each subgroup prompt must occupy its own source line immediately before the + * subgroup's first response. + * Conditional groups retain their existing non-reparenting ARIA path. + * @param {HTMLElement} fieldsetEle - The fieldset before question text is rebuilt. + * @returns {{firstPrompt: Node} | null} - The validated first subgroup boundary. + */ +function createStaticCompoundRadioPlan(fieldsetEle) { + if (fieldsetEle.querySelector('.displayif, [displayif]')) return null; + + const radioResponses = Array.from( + fieldsetEle.querySelectorAll(':scope > .response'), + ).map((response) => ({ + response, + input: response.querySelector(':scope > input[type="radio"][name]'), + })).filter(({ input }) => input); + if (new Set(radioResponses.map(({ input }) => input.name)).size <= 1) return null; + + 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] }); + } + }); + if (new Set(responseGroups.map(({ name }) => name)).size !== responseGroups.length) return null; + if (responseGroups.some(({ responses }) => responses.some(({ hidden, style }) => ( + hidden || style.display === 'none' + )))) return null; + + const promptNodeGroups = responseGroups.map(({ responses }) => ( + findStaticCompoundSourcePrompt(responses[0]) + )); + if (promptNodeGroups.some((promptNodes) => !promptNodes)) return null; + + const promptNodes = promptNodeGroups.flat(); + if (new Set(promptNodes).size !== promptNodes.length) return null; + const firstPrompt = promptNodeGroups[0][0]; + + // Do not split away the only available prompt. The outer fieldset must + // retain a non-empty legend in order to name the complete question. + for (let previous = firstPrompt.previousSibling; previous; previous = previous.previousSibling) { + if ( + previous.nodeType === Node.TEXT_NODE && previous.textContent.trim() !== '' + || previous.nodeType === Node.ELEMENT_NODE && previous.tagName !== 'BR' + ) return { firstPrompt }; + } + return null; +} + +function findStaticCompoundSourcePrompt(firstResponse) { + let node = firstResponse.previousSibling; + + // Ignore line endings and indentation immediately before the response. + while (node && ( + node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR' + || node.nodeType === Node.TEXT_NODE && node.textContent.trim() === '' + )) { + node = node.previousSibling; + } + + const promptNodes = []; + while (node) { + if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') break; + if (node.classList?.contains('response')) break; + if ( + node.nodeType !== Node.TEXT_NODE + && !(node.nodeType === Node.ELEMENT_NODE && ['U', 'B', 'I'].includes(node.tagName)) + ) return null; + if (node.nodeType !== Node.TEXT_NODE || node.textContent.trim() !== '') { + promptNodes.unshift(node); + } + node = node.previousSibling; + } + + return promptNodes.length > 0 && promptNodes.some(({ textContent }) => textContent.trim() !== '') + ? promptNodes + : null; } // Find additional questions (e.g. QoL multi-question surveys). @@ -179,8 +403,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 +465,221 @@ 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. + * @param {boolean} useNativeStaticGroups - Whether the complete static source structure was validated. + */ +function manageCompoundRadioGroups(fieldsetEle, useNativeStaticGroups = false) { + 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 }, groupIndex) => { + 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 (!useNativeStaticGroups) { + 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)); + return; + } + + const radioGroup = document.createElement('fieldset'); + radioGroup.classList.add('compound-radio-group'); + if (groupIndex === 0) radioGroup.classList.add('compound-radio-group-first'); + + const groupLegend = document.createElement('legend'); + groupLegend.classList.add('compound-radio-group-legend'); + groupLegend.id = labelId; + while (prompt.firstChild) { + groupLegend.appendChild(prompt.firstChild); + } + + radioGroup.appendChild(groupLegend); + fieldsetEle.insertBefore(radioGroup, prompt); + prompt.remove(); + 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. @@ -535,7 +974,7 @@ function createFocusableElement(fieldsetEle, focusNode) { border: 0; `; - if (focusNode && fieldsetEle.contains(focusNode)) { + if (focusNode && focusNode !== fieldsetEle && fieldsetEle.contains(focusNode)) { fieldsetEle.insertBefore(focusableEle, focusNode); } else { const legendEle = fieldsetEle.querySelector('legend'); @@ -568,12 +1007,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; + + 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; - // 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'); + // 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; @@ -583,14 +1029,48 @@ 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) { + // 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,19 +1084,16 @@ 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 responseText = label?.querySelector('.grid-label-response-text'); const input = cell?.querySelector('input[type="checkbox"], input[type="radio"]'); if (!liveRegion || !cell || !label || !input) { @@ -624,17 +1101,19 @@ export function updateAriaLiveSelectionAnnouncerTable(responseDiv) { } const actionText = input.checked ? 'Selected.' : 'Unselected.'; - const announcementText = `${label.textContent} ${actionText}`; + const announcementText = `${responseText?.textContent ?? 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..add7547 100644 --- a/buildGrid.js +++ b/buildGrid.js @@ -42,13 +42,14 @@ 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 += ``; }); - grid_html += ''; + grid_html += ''; // now lets handle each question... grid_obj.questions.forEach((question) => { @@ -59,17 +60,18 @@ function buildHtmlTable(grid_obj, gridButtonDiv) { // Start the row for the question, then add the row header (question text) grid_html += - ` + ``; // All selectable responses for a given question share the same 'name' attribute to link them as a group - // The label is used as a click target for the radio/checkbox input + // The label is used as a click target for the radio/checkbox input. Its + // hidden row context is populated after piped and conditional text resolves. grid_obj.responses.forEach((resp, resp_index) => { grid_html += ` - `; }); @@ -127,7 +129,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..56afecc 100644 --- a/common.js +++ b/common.js @@ -23,7 +23,7 @@ export const ariaLiveAnnouncementRegions = () => { export const progressBar = () => { return moduleParams.showProgressBarInQuest ? `
-
+
0% Complete
@@ -33,16 +33,16 @@ export const progressBar = () => { export const responseRequestedModal = () => { return ` -
${header_text}
${question_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..0ca0f5f 100644 --- a/tests/integration/eventHandlers.spec.js +++ b/tests/integration/eventHandlers.spec.js @@ -44,6 +44,26 @@ 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. +`; + +async function transitionToTextQuestion(quest) { + quest.root.querySelector('#Q1_1').click(); + quest.root.querySelector('#Q1 .next').click(); + await vi.advanceTimersByTimeAsync(0); + + expect(quest.root.querySelector('form.active')?.id).toBe('Q2'); + return { + focusTarget: quest.root.querySelector('#Q2 .screen-reader-focus'), + input: quest.root.querySelector('#Q2_TEXT'), + }; +} + describe('delegated runtime event handling', () => { afterEach(() => { vi.clearAllTimers(); @@ -77,9 +97,11 @@ describe('delegated runtime event handling', () => { input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); expect(quest.state.getActiveQuestionState().TEXT).toBe('focusout value'); expect(input.getAttribute('style')).toContain('size: 20'); + input.dataset.acceptedModalValue = input.value; form.querySelector('.reset').click(); expect(input.value).toBe(''); + expect(input.hasAttribute('data-accepted-modal-value')).toBe(false); expect(quest.state.getActiveQuestionState().TEXT).toBeUndefined(); }); @@ -117,6 +139,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 +205,119 @@ 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 checkbox-group validation semantics when its form is reset', async () => { + const quest = await renderFreshQuest({ markdown: TEXT_SURVEY }); + const form = quest.root.querySelector('#TEXT'); + form.dataset.minCount = '2'; + form.querySelector('fieldset').innerHTML = ` +
+
+
+ `; + const inputs = [...form.querySelectorAll('input')]; + const { validateInput } = await import('../../validate.js'); + const { resetChildren } = await import('../../eventHandlers.js'); + + validateInput(inputs[0]); + const error = form.querySelector('.validation-container'); + expect(error).not.toBeNull(); + inputs.forEach((input) => { + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(input.getAttribute('aria-describedby').split(/\s+/)).toContain(error.id); + }); + + resetChildren(form); + + expect(form.querySelector('.validation-container')).toBeNull(); + inputs.forEach((input) => { + expect(input.checked).toBe(false); + expect(input.hasAttribute('aria-invalid')).toBe(false); + expect(input.hasAttribute('aria-describedby')).toBe(false); + }); + }); + + it('clears stale validation when another choice clears an embedded number response', async () => { + const quest = await renderFreshQuest({ markdown: TEXT_SURVEY }); + const form = quest.root.querySelector('#TEXT'); + form.querySelector('fieldset').innerHTML = ` + +
+ +
+
+ +
+ `; + quest.state.setNumResponseInputs('TEXT', 2); + const other = form.querySelector('#OTHER'); + const none = form.querySelector('#NONE'); + const detail = form.querySelector('#DETAIL'); + + other.click(); + detail.value = '9'; + detail.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + const error = form.querySelector('.validation-container'); + expect(error).not.toBeNull(); + expect(detail.getAttribute('aria-invalid')).toBe('true'); + expect(detail.getAttribute('aria-describedby').split(/\s+/)).toEqual([ + 'detail-hint', + error.id, + ]); + + none.click(); + + expect(detail.value).toBe(''); + expect(form.querySelector('.validation-container')).toBeNull(); + expect(detail.hasAttribute('aria-invalid')).toBe(false); + expect(detail.getAttribute('aria-describedby')).toBe('detail-hint'); + expect(quest.state.getActiveQuestionState().TEXT).toEqual({ CHOICES: '2' }); + }); + + 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'); @@ -238,29 +385,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 () => { @@ -273,15 +433,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(); }); @@ -353,17 +515,245 @@ describe('delegated runtime event handling', () => { vi.useFakeTimers(); const quest = await renderFreshQuest(); vi.clearAllTimers(); + const nativeRequestAnimationFrame = window.requestAnimationFrame; + let scheduledFrameCount = 0; + window.requestAnimationFrame = (callback) => { + scheduledFrameCount += 1; + return nativeRequestAnimationFrame.call(window, callback); + }; + + let focusTarget; + try { + ({ focusTarget } = await transitionToTextQuestion(quest)); + expect(document.activeElement).not.toBe(focusTarget); + expect(scheduledFrameCount).toBe(1); + } finally { + window.requestAnimationFrame = nativeRequestAnimationFrame; + } + await vi.advanceTimersToNextTimerAsync(); + expect(document.activeElement).toBe(focusTarget); + }); - quest.root.querySelector('#Q1_1').click(); - quest.root.querySelector('#Q1 .next').click(); + it('never overrides rapid focus and typing in a newly rendered response', async () => { + vi.useFakeTimers(); + const quest = await renderFreshQuest(); + vi.clearAllTimers(); + + const { focusTarget, input } = await transitionToTextQuestion(quest); + input.focus(); + input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'A', code: 'KeyA' })); + input.value = 'A'; + input.dispatchEvent(new InputEvent('input', { + bubbles: true, + data: 'A', + inputType: 'insertText', + })); + + await vi.runAllTimersAsync(); + expect(document.activeElement).toBe(input); + expect(document.activeElement).not.toBe(focusTarget); + expect(input.value).toBe('A'); + }); + + it('does not override focus moved to a host control before the handoff', async () => { + vi.useFakeTimers(); + const quest = await renderFreshQuest(); + vi.clearAllTimers(); + + const { focusTarget } = await transitionToTextQuestion(quest); + const hostControl = document.querySelector('#afterQuest'); + hostControl.focus(); + + await vi.runAllTimersAsync(); + expect(document.activeElement).toBe(hostControl); + expect(document.activeElement).not.toBe(focusTarget); + }); + + it('honors host focus moved while asynchronous question content is loading', async () => { + vi.useFakeTimers(); + let finishAsyncLoad; + const fetchAsyncQuestion = vi.fn(() => new Promise((resolve) => { + finishAsyncLoad = resolve; + })); + const quest = await renderFreshQuest({ + params: { + asyncQuestionsMap: { + '[Q1?]': { func: 'loadQuestion', args: [] }, + }, + fetchAsyncQuestion, + }, + }); + const hostControl = document.querySelector('#afterQuest'); + + expect(fetchAsyncQuestion).toHaveBeenCalledOnce(); + hostControl.focus(); + finishAsyncLoad(); await vi.advanceTimersByTimeAsync(0); + await vi.runAllTimersAsync(); + + expect(quest.root.querySelector('#Q1 .screen-reader-focus')).not.toBeNull(); + expect(document.activeElement).toBe(hostControl); + }); + + it('focuses a terminal asynchronous error unless participant or host activity cancels the handoff', async () => { + vi.useFakeTimers(); + let rejectAsyncLoad; + const fetchAsyncQuestion = vi.fn(() => new Promise((_, reject) => { + rejectAsyncLoad = reject; + })); + const quest = await renderFreshQuest({ + params: { + asyncQuestionsMap: { + '[Q1?]': { func: 'loadQuestion', args: [] }, + }, + fetchAsyncQuestion, + }, + }); + + rejectAsyncLoad(new Error('Synthetic async failure')); + await vi.advanceTimersByTimeAsync(0); + await vi.runAllTimersAsync(); + + const error = quest.root.querySelector('#Q1 .validation-container'); + expect(error.firstElementChild.innerText).toContain('Error fetching question. Please go back and try again.'); + expect(error.tabIndex).toBe(-1); + expect(error.hasAttribute('role')).toBe(false); + expect(error.hasAttribute('aria-atomic')).toBe(false); + expect(document.activeElement).toBe(error); + expect(document.activeElement).not.toBe(quest.root.querySelector('#Q1 .screen-reader-focus')); + expect(quest.root.querySelector('#ariaLiveQuestionAnnouncer').textContent).toBe(''); + }); + + it('does not move focus to a terminal asynchronous error after host focus cancels the handoff', async () => { + vi.useFakeTimers(); + let rejectAsyncLoad; + const fetchAsyncQuestion = vi.fn(() => new Promise((_, reject) => { + rejectAsyncLoad = reject; + })); + const quest = await renderFreshQuest({ + params: { + asyncQuestionsMap: { + '[Q1?]': { func: 'loadQuestion', args: [] }, + }, + fetchAsyncQuestion, + }, + }); + const hostControl = document.querySelector('#afterQuest'); + + hostControl.focus(); + rejectAsyncLoad(new Error('Synthetic async failure')); + await vi.advanceTimersByTimeAsync(0); + await vi.runAllTimersAsync(); + + const error = quest.root.querySelector('#Q1 .validation-container'); + expect(error).not.toBeNull(); + expect(document.activeElement).toBe(hostControl); + expect(document.activeElement).not.toBe(error); + expect(quest.root.querySelector('#ariaLiveQuestionAnnouncer').textContent).toBe( + 'Error fetching question. Please go back and try again.', + ); + }); + + it('announces a terminal asynchronous error when interaction cancels its scheduled focus', async () => { + vi.useFakeTimers(); + let rejectAsyncLoad; + const fetchAsyncQuestion = vi.fn(() => new Promise((_, reject) => { + rejectAsyncLoad = reject; + })); + const quest = await renderFreshQuest({ + params: { + asyncQuestionsMap: { + '[Q1?]': { func: 'loadQuestion', args: [] }, + }, + fetchAsyncQuestion, + }, + }); + + rejectAsyncLoad(new Error('Synthetic async failure')); + await vi.advanceTimersByTimeAsync(0); + const error = quest.root.querySelector('#Q1 .validation-container'); + const hostControl = document.querySelector('#afterQuest'); + expect(error).not.toBeNull(); + expect(document.activeElement).not.toBe(error); + + hostControl.focus(); + await vi.runAllTimersAsync(); + + expect(document.activeElement).toBe(hostControl); + expect(quest.root.querySelector('#ariaLiveQuestionAnnouncer').textContent).toBe( + 'Error fetching question. Please go back and try again.', + ); + }); - expect(quest.root.querySelector('form.active')?.id).toBe('Q2'); - const focusTarget = quest.root.querySelector('#Q2 .screen-reader-focus'); - await vi.advanceTimersByTimeAsync(499); + it.each([ + ['participant keyboard activity', (quest) => quest.root.querySelector('#Q2 legend'), () => new KeyboardEvent('keydown', { bubbles: true, key: 'A', code: 'KeyA' })], + ['participant pointer activity', (quest) => quest.root.querySelector('#Q2 legend'), () => new PointerEvent('pointerdown', { bubbles: true })], + ['participant assistive-technology click', (quest) => quest.root.querySelector('#Q2 legend'), () => new MouseEvent('click', { bubbles: true, detail: 0 })], + ['host keyboard activity', () => document.querySelector('#afterQuest'), () => new KeyboardEvent('keydown', { bubbles: true, key: 'A', code: 'KeyA' })], + ['host pointer activity', () => document.querySelector('#afterQuest'), () => new PointerEvent('pointerdown', { bubbles: true })], + ['host programmatic click', () => document.querySelector('#afterQuest'), () => new MouseEvent('click', { bubbles: true, detail: 0 })], + ])('cancels the question-focus handoff after %s', async (_, eventTarget, createEvent) => { + vi.useFakeTimers(); + const quest = await renderFreshQuest(); + vi.clearAllTimers(); + + const { focusTarget } = await transitionToTextQuestion(quest); + eventTarget(quest).dispatchEvent(createEvent()); + + await vi.runAllTimersAsync(); expect(document.activeElement).not.toBe(focusTarget); - await vi.advanceTimersByTimeAsync(1); - expect(document.activeElement).toBe(focusTarget); + }); + + it('does not move focus behind an open response modal', async () => { + vi.useFakeTimers(); + const quest = await renderFreshQuest(); + vi.clearAllTimers(); + + const { focusTarget } = await transitionToTextQuestion(quest); + quest.root.querySelector('#softModal').classList.add('show'); + + await vi.advanceTimersToNextTimerAsync(); + expect(document.activeElement).not.toBe(focusTarget); + }); + + it('ignores a focus target disconnected before the handoff', async () => { + vi.useFakeTimers(); + const quest = await renderFreshQuest(); + vi.clearAllTimers(); + + const { focusTarget } = await transitionToTextQuestion(quest); + const focusSpy = vi.spyOn(focusTarget, 'focus'); + focusTarget.remove(); + + await vi.advanceTimersToNextTimerAsync(); + expect(focusTarget.isConnected).toBe(false); + expect(focusSpy).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(focusTarget); + }); + + it('does not restore an obsolete submit trigger during render-driven modal disposal', async () => { + const quest = await renderFreshQuest(); + 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 input = quest.root.querySelector('#Q2_TEXT'); + input.value = 'valid'; + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + quest.root.querySelector('#Q2 .next').click(); + await vi.waitFor(() => expect(quest.root.querySelector('form.active')?.id).toBe('END')); + + const submitTrigger = quest.root.querySelector('#END [data-click-type="submitSurvey"]'); + submitTrigger.click(); + const submitModal = quest.root.querySelector('#submitModal'); + expect(submitModal.classList).toContain('show'); + expect(document.activeElement).toBe(quest.root.querySelector('#submitModalBodyText')); + + const focusSpy = vi.spyOn(submitTrigger, 'focus'); + submitModal._questRenderDisposal = true; + globalThis.bootstrap.Modal.getInstance(submitModal).hide(); + + expect(focusSpy).not.toHaveBeenCalled(); }); it('keeps host controls outside the delegated event boundary unchanged', async () => { diff --git a/tests/integration/gridDeepCoverage.spec.js b/tests/integration/gridDeepCoverage.spec.js index db22cb0..cb567df 100644 --- a/tests/integration/gridDeepCoverage.spec.js +++ b/tests/integration/gridDeepCoverage.spec.js @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { renderFreshQuest } from '../helpers/questRuntime.js'; // This is the nine-row sleep grid from the locked production module2.txt -// corpus (commit 7ae99a22af325cf0e14be047a7636462db9bfd50). Keeping the authored +// corpus (commit 7ae99a22af325cf0e14be047a7636462db9bfd50). Keeping the source // IDs and response values makes the state-shape contract reviewable. const PRODUCTION_SLEEP_GRID_SURVEY = ` {"name":"TEST_PRODUCTION_GRID"} @@ -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([]); }); @@ -183,8 +195,16 @@ describe('deep grid state coverage', () => { const grid = quest.root.querySelector('#GRID_VALIDATION'); grid.querySelector('button.next').click(); - await vi.waitFor(() => expect(quest.root.querySelector(`#${modalId}`).classList.contains('show')).toBe(true)); - expect(quest.root.querySelector(`#${modalId} [role="alert"]`)).not.toBeNull(); + const modal = quest.root.querySelector(`#${modalId}`); + await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true)); + const descriptionId = modal.getAttribute('aria-describedby'); + const description = modal.querySelector(`#${descriptionId}`); + expect(description).not.toBeNull(); + expect(description.innerText.trim()).not.toBe(''); + expect(description.hasAttribute('role')).toBe(false); + expect(description.getAttribute('tabindex')).toBe('-1'); + expect(description).toBe(document.activeElement); + expect(modal.querySelector('[role="alert"]')).toBeNull(); expect(quest.root.querySelector('form.question.active')?.id).toBe('GRID_VALIDATION'); 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..6d44ecb 100644 --- a/tests/integration/mainRender.spec.js +++ b/tests/integration/mainRender.spec.js @@ -34,6 +34,82 @@ 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.each([ + ['en', 'Close'], + ['es', 'Cerrar'], + ])('gives every %s dialog resolvable relationships and a localized Close name', async (lang, closeName) => { + const quest = await renderFreshQuest({ params: { lang } }); + const modals = [ + '#softModal', + '#hardModal', + '#softModalResponse', + '#submitModal', + '#storeErrorModal', + ].map((selector) => quest.root.querySelector(selector)); + + modals.forEach((modal) => { + const labelledBy = modal.getAttribute('aria-labelledby'); + const describedBy = modal.getAttribute('aria-describedby'); + expect(labelledBy).toBeTruthy(); + expect(describedBy).toBeTruthy(); + expect(modal.querySelector(`#${labelledBy}`)).not.toBeNull(); + const description = modal.querySelector(`#${describedBy}`); + expect(description).not.toBeNull(); + expect(description.tagName).toBe('P'); + expect(description.tabIndex).toBe(-1); + expect(modal.querySelector('[role="alert"]')).toBeNull(); + expect(modal.querySelector('.btn-close').getAttribute('aria-label')).toBe(closeName); + }); + + const ids = [...quest.root.querySelectorAll('[id]')].map(({ id }) => id); + expect(new Set(ids).size).toBe(ids.length); + }); + + 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 +176,272 @@ 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)); + expect(document.activeElement).toBe(quest.root.querySelector('#storeErrorModalBody')); + + 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 +449,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 +481,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 +494,307 @@ 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.each([ + { + kind: 'requested', + marker: '?', + modalId: 'softModal', + bodyId: 'modalBodyText', + message: 'There is 1 question unanswered on this page. Would you like to continue?', + }, + { + kind: 'required', + marker: '!', + modalId: 'hardModal', + bodyId: 'hardModalBodyText', + message: 'There is 1 question unanswered on this page. Please answer the question.', + }, + ])('keeps a $kind-response modal scoped to a retained sequential-render root', async ({ + marker, + modalId, + bodyId, + message, + }) => { + const markdown = ` + {"name":"RETAINED_MODAL_ROOT"} + [Q1${marker}] Choose one answer. + (1) First answer + [END] Done. + `; + const quest = await renderFreshQuest({ markdown, rootId: 'firstRoot' }); + const firstRoot = quest.root; + const obsoleteModal = firstRoot.querySelector(`[id="${modalId}"]`); + obsoleteModal.querySelector(`[id="${bodyId}"]`).textContent = 'Obsolete modal body'; + + const secondRoot = document.createElement('div'); + secondRoot.id = 'secondRoot'; + document.body.append(secondRoot); + const rendered = await quest.transform.render({ + activate: true, + text: markdown, + store: vi.fn(async () => ({ code: 200 })), + errorLogger: () => {}, + }, 'secondRoot'); + + expect(rendered).toBe(true); + const currentQuestion = secondRoot.querySelector('form.question.active'); + expect(currentQuestion?.id).toBe('Q1'); + const currentModal = secondRoot.querySelector(`[id="${modalId}"]`); + const focusTarget = currentQuestion.querySelector('.screen-reader-focus'); + await vi.waitFor(() => expect(document.activeElement).toBe(focusTarget)); + + currentQuestion.querySelector('.next').click(); + + expect(obsoleteModal.classList).not.toContain('show'); + expect(obsoleteModal.querySelector(`[id="${bodyId}"]`).textContent).toBe('Obsolete modal body'); + expect(currentModal.classList).toContain('show'); + expect(currentModal.querySelector(`[id="${bodyId}"]`).innerText.replace(/\s+/g, ' ').trim()).toBe(message); + expect(document.activeElement).toBe(currentModal.querySelector(`[id="${bodyId}"]`)); + + const modalInstance = globalThis.bootstrap.Modal.getInstance(currentModal); + expect(modalInstance).not.toBeNull(); + modalInstance.hide(); + expect(document.activeElement).toBe(focusTarget); + + currentQuestion.querySelector('.next').click(); + expect(globalThis.bootstrap.Modal.getInstance(currentModal)).toBe(modalInstance); + modalInstance.hide(); + expect(document.activeElement).toBe(focusTarget); + }); + + 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 +841,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/authoredMarkupFidelity.spec.js b/tests/integration/markupFidelity.spec.js similarity index 97% rename from tests/integration/authoredMarkupFidelity.spec.js rename to tests/integration/markupFidelity.spec.js index 7b1c921..b9c81a1 100644 --- a/tests/integration/authoredMarkupFidelity.spec.js +++ b/tests/integration/markupFidelity.spec.js @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { renderFreshQuest } from '../helpers/questRuntime.js'; -const authoredMarkupSurvey = ` +const markupSurvey = ` {"name":"MARKUP_FIDELITY"} [SOURCE] Source response. @@ -11,7 +11,7 @@ const authoredMarkupSurvey = ` First production-style paragraph keeps italic detail and underlined detail. -Second production-style paragraph contains |displayif=doesNotEqual(SOURCE,"")|{$SOURCE_VALUE}| and includes |popup|more information|Help title|Authored popup detail|. +Second production-style paragraph contains |displayif=doesNotEqual(SOURCE,"")|{$SOURCE_VALUE}| and includes |popup|more information|Help title|Popup detail|. (1) Keep later response (2) Clear later response @@ -114,9 +114,9 @@ function updateSource(input, value) { input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); } -describe('authored question markup fidelity', () => { +describe('question markup fidelity', () => { it('keeps production-style rich markup stable through navigation and source edits', async () => { - const quest = await renderFreshQuest({ markdown: authoredMarkupSurvey }); + const quest = await renderFreshQuest({ markdown: markupSurvey }); const sourceInput = (await activeQuestion(quest, 'SOURCE')).querySelector('#SOURCE_VALUE'); updateSource(sourceInput, 'Original participant'); @@ -151,7 +151,7 @@ describe('authored question markup fidelity', () => { popover: { text: 'more information', title: 'Help title', - content: 'Authored popup detail', + content: 'Popup detail', role: 'button', tabindex: '0', trigger: 'manual', diff --git a/tests/integration/productionLoopCoverage.spec.js b/tests/integration/productionLoopCoverage.spec.js index df62e87..a42ccba 100644 --- a/tests/integration/productionLoopCoverage.spec.js +++ b/tests/integration/productionLoopCoverage.spec.js @@ -185,7 +185,7 @@ describe('locked production loop expansion and navigation', () => { expect(exactQuestionIndex(processor, `${vaccineLoop.firstId}_25_25`)).toBeGreaterThan(finalValidIndex); }); - it.each(LOOP_CASES)('$label routes an authored early exit to the next iteration', async (loop) => { + it.each(LOOP_CASES)('$label routes an early exit to the next iteration', async (loop) => { const { processor } = await createProcessor(lockedMarkdown(loop.file), { [loop.countId]: '2' }); prepareLoop(processor, loop.firstId); const earlyExitIndex = exactQuestionIndex(processor, `${loop.earlyExitId}_1_1`); diff --git a/tests/integration/questionProcessor.spec.js b/tests/integration/questionProcessor.spec.js index 22ac1d4..28e22b6 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,388 @@ 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')).toBeNull(); + expect(fallback.querySelector('#FALLBACK_NUMBER-desc')).toBeNull(); + expect(fallback.querySelector('#FALLBACK_DATE-desc').textContent).toBe(dateDescription); + expect(fallback.querySelector('#FALLBACK_MONTH-desc').textContent).toBe(monthDescription); + }); + + it.each([ + { + language: 'en', + fallbackName: 'Enter a value', + zeroDescription: 'Value must be greater than or equal to 0. Value must be less than or equal to 10', + minDescription: 'Value must be greater than or equal to 2', + maxDescription: 'Value must be less than or equal to 8', + }, + { + language: 'es', + fallbackName: 'Introduzca un valor', + zeroDescription: 'El valor debe ser mayor o igual a 0. El valor debe ser menor o igual a 10', + minDescription: 'El valor debe ser mayor o igual a 2', + maxDescription: 'El valor debe ser menor o igual a 8', + }, + ])('keeps number names and descriptions distinct in $language', async ({ + language, + fallbackName, + zeroDescription, + minDescription, + maxDescription, + }) => { + const { processor } = await createProcessor(` + {"name":"NUMBER_SEMANTICS_${language.toUpperCase()}"} + [NUMBERS?] Number semantics. + Zero minimum + Existing hint + Second hint + Fallback hint + |__|__|id=ZERO_VALUE min=0 max=10 aria-labelledby='ZERO_LABEL' aria-describedby='existing-number-hint ZERO_VALUE-desc existing-number-hint'| + |__|__|id=MIN_ONLY_VALUE min=2| + |__|__|id=MAX_ONLY_VALUE max=8| + |__|__|id=EXPLICIT_VALUE aria-label='Explicit number' aria-describedby='second-number-hint existing-number-hint second-number-hint'| + |__|__|id=REFERENCED_FALLBACK_VALUE aria-describedby='fallback-number-hint REFERENCED_FALLBACK_VALUE-desc'| + |__|__|id=FALLBACK_VALUE| + [END,end] Done. + `, {}, { language }); + const question = processById(processor, 'NUMBERS'); + const zero = question.querySelector('#ZERO_VALUE'); + const minOnly = question.querySelector('#MIN_ONLY_VALUE'); + const maxOnly = question.querySelector('#MAX_ONLY_VALUE'); + const explicit = question.querySelector('#EXPLICIT_VALUE'); + const referencedFallback = question.querySelector('#REFERENCED_FALLBACK_VALUE'); + const fallback = question.querySelector('#FALLBACK_VALUE'); + + expect(zero.getAttribute('aria-labelledby')).toBe('ZERO_LABEL'); + expect(zero.hasAttribute('aria-label')).toBe(false); + expect(zero.getAttribute('aria-describedby').split(/\s+/)).toEqual([ + 'existing-number-hint', + 'ZERO_VALUE-desc', + ]); + expect(zero.outerHTML.match(/aria-describedby=/g)).toHaveLength(1); + expect(question.querySelector('#ZERO_VALUE-desc').textContent).toBe(zeroDescription); + expect(zero.placeholder).toBe(fallbackName); + expect(zero.dataset.min).toBe('0'); + + expect(minOnly.getAttribute('aria-describedby')).toBe('MIN_ONLY_VALUE-desc'); + expect(question.querySelector('#MIN_ONLY_VALUE-desc').textContent).toBe(minDescription); + expect(maxOnly.getAttribute('aria-describedby')).toBe('MAX_ONLY_VALUE-desc'); + expect(question.querySelector('#MAX_ONLY_VALUE-desc').textContent).toBe(maxDescription); + + expect(explicit.getAttribute('aria-label')).toBe('Explicit number'); + expect(explicit.getAttribute('aria-describedby').split(/\s+/)).toEqual([ + 'second-number-hint', + 'existing-number-hint', + 'EXPLICIT_VALUE-desc', + ]); + expect(explicit.outerHTML.match(/aria-describedby=/g)).toHaveLength(1); + expect(question.querySelector('#EXPLICIT_VALUE-desc').textContent).toBe(fallbackName); + expect(explicit.getAttribute('aria-describedby').split(/\s+/).map( + (id) => question.querySelector(`#${id}`).textContent, + )).toEqual(['Second hint', 'Existing hint', fallbackName]); + expect(question.querySelectorAll('#EXPLICIT_VALUE-desc')).toHaveLength(1); + + expect(referencedFallback.getAttribute('aria-label')).toBe(fallbackName); + expect(referencedFallback.getAttribute('aria-describedby').split(/\s+/)).toEqual([ + 'fallback-number-hint', + 'REFERENCED_FALLBACK_VALUE-desc', + ]); + expect(referencedFallback.outerHTML.match(/aria-describedby=/g)).toHaveLength(1); + expect(question.querySelector('#REFERENCED_FALLBACK_VALUE-desc').textContent).toBe(fallbackName); + for (const id of referencedFallback.getAttribute('aria-describedby').split(/\s+/)) { + expect(question.querySelectorAll(`[id="${id}"]`), id).toHaveLength(1); + } + + expect(fallback.getAttribute('aria-label')).toBe(fallbackName); + expect(fallback.hasAttribute('aria-describedby')).toBe(false); + expect(question.querySelector('#FALLBACK_VALUE-desc')).toBeNull(); + + const numbers = Array.from(question.querySelectorAll('input[type="number"]')); + expect(numbers.map(({ id }) => id)).toEqual([ + 'ZERO_VALUE', + 'MIN_ONLY_VALUE', + 'MAX_ONLY_VALUE', + 'EXPLICIT_VALUE', + 'REFERENCED_FALLBACK_VALUE', + 'FALLBACK_VALUE', + ]); + expect(new Set(numbers.map(({ id }) => id)).size).toBe(numbers.length); + const descriptionIds = Array.from( + question.querySelectorAll('[id$="-desc"]'), + ({ id }) => id, + ); + expect(descriptionIds).toHaveLength(5); + expect(new Set(descriptionIds).size).toBe(descriptionIds.length); + }); + + it('preserves explicit accessible names and never mistakes metadata or choice markup for a name', async () => { + const { processor } = await createProcessor(` + {"name":"SCALAR_NAME_BOUNDARIES"} + [NUMBER?] Number. + |__|__|id=EXPLICIT_NUMBER min=1 max=2 aria-label='Explicit number'| + [DATE?] Explicit date + |date|id=EXPLICIT_DATE aria-labelledby='EXPLICIT_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('#EXPLICIT_NUMBER'); + expect(number.id).toBe('EXPLICIT_NUMBER'); + expect(number.getAttribute('aria-label')).toBe('Explicit number'); + expect(number.getAttribute('aria-describedby')).toBe('EXPLICIT_NUMBER-desc'); + + const date = processById(processor, 'DATE').querySelector('#EXPLICIT_DATE'); + expect(date.id).toBe('EXPLICIT_DATE'); + expect(date.hasAttribute('aria-label')).toBe(false); + expect(date.getAttribute('aria-labelledby')).toBe('EXPLICIT_DATE_LABEL'); + expect(date.getAttribute('aria-describedby')).toBe('EXPLICIT_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":"SCALAR_CHOICE_DELIMITERS"} + [QUESTA11YOPTION_0_END?] Scalar names. + |@|id=SCALAR_EMAIL aria-label='Contact option (1) [2] & more'| + |date|id=SCALAR_DATE aria-label='Appointment [2]'| + |time|id=SCALAR_TIME aria-label='Preferred time (3)'| + |__|__|id=SCALAR_NUMBER aria-label='Amount [4]'| + [END,end] Done. + `); + const question = processById(processor, 'QUESTA11YOPTION_0_END'); + + expect(question.querySelector('#SCALAR_EMAIL').getAttribute('aria-label')).toBe('Contact option (1) [2] & more'); + expect(question.querySelector('#SCALAR_DATE').getAttribute('aria-label')).toBe('Appointment [2]'); + expect(question.querySelector('#SCALAR_TIME').getAttribute('aria-label')).toBe('Preferred time (3)'); + expect(question.querySelector('#SCALAR_NUMBER').getAttribute('aria-label')).toBe('Amount [4]'); + expect(question.querySelector('#SCALAR_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="SCALAR_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 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 +641,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(); @@ -422,7 +803,7 @@ describe('QuestionProcessor loop runtime', () => { [END,end] Done. `; - it('continues to the next authored iteration, then exits when the response boundary changes', async () => { + it('continues to the next iteration, then exits when the response boundary changes', async () => { const { processor, moduleParams } = await createProcessor(LOOP_MARKDOWN, { D_100: '2' }); const firstLoopIndex = processor.questions.findIndex(({ questionIDExactSearch }) => questionIDExactSearch === 'ITEM_1_1'); processor.processQuestion(firstLoopIndex); @@ -505,7 +886,7 @@ describe('QuestionProcessor loop runtime', () => { expect(moduleParams.errorLogger).toHaveBeenCalledWith(expect.stringContaining('loop data not found')); }); - it('preserves authored loop metadata and English teen ordinals', async () => { + it('preserves loop metadata and English teen ordinals', async () => { const { processor } = await createProcessor(` {"name":"LOOP_ORDINALS"} [D_300?] Count |__|__|id=D_300| @@ -675,4 +1056,44 @@ describe('QuestionProcessor parser boundaries', () => { expect(processById(processor, 'DEFAULT_TEXTBOX').querySelector('#DEFAULT_TEXTBOX_text')).not.toBeNull(); expect(processById(processor, 'DEFAULT_TEXTAREA').querySelector('#DEFAULT_TEXTAREA_ta')).not.toBeNull(); }); + + it('parses production-sized pipe-rich prompts before number and linked-text controls', async () => { + const longConditionalLine = '|displayif=equals(D_TRIGGER,1)|home|'.repeat(150); + expect(`[LONG_NUMBER?] ${longConditionalLine}`.length).toBeGreaterThan(6_401); + + const { processor, moduleParams } = await createProcessor(` + {"name":"LONG_TEXT_INPUT_LINES"} + [LONG_NUMBER?] ${longConditionalLine} + Year moved out |__|__|__|__|id=LONG_NUMBER_VALUE min=1900 max=2030| + [LONG_TEXT?] ${longConditionalLine} + (807835037) Other: Please describe |__|id=LONG_TEXT_VALUE| + [END,end] Done. + `); + + expect(() => processor.processAllQuestions()).not.toThrow(); + + const longNumber = processById(processor, 'LONG_NUMBER'); + expect(longNumber.querySelectorAll('input')).toHaveLength(1); + expect(longNumber.querySelector('#LONG_NUMBER_VALUE')).toMatchObject({ + name: 'LONG_NUMBER', + type: 'number', + }); + expect(longNumber.querySelector('#LONG_NUMBER_VALUE').getAttribute('aria-label')) + .toBe('Year moved out'); + + const longText = processById(processor, 'LONG_TEXT'); + expect(longText.querySelectorAll('input[type="text"]')).toHaveLength(1); + expect(longText.querySelector('#LONG_TEXT_VALUE')).toMatchObject({ + name: 'LONG_TEXT', + type: 'text', + }); + expect(longText.querySelector('#LONG_TEXT_VALUE').getAttribute('aria-label')) + .toBe('Other: Please describe'); + expect(longText.querySelectorAll('input[type="radio"]')).toHaveLength(1); + expect(longText.querySelector('input[type="radio"]').labels).toHaveLength(1); + + expect(longNumber.innerHTML).not.toContain('|__|'); + expect(longText.innerHTML).not.toContain('|__|'); + expect(moduleParams.errorLogger).not.toHaveBeenCalled(); + }); }); diff --git a/tests/integration/questionnaireHelpers.spec.js b/tests/integration/questionnaireHelpers.spec.js index 71bb912..1025649 100644 --- a/tests/integration/questionnaireHelpers.spec.js +++ b/tests/integration/questionnaireHelpers.spec.js @@ -104,7 +104,7 @@ describe('questionnaire runtime helpers', () => { expect(concept.style.display).toBe('none'); }); - it('handles modal checks, radio-with-text selection, and active state from textbox input', async () => { + it('accepts only the confirmed response-modal value while preserving edit and dismissal prompts', async () => { const { quest, textboxinput } = await loadRuntime(); const form = quest.root.querySelector('#Q1'); form.querySelector('fieldset').innerHTML = ` @@ -117,23 +117,67 @@ describe('questionnaire runtime helpers', () => { const radio = form.querySelector('#OTHER'); const text = form.querySelector('#OTHER_TEXT'); text.value = '6'; + text.focus(); textboxinput(text); + const responseModal = quest.root.querySelector('#softModalResponse'); expect(radio.checked).toBe(true); - expect(quest.root.querySelector('#softModalResponse').classList).toContain('show'); + expect(responseModal.classList).toContain('show'); expect(quest.root.querySelector('#modalResponseBody').innerText).toBe('Confirm this value'); + expect(responseModal.getAttribute('aria-describedby')).toBe('modalResponseBody'); + expect(document.activeElement).toBe(quest.root.querySelector('#modalResponseBody')); expect(quest.state.getActiveQuestionState().Q1).toEqual({ Q1: '99', OTHER_TEXT: '6' }); + + responseModal.querySelector('#modalResponseContinueButton').click(); + globalThis.bootstrap.Modal.getInstance(responseModal).hide(); + expect(document.activeElement).toBe(text); + expect(text.dataset.acceptedModalValue).toBe('6'); + + textboxinput(text); + expect(responseModal.classList).not.toContain('show'); + expect(quest.state.getActiveQuestionState().Q1).toEqual({ Q1: '99', OTHER_TEXT: '6' }); + + text.value = '7'; + textboxinput(text); + expect(responseModal.classList).toContain('show'); + expect(text.hasAttribute('data-accepted-modal-value')).toBe(false); + responseModal.querySelector('#modalResponseCloseButton').click(); + globalThis.bootstrap.Modal.getInstance(responseModal).hide(); + expect(document.activeElement).toBe(text); + + textboxinput(text); + expect(responseModal.classList).toContain('show'); + responseModal.querySelector('.btn-close').click(); + globalThis.bootstrap.Modal.getInstance(responseModal).hide(); + textboxinput(text); + expect(responseModal.classList).toContain('show'); + globalThis.bootstrap.Modal.getInstance(responseModal).hide(); + textboxinput(text); + expect(responseModal.classList).toContain('show'); + expect(quest.state.getActiveQuestionState().Q1).toEqual({ Q1: '99', OTHER_TEXT: '7' }); + + const hostControl = document.createElement('button'); + document.body.appendChild(hostControl); + hostControl.focus(); + globalThis.bootstrap.Modal.getInstance(responseModal).hide(); + expect(document.activeElement).toBe(hostControl); + + textboxinput(text); + const inputFocus = vi.spyOn(text, 'focus'); + responseModal._questRenderDisposal = true; + globalThis.bootstrap.Modal.getInstance(responseModal).hide(); + expect(inputFocus).not.toHaveBeenCalled(); }); it('clears mutually exclusive checkbox and text siblings, including stale validation UI', async () => { - const { quest, handleXOR } = await loadRuntime(); + const { quest, handleXOR, moduleParams } = await loadRuntime(); + const { validationError } = await import('../../validate.js'); const form = quest.root.querySelector('#Q1'); form.querySelector('fieldset').innerHTML = `
- -
Old error
+
`; quest.state.setNumResponseInputs('Q1', 2); @@ -141,12 +185,16 @@ describe('questionnaire runtime helpers', () => { quest.state.setResponse('Q1', 'TEXT', 2, 'old'); const check = form.querySelector('#CHECK'); const text = form.querySelector('#TEXT'); - form.querySelector('.validation-container span').innerText = 'Old error'; + validationError(text, moduleParams.i18n.validationInputEmptyField); + const errorId = form.querySelector('.validation-container').id; + expect(text.getAttribute('aria-describedby').split(/\s+/)).toEqual(['existing-hint', errorId]); expect(handleXOR(check)).toBe('1'); expect(text.value).toBe(''); expect(text.classList).not.toContain('invalid'); expect(form.querySelector('.validation-container')).toBeNull(); + expect(text.hasAttribute('aria-invalid')).toBe(false); + expect(text.getAttribute('aria-describedby')).toBe('existing-hint'); expect(quest.state.getActiveQuestionState().Q1.TEXT).toBeUndefined(); text.value = ''; diff --git a/tests/integration/restoreResponses.spec.js b/tests/integration/restoreResponses.spec.js index eeacfc7..8c15602 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: ` @@ -68,9 +93,14 @@ describe('response restoration', () => { expect(quest.root.querySelector('#CHECK_GROUP_2').checked).toBe(true); expect(quest.root.querySelector('#RADIO_GROUP_8').checked).toBe(true); expect(quest.root.querySelector('#DETAIL').value).toBe('restored detail'); + expect(quest.state.getActiveQuestionState().MULTI).toEqual({ + CHECK_GROUP: ['1', '2'], + RADIO_GROUP: '8', + DETAIL: 'restored detail', + }); }); - it('restores XOR object values into authored XOR controls', async () => { + it('restores XOR object values into XOR controls', async () => { const quest = await renderFreshQuest({ markdown: ` {"name":"RESTORE_XOR"} @@ -119,6 +149,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/integration/validation.spec.js b/tests/integration/validation.spec.js index 2e58f48..0023f19 100644 --- a/tests/integration/validation.spec.js +++ b/tests/integration/validation.spec.js @@ -17,26 +17,88 @@ function appendInput({ type = 'text', value = '', attributes = {}, className = ' describe('validation through exported behavior', () => { let validateInput; + let validationError; + let clearValidationError; let moduleParams; beforeEach(async () => { ({ questionnaire: { moduleParams } } = await loadFreshModuleGraph()); - ({ validateInput } = await import('../../validate.js')); + ({ validateInput, validationError, clearValidationError } = await import('../../validate.js')); }); it('marks required empty values invalid and clears the message after correction', () => { - const { form, input } = appendInput({ type: 'text', attributes: { 'data-required': true } }); + const { form, input } = appendInput({ + type: 'text', + attributes: { + 'data-required': true, + 'aria-describedby': 'existing-hint extra-hint', + }, + }); + input.insertAdjacentHTML('beforebegin', ''); validateInput(input); + const error = input.nextElementSibling; expect(input.classList).toContain('invalid'); expect(form.classList).toContain('invalid'); - expect(input.nextElementSibling.firstElementChild.innerText).toContain('Please fill out this field'); + expect(error.firstElementChild.innerText).toContain('Please fill out this field'); + expect(error.getAttribute('role')).toBe('alert'); + expect(error.getAttribute('aria-atomic')).toBe('true'); + expect(error.id).toMatch(/^quest-validation-error-/); + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(input.getAttribute('aria-describedby').split(/\s+/)).toEqual([ + 'existing-hint', + 'extra-hint', + error.id, + ]); input.value = 'valid'; validateInput(input); expect(input.classList).not.toContain('invalid'); expect(form.classList).not.toContain('invalid'); expect(input.nextElementSibling).toBeNull(); + expect(input.hasAttribute('aria-invalid')).toBe(false); + expect(input.getAttribute('aria-describedby')).toBe('existing-hint extra-hint'); + }); + + it('updates one live error relationship and restores the existing invalid state exactly', () => { + const { input } = appendInput({ + attributes: { + 'aria-describedby': 'existing-hint', + 'aria-invalid': 'grammar', + }, + }); + + validationError(input, moduleParams.i18n.validationInputEmptyField); + const error = input.nextElementSibling; + validationError(input, moduleParams.i18n.validationInputEmptyField); + + expect(input.parentElement.querySelectorAll('.validation-container')).toHaveLength(1); + expect(input.getAttribute('aria-describedby').split(/\s+/)).toEqual(['existing-hint', error.id]); + expect(input.getAttribute('aria-invalid')).toBe('true'); + + clearValidationError(input); + expect(input.getAttribute('aria-describedby')).toBe('existing-hint'); + expect(input.getAttribute('aria-invalid')).toBe('grammar'); + }); + + it('can return a static programmatic error target without live-region semantics', () => { + const { input } = appendInput(); + + const error = validationError( + input, + moduleParams.i18n.validationInputEmptyField, + [input], + { liveRegion: false }, + ); + error.tabIndex = -1; + + expect(error).toBe(input.nextElementSibling); + expect(error.firstElementChild.innerText).toContain(moduleParams.i18n.validationInputEmptyField); + expect(error.tabIndex).toBe(-1); + expect(error.hasAttribute('role')).toBe(false); + expect(error.hasAttribute('aria-atomic')).toBe(false); + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(input.getAttribute('aria-describedby').split(/\s+/)).toContain(error.id); }); it.each([ @@ -114,7 +176,7 @@ describe('validation through exported behavior', () => { expect(input.nextElementSibling.firstElementChild.innerText).toContain(message); }); - it('accepts a date inside its authored boundaries', () => { + it('accepts a date inside its configured boundaries', () => { const { input } = appendInput({ type: 'date', value: '2026-06-15', @@ -147,7 +209,8 @@ describe('validation through exported behavior', () => { form.dataset.minCount = '2'; form.dataset.maxCount = '2'; form.innerHTML = ` -
+ +
`; @@ -156,15 +219,29 @@ describe('validation through exported behavior', () => { const lastResponse = form.lastElementChild; validateInput(inputs[0]); - expect(lastResponse.nextElementSibling.firstElementChild.innerText).toContain('selected 1'); + const minimumError = lastResponse.nextElementSibling; + expect(minimumError.firstElementChild.innerText).toContain('selected 1'); + inputs.forEach((input) => { + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(input.getAttribute('aria-describedby').split(/\s+/)).toContain(minimumError.id); + }); inputs[1].checked = true; validateInput(inputs[1]); expect(lastResponse.classList).not.toContain('invalid'); + inputs.forEach((input) => expect(input.hasAttribute('aria-invalid')).toBe(false)); + expect(inputs[0].getAttribute('aria-describedby')).toBe('checkbox-hint'); + expect(inputs[1].hasAttribute('aria-describedby')).toBe(false); + expect(inputs[2].hasAttribute('aria-describedby')).toBe(false); inputs[2].checked = true; validateInput(inputs[2]); - expect(lastResponse.nextElementSibling.firstElementChild.innerText).toContain('selected 3'); + const maximumError = lastResponse.nextElementSibling; + expect(maximumError.firstElementChild.innerText).toContain('selected 3'); + inputs.forEach((input) => { + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(input.getAttribute('aria-describedby').split(/\s+/)).toContain(maximumError.id); + }); }); it('adds mismatch errors to both confirmation fields and clears both after correction', () => { @@ -182,11 +259,16 @@ describe('validation through exported behavior', () => { expect(original.classList).toContain('invalid'); expect(confirmation.classList).toContain('invalid'); expect(confirmation.nextElementSibling.firstElementChild.innerText).toContain('do not match'); + expect(original.getAttribute('aria-invalid')).toBe('true'); + expect(confirmation.getAttribute('aria-invalid')).toBe('true'); + expect(original.getAttribute('aria-describedby')).not.toBe(confirmation.getAttribute('aria-describedby')); confirmation.value = 'first'; validateInput(confirmation); expect(original.classList).not.toContain('invalid'); expect(confirmation.classList).not.toContain('invalid'); + expect(original.hasAttribute('aria-invalid')).toBe(false); + expect(confirmation.hasAttribute('aria-invalid')).toBe(false); }); it('intentionally skips validation for native radio and time controls', () => { diff --git a/tests/knownDefects/registry.js b/tests/knownDefects/registry.js index df34162..f1b20a8 100644 --- a/tests/knownDefects/registry.js +++ b/tests/knownDefects/registry.js @@ -21,80 +21,47 @@ function defect(localDefectId, target, reason, extra = {}) { */ 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.', { + treePrune: defect('QD-TREE-002', 'Tree.prune branch removal', 'Pruning does not return to the predecessor with the expected sibling structure intact.'), + treeHasNext: defect('QD-TREE-003', 'Tree.hasNext non-mutating lookahead', 'Lookahead dereferences a nonexistent nextNode property and throws instead of reporting whether a next value exists.'), + 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 written.', { 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 alternatives the same response/DOM ID and different XOR groups, so one value can overwrite or restore as the other concept.', { + 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', - }), + 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 specified name.'), }); 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.', { + imageAlt: defect('QD-AXE-006', 'Question image alternative-text decision', 'QuestionProcessor emits an image without an alt attribute, so questionnaire content cannot explicitly identify it as informative or decorative.', { + ruleId: 'image-alt', + impact: 'critical', + targets: ['#PLAIN img'], + corpusCommit: '7ae99a22af325cf0e14be047a7636462db9bfd50', + corpusOccurrences: 84, sourcePaths: [ - 'prod/moduleDietScreener', - 'prod/moduleDietScreenerSpanish.txt', - 'prod/moduleQoL.txt', - 'prod/moduleQoLSpanish.txt', + 'prod/module1.txt', + 'prod/module1Spanish.txt', + 'prod/module3.txt', + 'prod/module3Spanish.txt', + 'prod/module4.txt', + 'prod/module4Spanish.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..1bc413f 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()); @@ -127,124 +107,81 @@ describe('characterized Quest runtime defects', () => { expect(tree.rootNode.children.map(({ value }) => value)).toEqual(['Q1', 'Q2']); }); - it.fails(`${runtimeDefects.treeHasNext.localDefectId}: reports lookahead without mutation`, () => { + it.fails(`${runtimeDefects.treeHasNext.localDefectId}: reports lookahead without throwing or mutation`, () => { const tree = new Tree(); tree.add('Q1'); + expect(() => tree.hasNext()).not.toThrow(); expect(tree.hasNext()).toBe(true); 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 sourceCondition = lockedCovidGridCondition(locale); + const expectedCondition = `someSelected("${expectedCovidGridResponseIds.join('","')}")`; + expect(sourceCondition).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 alternatives use distinct 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 () => { @@ -307,7 +244,7 @@ describe('characterized Quest runtime defects', () => { )).toBe(true); }); - it.fails(`${runtimeDefects.explicitCombinedChoiceName.localDefectId}: preserves an authored combined-choice name exactly`, async () => { + it.fails(`${runtimeDefects.explicitCombinedChoiceName.localDefectId}: preserves a specified combined-choice name exactly`, async () => { const { processor } = await loadQuestionProcessor(` {"name":"EXPLICIT_COMBINED_NAME"} [Q1?] Other response. diff --git a/tests/setup/jsdom.js b/tests/setup/jsdom.js index 2ce7b3f..0036eb2 100644 --- a/tests/setup/jsdom.js +++ b/tests/setup/jsdom.js @@ -93,6 +93,25 @@ beforeEach(() => { document.documentElement.lang = 'en'; document.body.innerHTML = ''; + let nextAnimationFrameId = 1; + const animationFrameTimers = new Map(); + vi.stubGlobal('requestAnimationFrame', vi.fn((callback) => { + const animationFrameId = nextAnimationFrameId++; + const timerId = window.setTimeout(() => { + animationFrameTimers.delete(animationFrameId); + callback(window.performance.now()); + }, 16); + animationFrameTimers.set(animationFrameId, timerId); + return animationFrameId; + })); + vi.stubGlobal('cancelAnimationFrame', vi.fn((animationFrameId) => { + const timerId = animationFrameTimers.get(animationFrameId); + if (timerId === undefined) return; + + window.clearTimeout(timerId); + animationFrameTimers.delete(animationFrameId); + })); + globalThis.bootstrap = { Modal: class ModalStub extends BootstrapComponentStub { static instances = new WeakMap(); @@ -101,6 +120,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(); + } }, }; @@ -130,6 +203,9 @@ afterEach(() => { unexpectedNetworkAttempts, 'jsdom attempted network access without an explicit per-test host-boundary stub', ).toEqual([]); + // Cancel a pending focus handoff so its document listeners do not outlive + // the test when fake timers are cleared below. + document.dispatchEvent(new Event('pointerdown')); vi.clearAllTimers(); vi.useRealTimers(); vi.unstubAllGlobals(); diff --git a/tests/unit/buildGrid.spec.js b/tests/unit/buildGrid.spec.js index dcd91a9..a3e8c54 100644 --- a/tests/unit/buildGrid.spec.js +++ b/tests/unit/buildGrid.spec.js @@ -20,11 +20,32 @@ 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.querySelectorAll('table.quest-grid [role]')).toHaveLength(0); expect(form.querySelector('#ROW1_0').value).toBe('1'); + expect(form.querySelector('#ROW1_0').hasAttribute('aria-labelledby')).toBe(false); + expect(form.querySelector('#ROW2_1').hasAttribute('aria-labelledby')).toBe(false); + expect(form.querySelector('#ROW1_0').labels).toHaveLength(1); + expect(form.querySelector('#ROW1_0').labels[0].id).toBe('labelROW1_0'); + expect(form.querySelector('#qtextROW1').textContent).toContain('First'); + expect(form.querySelector('#labelROW1_0 .grid-label-row-context').textContent).toBe(''); + expect( + form.querySelector('#labelROW1_0 .grid-label-row-context').classList.contains('visually-hidden'), + ).toBe(true); + expect(form.querySelector('#labelROW1_0 .grid-label-response-text').textContent).toBe('Yes'); + expect(form.querySelector('#labelROW1_0').textContent).toBe('Yes'); + expect(form.querySelector('#labelROW1_0 [data-gridreplace="firstName"]')).toBeNull(); 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,9 +63,15 @@ 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').hasAttribute('aria-labelledby')).toBe(false); + expect(form.querySelector('#ROW_1').hasAttribute('aria-labelledby')).toBe(false); + expect(form.querySelector('#ROW_0').labels).toHaveLength(1); + expect(form.querySelector('#labelROW_0 .grid-label-row-context').textContent).toBe(''); + expect(form.querySelector('#labelROW_0 .grid-label-response-text').textContent).toBe('Alpha'); + expect(form.querySelector('#labelROW_1').textContent).toBe('Beta'); }); - it('uses a plain prompt when no edit marker is authored', () => { + it('uses a plain prompt when no edit marker is present', () => { const html = parseGrid('|grid|id=PLAIN|Question|[ROW]Text;|(1:One)|', buttons); const template = document.createElement('template'); template.innerHTML = html; @@ -63,4 +90,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 condition = 'valueOrDefault("AGE","DEFAULT")>=18 and someSelected("ROW_1","ROW_2")'; + const html = parseGrid( + `|grid|id=CONDITIONAL|Question|[ROW,displayif=${condition}]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(condition)); + expect(decodeURIComponent(serializedCondition)).toBe(condition); + expect(serializedCondition).not.toContain('%25'); + }); }); diff --git a/tests/unit/customMath.spec.js b/tests/unit/customMath.spec.js index bb350e7..fdb0293 100644 --- a/tests/unit/customMath.spec.js +++ b/tests/unit/customMath.spec.js @@ -97,7 +97,7 @@ describe('Quest MathJS extensions', () => { const { evaluateCondition } = await import('../../evaluateConditions.js'); // Quoted IDs stay in the MathJS path and use scalar coercion. Legacy - // authored expressions use a bare response ID. MathJS rejects that symbol + // Legacy expressions use a bare response ID. MathJS rejects that symbol // and Quest's fallback evaluator correctly treats equals as membership. expect(fn.valueEquals('CHECKS', 1)).toBe(false); expect(fn.equals('CHECKS', 3)).toBe(false); @@ -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); @@ -220,7 +228,7 @@ describe('Quest MathJS extensions', () => { expect(fn.selectionCount('SCALAR')).toBe(0); }); - it('registers custom functions at the top level for authored MathJS expressions', async () => { + it('registers custom functions at the top level for MathJS expressions', async () => { const { initializeCustomMathJSFunctions, math, customMathJSFunctions: fn } = await loadMathWithState({ ANSWER: '1' }); initializeCustomMathJSFunctions(); diff --git a/tests/unit/stateManager.spec.js b/tests/unit/stateManager.spec.js index e7fec17..500cde2 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({}); }); @@ -361,7 +449,7 @@ describe('state manager', () => { expect(await manager.submitSurvey()).toBeUndefined(); }); - it('logs invalid store rollback click types and automatically hides the recovery modal', async () => { + it('logs invalid store rollback click types and keeps the recovery modal open until dismissal', async () => { vi.useFakeTimers(); document.body.innerHTML += '
'; const store = vi.fn(async () => ({ code: 503 })); @@ -373,7 +461,10 @@ describe('state manager', () => { manager.syncToStore(button); await vi.waitFor(() => expect(moduleParams.errorLogger).toHaveBeenCalledWith('Invalid click type (handleStoreError):', 'unknown')); expect(document.querySelector('#storeErrorModal').classList).toContain('show'); - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(6000); + expect(document.querySelector('#storeErrorModal').classList).toContain('show'); + + globalThis.bootstrap.Modal.getInstance(document.querySelector('#storeErrorModal')).hide(); expect(document.querySelector('#storeErrorModal').classList).not.toContain('show'); }); diff --git a/validate.js b/validate.js index dd4525a..ba177b6 100644 --- a/validate.js +++ b/validate.js @@ -2,6 +2,82 @@ import { callExchangeValues, moduleParams } from "./questionnaire.js"; import { translate } from "./common.js"; import { math } from './customMathJSImplementation.js'; +let validationErrorSequence = 0; +const validationAssociations = new WeakMap(); + +function nextValidationErrorId(ownerDocument) { + let errorId; + do { + validationErrorSequence += 1; + errorId = `quest-validation-error-${validationErrorSequence}`; + } while (ownerDocument.getElementById(errorId)); + return errorId; +} + +function descriptionTokens(element) { + return (element.getAttribute('aria-describedby') ?? '') + .split(/\s+/) + .filter(Boolean); +} + +function addDescriptionToken(element, token) { + const tokens = new Set(descriptionTokens(element)); + tokens.add(token); + element.setAttribute('aria-describedby', [...tokens].join(' ')); +} + +function removeDescriptionToken(element, token) { + const tokens = descriptionTokens(element).filter((current) => current !== token); + if (tokens.length > 0) { + element.setAttribute('aria-describedby', tokens.join(' ')); + } else { + element.removeAttribute('aria-describedby'); + } +} + +function restoreValidationTarget(target, errorId, priorAriaInvalid) { + removeDescriptionToken(target, errorId); + if (priorAriaInvalid === null) { + target.removeAttribute('aria-invalid'); + } else { + target.setAttribute('aria-invalid', priorAriaInvalid); + } +} + +function associateValidationError(errorElement, relatedTargets) { + const targets = [...new Set(relatedTargets)] + .filter((target) => target?.matches?.('input, select, textarea')); + let metadata = validationAssociations.get(errorElement); + if (!metadata) { + metadata = { targets: new Map() }; + validationAssociations.set(errorElement, metadata); + } + + const nextTargets = new Set(targets); + metadata.targets.forEach((priorAriaInvalid, target) => { + if (!nextTargets.has(target)) { + restoreValidationTarget(target, errorElement.id, priorAriaInvalid); + metadata.targets.delete(target); + } + }); + + targets.forEach((target) => { + if (!metadata.targets.has(target)) { + metadata.targets.set(target, target.getAttribute('aria-invalid')); + } + target.setAttribute('aria-invalid', 'true'); + addDescriptionToken(target, errorElement.id); + }); +} + +function clearValidationAssociations(errorElement) { + const metadata = validationAssociations.get(errorElement); + metadata?.targets.forEach((priorAriaInvalid, target) => { + restoreValidationTarget(target, errorElement.id, priorAriaInvalid); + }); + validationAssociations.delete(errorElement); +} + export function validateInput(inputElement) { let handlers = { @@ -40,13 +116,22 @@ export function clearValidationError(inputElement) { inputElement.nextElementSibling?.classList.contains('validation-container')) { let errDiv = inputElement.nextElementSibling; + const form = inputElement.closest("form"); + clearValidationAssociations(errDiv); errDiv.parentNode.removeChild(errDiv) inputElement.classList.remove("invalid"); - inputElement.closest("form").classList.remove("invalid"); + if (!form?.querySelector('.validation-container')) { + form?.classList.remove("invalid"); + } } } -export function validationError(inputElement, errorMsg) { +export function validationError( + inputElement, + errorMsg, + relatedTargets = [inputElement], + { liveRegion = true } = {}, +) { let errSpan = null let errDiv = null; @@ -54,7 +139,7 @@ export function validationError(inputElement, errorMsg) { // or create a new one... if (inputElement && inputElement.nextElementSibling?.classList.contains('validation-container')) { errDiv = inputElement.nextElementSibling; - errSpan = inputElement.nextElementSibling.firstChild; + errSpan = errDiv.firstElementChild; } else { errDiv = document.createElement("div") errDiv.classList.add('validation-container'); @@ -63,15 +148,26 @@ 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); } + if (!errDiv.id) { + errDiv.id = nextValidationErrorId(inputElement.ownerDocument); + } + if (liveRegion) { + errDiv.setAttribute('role', 'alert'); + errDiv.setAttribute('aria-atomic', 'true'); + } else { + errDiv.removeAttribute('role'); + errDiv.removeAttribute('aria-atomic'); + } + associateValidationError(errDiv, relatedTargets); + errSpan.innerText = errorMsg inputElement.classList.add("invalid"); - inputElement.closest("form").classList.add("invalid"); + inputElement.closest("form")?.classList.add("invalid"); + return errDiv; } function validate_number(inputElement) { @@ -260,15 +356,14 @@ function validate_count(inputElement){ let maxCount = inputElement.form.dataset.maxCount; let selectedCount = inputElement.form.querySelectorAll(`[name=${inputElement.name}]:checked`).length; - let lastElement = inputElement.form.querySelectorAll(`[name=${inputElement.name}]`); - - lastElement = lastElement.item(lastElement.length - 1).closest(".response"); + const relatedInputs = [...inputElement.form.querySelectorAll(`[name=${inputElement.name}]`)]; + const lastElement = relatedInputs.at(-1).closest(".response"); if (hasMin && selectedCount < minCount) { - validationError(lastElement, translate("validationCountMore", [selectedCount, minCount])); + validationError(lastElement, translate("validationCountMore", [selectedCount, minCount]), relatedInputs); } else if (hasMax && selectedCount > maxCount) { - validationError(lastElement, translate("validationCountLess", [selectedCount, maxCount])); + validationError(lastElement, translate("validationCountLess", [selectedCount, maxCount]), relatedInputs); } else { clearValidationError(lastElement)