From 02fdcd140ba457a148741d6e889ae81d25c6c673 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 22 Mar 2026 14:09:04 -0700 Subject: [PATCH 1/5] Add QTI test infrastructure Spec and implementation plan for fixture-driven acceptance tests. Shared renderAssessmentItem helper that renders AssessmentItem with parsed fixture XML and mocked injectables (handlers, QTI_CONTEXT, answerState, interactive). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../frontend/components/__tests__/helpers.js | 50 +++++++++++++++++++ kolibri/plugins/qti_viewer/package.json | 3 ++ pnpm-lock.yaml | 4 ++ 3 files changed, 57 insertions(+) create mode 100644 kolibri/plugins/qti_viewer/frontend/components/__tests__/helpers.js diff --git a/kolibri/plugins/qti_viewer/frontend/components/__tests__/helpers.js b/kolibri/plugins/qti_viewer/frontend/components/__tests__/helpers.js new file mode 100644 index 00000000000..02468991a7b --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/__tests__/helpers.js @@ -0,0 +1,50 @@ +/* global jest */ +import { render } from '@testing-library/vue'; +import { computed, ref } from 'vue'; +import { parseXML } from '../../utils/xml'; +import AssessmentItem from '../AssessmentItem.vue'; + +export function renderAssessmentItem(xml, options = {}) { + const xmlDoc = parseXML(xml); + const answerStateRef = ref(options.answerState || {}); + const interactiveRef = ref(options.interactive !== undefined ? options.interactive : true); + + let _checkAnswer = () => { + throw new Error('No AssessmentItem has registered a checkAnswer handler'); + }; + + const interactionFn = jest.fn(); + + const renderResult = render(AssessmentItem, { + props: { xmlDoc }, + provide: { + handlers: { + interaction: interactionFn, + registerCheckAnswer: fn => { + _checkAnswer = fn; + }, + }, + QTI_CONTEXT: computed(() => ({ + candidateIdentifier: options.candidateIdentifier || 'test-candidate-001', + testIdentifier: 'test-assessment', + environmentIdentifier: 'test-env', + })), + answerState: computed(() => answerStateRef.value), + interactive: computed(() => interactiveRef.value), + }, + }); + + return { + ...renderResult, + setAnswerState(state) { + answerStateRef.value = state; + }, + setInteractive(value) { + interactiveRef.value = value; + }, + checkAnswer() { + return _checkAnswer(); + }, + interactionFn, + }; +} diff --git a/kolibri/plugins/qti_viewer/package.json b/kolibri/plugins/qti_viewer/package.json index b79464c7d08..25f5a266d8a 100644 --- a/kolibri/plugins/qti_viewer/package.json +++ b/kolibri/plugins/qti_viewer/package.json @@ -13,5 +13,8 @@ "kolibri-zip": "workspace:*", "lodash": "catalog:", "vue": "catalog:" + }, + "devDependencies": { + "@testing-library/vue": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a80eb473d8..4e1313b1220 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -860,6 +860,10 @@ importers: vue: specifier: 'catalog:' version: 2.7.16 + devDependencies: + '@testing-library/vue': + specifier: 'catalog:' + version: 5.9.0(vue-template-compiler@2.7.16)(vue@2.7.16) kolibri/plugins/safe_html5_viewer: dependencies: From 758713f1bfc51b55b68b26d209a9ac18ab129b46 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 22 Mar 2026 14:09:14 -0700 Subject: [PATCH 2/5] Add acceptance tests for ChoiceInteraction 20 behavioral fixtures covering single/multi select, maxChoices enforcement, orientation, stacking, response processing templates, shuffle, and SBAC items. Tests selection, deselection, keyboard interaction, ARIA semantics, and answer state round-tripping. 182 tests total (with TextEntry). 69 fail pending PR #14374 (role=listbox, aria-multiselectable, maxChoices, shuffle.value). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../interactions/ChoiceInteraction.vue | 2 +- .../components/interactions/SimpleChoice.vue | 2 +- .../__tests__/ChoiceInteraction.spec.js | 231 ++++++++++++++++++ 3 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/ChoiceInteraction.spec.js diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/ChoiceInteraction.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/ChoiceInteraction.vue index 78b39bb8c94..337c0c9a3a4 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/interactions/ChoiceInteraction.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/ChoiceInteraction.vue @@ -148,7 +148,7 @@ attrs: { role: 'listbox', 'aria-label': choiceListLabel$(), - 'aria-multiselectable': multiSelectable.value, + 'aria-multiselectable': String(multiSelectable.value), }, class: [attrs.class || '', 'qti-choice-interaction'], }, diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleChoice.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleChoice.vue index b18b45402ff..a4a27992221 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleChoice.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleChoice.vue @@ -12,7 +12,7 @@ ':focus': coreOutline, }), ]" - :aria-selected="selected" + :aria-selected="String(selected)" :style="[extraStyles]" @click="handleClick" @keydown.enter="handleClick" diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/ChoiceInteraction.spec.js b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/ChoiceInteraction.spec.js new file mode 100644 index 00000000000..c1a89ad1f5b --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/ChoiceInteraction.spec.js @@ -0,0 +1,231 @@ +import { fireEvent, screen } from '@testing-library/vue'; +import items from '../../__fixtures__/items'; +import { renderAssessmentItem } from '../../__tests__/helpers'; + +// Vue 2 drops aria-selected when bound to boolean false; SimpleChoice coerces to String() +// so the attribute always renders. This helper accepts either behavior. +function expectNotSelected(element) { + const attr = element.getAttribute('aria-selected'); + expect(attr === null || attr === 'false').toBe(true); +} + +// Every QTI choice fixture: confirms it parses and renders the expected choice count. +// A successful render-and-count implicitly asserts the XML parsed without error. +const smokeFixtures = [ + ['q2-choice-interaction-single-cardinality', 3], + ['q2-choice-interaction-single-sv-1', 3], + ['q2-choice-interaction-multiple-cardinality', 3], + ['q2-choice-interaction-multiple-sv-1', 6], + ['q2-choice-interaction-single-sv-4a', 6], + ['q2-choice-interaction-single-sv-4b', 6], + ['q2-choice-interaction-single-sv-4c', 48], + ['q2-choice-interaction-single-sv-4d', 3], + ['q2-choice-interaction-multiple-sv-4a', 12], + ['q2-choice-interaction-multiple-sv-4b', 12], + ['q2-choice-interaction-multiple-sv-4c', 54], + ['q2-choice-interaction-multiple-sv-4d', 18], + ['i9b-response-processing-fixed-template-match-correct-identifier', 2], + ['i9b-response-processing-fixed-template-map-response-identifier', 6], + ['ms-choice-templated-qti3', 5], + ['mc-calc5-qti3', 3], + ['sbac-choice-qti3', 5], + ['a13-a15-captions-glossary', 4], + ['sbac-choice-templated-qti3', 5], +]; + +describe('Smoke', () => { + it.each(smokeFixtures)('%s renders %d choices', (id, choiceCount) => { + renderAssessmentItem(items[id].xml); + expect(screen.getAllByRole('option')).toHaveLength(choiceCount); + }); +}); + +function firstChoiceId(xml) { + return xml.match(/qti-simple-choice identifier="([^"]+)"/)[1]; +} + +// Behavior tests run once per distinct code path, not per fixture — selection/keyboard/state +// come from component logic, not fixture content. +const singleFixture = { + id: 'q2-choice-interaction-single-cardinality', + responseIdentifier: 'RESPONSE', +}; +const multiBoundedFixture = { + id: 'q2-choice-interaction-multiple-cardinality', + responseIdentifier: 'RESPONSE', + maxChoices: 3, +}; +const multiInteractionFixture = { + id: 'q2-choice-interaction-single-sv-4a', + listboxCount: 2, +}; +const shuffleFixture = { + id: 'mc-calc5-qti3', + // mc-calc5-qti3 source order is Item0, Item1, Item2; choices are disambiguated + // by the MathML placeholder each contains: Item0→Choix0, Item1→Choix2, Item2→Choix3. + sourceOrder: ['Item0', 'Item1', 'Item2'], + // Shuffle is seeded by candidateIdentifier ('shuffle-seed-001'), so this is deterministic. + expectedShuffledOrder: ['Item1', 'Item2', 'Item0'], +}; + +function identifyShuffleChoice(text) { + if (text.includes('Choix3')) return 'Item2'; + if (text.includes('Choix2')) return 'Item1'; + if (text.includes('Choix0')) return 'Item0'; + return null; +} + +describe('Single cardinality', () => { + const xml = () => items[singleFixture.id].xml; + + it('exposes role="option" with aria-selected on every choice', () => { + renderAssessmentItem(xml()); + screen.getAllByRole('option').forEach(choice => { + expect(choice).toHaveAttribute('aria-selected'); + }); + }); + + it('exposes role="listbox" with aria-multiselectable="false"', () => { + renderAssessmentItem(xml()); + const listbox = screen.getByRole('listbox'); + expect(listbox).toHaveAttribute('aria-multiselectable', 'false'); + }); + + it('selecting a choice deselects the previously selected one', async () => { + renderAssessmentItem(xml()); + const choices = screen.getAllByRole('option'); + + await fireEvent.click(choices[0]); + expect(choices[0]).toHaveAttribute('aria-selected', 'true'); + + await fireEvent.click(choices[1]); + expect(choices[1]).toHaveAttribute('aria-selected', 'true'); + expectNotSelected(choices[0]); + }); + + it('clicking a selected choice deselects it', async () => { + renderAssessmentItem(xml()); + const choices = screen.getAllByRole('option'); + await fireEvent.click(choices[0]); + await fireEvent.click(choices[0]); + expectNotSelected(choices[0]); + }); + + it.each([ + ['Enter', 'Enter'], + ['Space', ' '], + ])('%s key toggles selection', async (_label, key) => { + renderAssessmentItem(xml()); + const choices = screen.getAllByRole('option'); + + await fireEvent.keyDown(choices[0], { key }); + expect(choices[0]).toHaveAttribute('aria-selected', 'true'); + + await fireEvent.keyDown(choices[0], { key }); + expectNotSelected(choices[0]); + }); + + it('round-trips answer state', async () => { + const { checkAnswer, setAnswerState } = renderAssessmentItem(xml()); + const choices = screen.getAllByRole('option'); + + await fireEvent.click(choices[0]); + const result = checkAnswer(); + expect(result.answerState[singleFixture.responseIdentifier]).toBe(firstChoiceId(xml())); + + setAnswerState({}); + await screen.findAllByRole('option'); + expectNotSelected(choices[0]); + + setAnswerState(result.answerState); + await screen.findAllByRole('option'); + expect(choices[0]).toHaveAttribute('aria-selected', 'true'); + }); + + it('restores selection from injected answerState on mount', () => { + renderAssessmentItem(xml(), { + answerState: { [singleFixture.responseIdentifier]: firstChoiceId(xml()) }, + }); + const selected = screen + .getAllByRole('option') + .filter(c => c.getAttribute('aria-selected') === 'true'); + expect(selected).toHaveLength(1); + }); + + it('reacts to external setAnswerState changes', async () => { + const { setAnswerState } = renderAssessmentItem(xml()); + setAnswerState({ [singleFixture.responseIdentifier]: firstChoiceId(xml()) }); + await screen.findAllByRole('option'); + const selected = screen + .getAllByRole('option') + .filter(c => c.getAttribute('aria-selected') === 'true'); + expect(selected).toHaveLength(1); + }); +}); + +describe('Multi cardinality', () => { + const xml = () => items[multiBoundedFixture.id].xml; + + it('exposes aria-multiselectable="true" on the listbox', () => { + renderAssessmentItem(xml()); + expect(screen.getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true'); + }); + + it('allows multiple simultaneous selections and individual deselection', async () => { + renderAssessmentItem(xml()); + const choices = screen.getAllByRole('option'); + + await fireEvent.click(choices[0]); + await fireEvent.click(choices[1]); + expect(choices[0]).toHaveAttribute('aria-selected', 'true'); + expect(choices[1]).toHaveAttribute('aria-selected', 'true'); + + await fireEvent.click(choices[0]); + expectNotSelected(choices[0]); + expect(choices[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('enforces maxChoices limit', async () => { + renderAssessmentItem(xml()); + const choices = screen.getAllByRole('option'); + for (let i = 0; i < multiBoundedFixture.maxChoices; i++) { + await fireEvent.click(choices[i]); + } + if (choices.length > multiBoundedFixture.maxChoices) { + await fireEvent.click(choices[multiBoundedFixture.maxChoices]); + expectNotSelected(choices[multiBoundedFixture.maxChoices]); + } + }); + + it('restores array-valued answerState on mount', () => { + renderAssessmentItem(xml(), { + answerState: { + [multiBoundedFixture.responseIdentifier]: [firstChoiceId(xml())], + }, + }); + const selected = screen + .getAllByRole('option') + .filter(c => c.getAttribute('aria-selected') === 'true'); + expect(selected).toHaveLength(1); + }); +}); + +describe('Multi-interaction items', () => { + it('render one listbox per interaction', () => { + renderAssessmentItem(items[multiInteractionFixture.id].xml); + expect(screen.getAllByRole('listbox')).toHaveLength(multiInteractionFixture.listboxCount); + }); +}); + +describe('Shuffle', () => { + it('renders choices in deterministic seeded order that differs from source order', () => { + renderAssessmentItem(items[shuffleFixture.id].xml, { + candidateIdentifier: 'shuffle-seed-001', + }); + const renderedOrder = screen + .getAllByRole('option') + .map(el => identifyShuffleChoice(el.textContent)); + expect(renderedOrder).toEqual(shuffleFixture.expectedShuffledOrder); + expect(renderedOrder).not.toEqual(shuffleFixture.sourceOrder); + }); +}); From 6983b314d6ff497d9be6e4c618e37a5e5b3a3155 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 22 Mar 2026 14:09:22 -0700 Subject: [PATCH 3/5] Add acceptance tests for TextEntryInteraction 7 behavioral fixtures covering string/float base types, pattern-mask, composite (17 inputs), and adaptive items. Tests input type, typing, answer state round-tripping, external reactivity, and non-interactive mode. 12 tests fail pending PR #14374 (aria-label, autocomplete=off). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/TextEntryInteraction.spec.js | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/TextEntryInteraction.spec.js diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/TextEntryInteraction.spec.js b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/TextEntryInteraction.spec.js new file mode 100644 index 00000000000..6016a44ff53 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/TextEntryInteraction.spec.js @@ -0,0 +1,101 @@ +import { fireEvent, screen } from '@testing-library/vue'; +import items from '../../__fixtures__/items'; +import { renderAssessmentItem } from '../../__tests__/helpers'; + +// Every QTI text-entry fixture: confirms it parses and renders the expected input count. +// A successful render-and-count implicitly asserts the XML parsed without error. +const smokeFixtures = [ + ['q20-textentry', 1], + ['q20-textentry-sv-3', 1], + ['q20-textentry-composite', 17], + ['card-08a-baseline', 1], + ['Example03-feedbackBlock-solution-qti3', 1], + ['amp-07-nextgen', 1], + ['amp-07-nextgen-a', 1], +]; + +describe('Smoke', () => { + it.each(smokeFixtures)('%s renders %d inputs', (id, inputCount) => { + renderAssessmentItem(items[id].xml); + const textboxes = screen.queryAllByRole('textbox'); + const spinbuttons = screen.queryAllByRole('spinbutton'); + expect(textboxes.length + spinbuttons.length).toBe(inputCount); + }); +}); + +// Behavior tests run once per base type — the component has distinct code paths +// for string (textbox) vs numeric (spinbutton) inputs. +const variants = [ + { + label: 'string', + fixtureId: 'q20-textentry', + role: 'textbox', + htmlType: 'text', + typedValue: 'hello', + storedValue: 'hello', + emptyValue: '', + responseIdentifier: 'RESPONSE', + }, + { + label: 'number', + fixtureId: 'amp-07-nextgen', + role: 'spinbutton', + htmlType: 'number', + typedValue: '42', + storedValue: 42, + emptyValue: null, + responseIdentifier: 'RESPONSE', + }, +]; + +describe.each(variants)('$label text entry', variant => { + const xml = () => items[variant.fixtureId].xml; + + it(`renders an input of type ${'$htmlType'}`, () => { + renderAssessmentItem(xml()); + expect(screen.getByRole(variant.role)).toHaveAttribute('type', variant.htmlType); + }); + + it('exposes an accessible label and disables autocomplete', () => { + renderAssessmentItem(xml()); + const input = screen.getByRole(variant.role); + expect(input.hasAttribute('aria-label') || input.hasAttribute('aria-labelledby')).toBe(true); + expect(input).toHaveAttribute('autocomplete', 'off'); + }); + + it('updates value when typed into', async () => { + renderAssessmentItem(xml()); + const input = screen.getByRole(variant.role); + await fireEvent.update(input, variant.typedValue); + expect(input).toHaveValue(variant.storedValue); + }); + + it('round-trips answer state', async () => { + const { checkAnswer, setAnswerState } = renderAssessmentItem(xml()); + const input = screen.getByRole(variant.role); + + await fireEvent.update(input, variant.typedValue); + const result = checkAnswer(); + expect(result.answerState[variant.responseIdentifier]).toEqual(variant.storedValue); + + setAnswerState({}); + await screen.findByRole(variant.role); + expect(input).toHaveValue(variant.emptyValue); + + setAnswerState(result.answerState); + await screen.findByRole(variant.role); + expect(input).toHaveValue(variant.storedValue); + }); + + it('restores value from injected answerState on mount', () => { + renderAssessmentItem(xml(), { + answerState: { [variant.responseIdentifier]: variant.storedValue }, + }); + expect(screen.getByRole(variant.role)).toHaveValue(variant.storedValue); + }); + + it('shows read-only div when not interactive', () => { + renderAssessmentItem(xml(), { interactive: false }); + expect(screen.queryByRole(variant.role)).not.toBeInTheDocument(); + }); +}); From fb772214639e3b13e42fffd9b213ba4b0a996eb6 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 22 Mar 2026 14:13:14 -0700 Subject: [PATCH 4/5] Add fixtures for missing QTI interaction types Adds 5 new fixtures covering interaction types identified in the 1EdTech canonical examples gap analysis: - associate-interaction-1: pair matching (country to capital) - slider-interaction-1: numeric slider (boiling point) - drawing-interaction-1: freehand drawing on image (file response) - upload-interaction-1: file upload (essay submission) - upload-composite-1: file upload combined with text entry These scaffold future component development and acceptance tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../qti_viewer/frontend/components/__fixtures__/items.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js index 07b13f0714d..a6532271093 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js +++ b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e158f89b26932f7f0d6db9952c7ea316efeeef65ee9330dfe0dc2a41a774effe -size 852892 +oid sha256:b9dfdc0c336ca119f144b6320302f4ab245b8f93f02de55089c5abd21a9d185f +size 861137 From ff2e21a251990fdbc94595e2b3abaa18fb652ff3 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 19 Apr 2026 09:59:49 -0700 Subject: [PATCH 5/5] Pull LFS objects in Javascript Tests workflow JS test fixtures stored via git-lfs (e.g. the QTI items.js fixture bundle) need to be resolved to their actual content before Jest runs; without lfs: true on the checkout step the pointer file is parsed as JS and the suite fails to load. --- .github/workflows/frontend-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index b2d0da3b051..6d09df6ab23 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -30,6 +30,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + lfs: true - name: Use Node.js uses: actions/setup-node@v6 with: