diff --git a/kolibri/plugins/qti_viewer/frontend/components/AnswerGuide.vue b/kolibri/plugins/qti_viewer/frontend/components/AnswerGuide.vue index 1e96b49fe85..f33dd155170 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/AnswerGuide.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/AnswerGuide.vue @@ -44,10 +44,20 @@ context: 'Tells the learner to complete the passage by choosing an option in each inline dropdown gap', }, + chooseThenTarget: { + message: 'Choose a response, then choose a target.', + context: + 'Tells the learner how to build each pair in a connect-pairs or match interaction: pick a response from the pool, then pick the answer slot to put it in', + }, shortAnswer: { message: 'Short answer only:', context: 'Tells the learner to type their answer in a text entry interaction', }, + gapMatch: { + message: 'Choose a response, then choose a gap.', + context: + 'Tells the learner how to fill each blank in a gap match question: pick a response from the pool, then pick the gap in the passage to put it in', + }, }); export default { diff --git a/kolibri/plugins/qti_viewer/frontend/components/AssessmentItem.vue b/kolibri/plugins/qti_viewer/frontend/components/AssessmentItem.vue index 86cfb12d133..38728c0fecb 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/AssessmentItem.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/AssessmentItem.vue @@ -38,6 +38,14 @@ import InlineChoiceInteraction from './interactions/InlineChoiceInteraction.vue'; import InlineChoice from './interactions/InlineChoice.vue'; import CustomInteraction from './interactions/CustomInteraction.vue'; + import AssociateInteraction from './interactions/AssociateInteraction.vue'; + import SimpleAssociableChoice from './interactions/SimpleAssociableChoice.vue'; + import MatchInteraction from './interactions/MatchInteraction.vue'; + import SimpleMatchSet from './interactions/SimpleMatchSet.vue'; + import GapMatchInteraction from './interactions/GapMatchInteraction.vue'; + import Gap from './interactions/Gap.vue'; + import GapText from './interactions/GapText.vue'; + import GapImg from './interactions/GapImg.vue'; const $themeTokens = themeTokens(); @@ -50,6 +58,14 @@ [InlineChoiceInteraction.tag]: InlineChoiceInteraction, [InlineChoice.tag]: InlineChoice, [CustomInteraction.tag]: CustomInteraction, + [AssociateInteraction.tag]: AssociateInteraction, + [SimpleAssociableChoice.tag]: SimpleAssociableChoice, + [MatchInteraction.tag]: MatchInteraction, + [SimpleMatchSet.tag]: SimpleMatchSet, + [GapMatchInteraction.tag]: GapMatchInteraction, + [Gap.tag]: Gap, + [GapText.tag]: GapText, + [GapImg.tag]: GapImg, }); /** @typedef {import('../utils/qti/values.js').QTIValue} QTIValue */ diff --git a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/items.js index 8a116ce6846..5006994505b 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:1e11d9f6e600fa9527e60106217935a723ff9ed2ffd4722b37484158f756362f -size 871767 +oid sha256:e5040c2e7b3d35609607eb6b6f3be6fb6ee1d9bd0a4e3d9649a5de12a608fa47 +size 880432 diff --git a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/structure.js b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/structure.js index c583a7bfffd..b2e0250af52 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/structure.js +++ b/kolibri/plugins/qti_viewer/frontend/components/__fixtures__/structure.js @@ -331,6 +331,23 @@ export default [ }, ], }, + { + title: 'Associate', + items: [ + { + identifier: 'associate-interaction-1', + title: 'Capital Cities', + }, + { + identifier: 'associate-interaction-fixed', + title: 'Associate - Fixed Choice', + }, + { + identifier: 'associate-interaction-images', + title: 'Associate - Images', + }, + ], + }, { title: 'Match', items: [ @@ -387,6 +404,10 @@ export default [ identifier: 'q6-gap-match-interaction-sv-4', title: 'Gap Match - Gap Widths', }, + { + identifier: 'gap-match-distractor-pools', + title: 'Gap Match - Answer Specific Pools', + }, ], }, { diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/AssociateInteraction.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/AssociateInteraction.vue new file mode 100644 index 00000000000..5ee0d98d20d --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/AssociateInteraction.vue @@ -0,0 +1,653 @@ + + + + + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/ChoiceInteraction.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/ChoiceInteraction.vue index 177e8cc0ce6..7ee757084d1 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/interactions/ChoiceInteraction.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/ChoiceInteraction.vue @@ -1,10 +1,9 @@ + + + + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/GapImg.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/GapImg.vue new file mode 100644 index 00000000000..dfac2211cec --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/GapImg.vue @@ -0,0 +1,45 @@ + + + + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/GapMatchInteraction.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/GapMatchInteraction.vue new file mode 100644 index 00000000000..0967b637c03 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/GapMatchInteraction.vue @@ -0,0 +1,652 @@ + + + + + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/GapText.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/GapText.vue new file mode 100644 index 00000000000..e0ef78742b1 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/GapText.vue @@ -0,0 +1,45 @@ + + + + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/InlineChoiceInteraction.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/InlineChoiceInteraction.vue index cf0ba031443..dd2ee28cb45 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/interactions/InlineChoiceInteraction.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/InlineChoiceInteraction.vue @@ -56,11 +56,10 @@ + + + + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/OrderInteraction.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/OrderInteraction.vue index 022894b7532..dbd258d6cce 100644 --- a/kolibri/plugins/qti_viewer/frontend/components/interactions/OrderInteraction.vue +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/OrderInteraction.vue @@ -1,6 +1,5 @@ diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleMatchSet.vue b/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleMatchSet.vue new file mode 100644 index 00000000000..69220cba6e0 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/SimpleMatchSet.vue @@ -0,0 +1,24 @@ + + + + diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/AssociateInteraction.spec.js b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/AssociateInteraction.spec.js new file mode 100644 index 00000000000..9711158cef8 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/AssociateInteraction.spec.js @@ -0,0 +1,728 @@ +import { fireEvent, screen, waitFor, within } from '@testing-library/vue'; +import items from '../../__fixtures__/items'; +import { renderAssessmentItem } from '../../__tests__/helpers'; +import { answerGuideStrings } from '../../AnswerGuide.vue'; +import { slotListboxStrings } from '../../../composables/useSlotListbox'; +import { associateStrings } from '../AssociateInteraction.vue'; + +const { + responsePoolLabel$, + firstSlotEmpty$, + secondSlotEmpty$, + firstSlotFilled$, + secondSlotFilled$, +} = associateStrings; + +const { emptyOption$ } = slotListboxStrings; + +// Container-scoped: the fixtures shuffle, so pool order is seeded and only +// membership is stable. Tests that care about order say so explicitly. +function poolEntries(container) { + return within(within(container).getByLabelText(responsePoolLabel$())) + .getAllByRole('listitem') + .map(entry => entry.textContent.trim()); +} + +function slots(container) { + return Array.from(container.querySelectorAll('.qti-associate-slot')); +} + +// Response content comes from the fixture XML rather than a translation, so it +// is matched on the rendered chip instead of through a *ByText query. +function poolChip(container, text) { + return Array.from( + container.querySelectorAll('.qti-associate-pool-entry .qti-associate-chip'), + ).find(chip => chip.textContent.trim() === text); +} + +// A placed response stays in the pool, disabled, so the pool never reflows. +function availablePoolChips(container) { + return Array.from(container.querySelectorAll('.qti-associate-pool-entry .qti-associate-chip')) + .filter(chip => !chip.classList.contains('qti-associate-chip-disabled')) + .map(chip => chip.textContent.trim()); +} + +describe('Smoke', () => { + it('renders the prompt', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + expect(container).toHaveTextContent(/Match each country to its capital city\./); + }); + + it('renders the associate answer guide', () => { + renderAssessmentItem(items['associate-interaction-1'].xml); + expect( + screen.getByText(answerGuideStrings.chooseThenTarget$(), { + selector: 'p.qti-selection-instructions', + }), + ).toBeVisible(); + }); + + it('puts every response in the pool', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + expect(poolEntries(container).sort()).toEqual([ + 'Berlin', + 'France', + 'Germany', + 'Japan', + 'Paris', + 'Tokyo', + ]); + }); + + it('renders images as response content', () => { + const { container } = renderAssessmentItem(items['associate-interaction-images'].xml); + expect(container.querySelectorAll('.qti-associate-chip img')).toHaveLength(3); + }); +}); + +describe('Pair rows', () => { + it('renders one row of two slots per association', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + // max-associations="3" + expect(container.querySelectorAll('.qti-associate-row')).toHaveLength(3); + expect(slots(container)).toHaveLength(6); + }); + + it('renders every slot empty before the learner answers', () => { + renderAssessmentItem(items['associate-interaction-1'].xml); + [1, 2, 3].forEach(number => { + expect(screen.getByLabelText(firstSlotEmpty$({ number }))).toBeVisible(); + expect(screen.getByLabelText(secondSlotEmpty$({ number }))).toBeVisible(); + }); + }); +}); + +describe('Restoring an answer', () => { + it('fills slots from injected answerState and disables those responses in the pool', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { + RESPONSE: [ + ['C1', 'C4'], + ['C2', 'C5'], + ], + }, + }); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ).toBeVisible(); + expect(poolEntries(container)).toHaveLength(6); + expect(availablePoolChips(container).sort()).toEqual(['Japan', 'Tokyo']); + }); + + it('re-renders when the answer state changes', async () => { + const { container, setAnswerState } = renderAssessmentItem( + items['associate-interaction-1'].xml, + ); + expect(availablePoolChips(container)).toHaveLength(6); + + setAnswerState({ RESPONSE: [['C3', 'C6']] }); + + await waitFor(() => { + expect(availablePoolChips(container).sort()).toEqual([ + 'Berlin', + 'France', + 'Germany', + 'Paris', + ]); + }); + }); +}); + +describe('Placed responses in the pool', () => { + it('keeps the response in place, disabled, rather than removing it', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const orderBefore = poolEntries(container); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + expect(poolEntries(container)).toEqual(orderBefore); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-disabled'); + expect(poolChip(container, 'France')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not let a disabled response be picked up from the pool', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { RESPONSE: [['C1', 'C4']] }, + }); + + await fireEvent.click(poolChip(container, 'France')); + + expect(container.querySelectorAll('.qti-associate-slot-target')).toHaveLength(0); + }); + + it('leaves a disabled response out of the draggable items', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { RESPONSE: [['C1', 'C4']] }, + }); + + expect(poolChip(container, 'France').closest('.draggable-item')).toBeNull(); + expect(poolChip(container, 'Japan').closest('.draggable-item')).not.toBeNull(); + }); +}); + +describe('Shuffle', () => { + it('presents the pool in a different order per candidate', () => { + const a = renderAssessmentItem(items['associate-interaction-1'].xml, { + candidateIdentifier: 'candidate-a', + }); + const b = renderAssessmentItem(items['associate-interaction-1'].xml, { + candidateIdentifier: 'candidate-b', + }); + + expect(poolEntries(a.container)).not.toEqual(poolEntries(b.container)); + }); + + it('keeps a fixed response in its authored position', () => { + // NILE is fixed="true" and authored first, so it leads the pool whatever + // the seed does to the rest. + const { container } = renderAssessmentItem(items['associate-interaction-fixed'].xml, { + candidateIdentifier: 'candidate-a', + }); + expect(poolEntries(container)[0]).toBe('Nile'); + }); +}); + +// SortableJS cannot be driven in jsdom, so a drag is exercised the way the +// abstraction reports it: useDraggableRegion's handleEnd inserts into the +// destination region first, then emits the source region's remaining items. +function findRegions() { + const mounted = Array.from(document.body.querySelectorAll('*')).find(el => el.__vue__); + const regions = []; + const walk = vm => { + if (!vm) { + return; + } + if (vm.$options.name === 'DraggableRegion') { + regions.push(vm); + } + (vm.$children || []).forEach(walk); + }; + walk(mounted && mounted.__vue__.$root); + return regions; +} + +function regionLabelled(label) { + return findRegions().find(region => region.label === label); +} + +async function dragInto(label, identifier) { + const regions = findRegions(); + const target = regions.find(region => region.label === label); + const source = regions.find(region => region.items.some(item => item.identifier === identifier)); + const sourceItemsBeforeDrag = source.items; + + target.$emit('update:items', [...target.items, { identifier }]); + source.$emit( + 'update:items', + sourceItemsBeforeDrag.filter(item => item.identifier !== identifier), + ); + await target.$nextTick(); +} + +describe('Placing by drag', () => { + it('fills an empty slot dragged onto from the pool', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await dragInto(firstSlotEmpty$({ number: 1 }), 'C1'); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ).toBeVisible(); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-disabled'); + }); + + it('frees the displaced response in the pool when dropped on a filled slot', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { RESPONSE: [['C1', 'C4']] }, + }); + + await dragInto(firstSlotFilled$({ number: 1, response: 'France' }), 'C3'); + + expect(screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'Japan' }))).toBeVisible(); + expect(poolChip(container, 'France')).not.toHaveClass('qti-associate-chip-disabled'); + expect(poolChip(container, 'Japan')).toHaveClass('qti-associate-chip-disabled'); + }); + + it('swaps two responses when one filled slot is dropped on the other', async () => { + renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { + RESPONSE: [ + ['C1', 'C4'], + ['C2', 'C5'], + ], + }, + }); + + await dragInto(firstSlotFilled$({ number: 2, response: 'Germany' }), 'C1'); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 2, response: 'France' })), + ).toBeVisible(); + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'Germany' })), + ).toBeVisible(); + }); + + it('empties the slot when a response is dragged back to the pool', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { RESPONSE: [['C1', 'C4']] }, + }); + + await dragInto(responsePoolLabel$(), 'C1'); + + expect(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))).toBeVisible(); + expect(poolChip(container, 'France')).toBeDefined(); + }); + + it('updates the response variable after a drag completes a pair', async () => { + const { checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await dragInto(firstSlotEmpty$({ number: 1 }), 'C2'); + await dragInto(secondSlotEmpty$({ number: 1 }), 'C5'); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([['C2', 'C5']]); + }); + }); + + it('disables the regions in review mode', () => { + renderAssessmentItem(items['associate-interaction-1'].xml, { interactive: false }); + + expect(findRegions().every(region => region.disabled)).toBe(true); + }); + + it('labels each region so the drop is announced', () => { + renderAssessmentItem(items['associate-interaction-1'].xml); + + expect(regionLabelled(responsePoolLabel$())).toBeDefined(); + expect(regionLabelled(firstSlotEmpty$({ number: 1 }))).toBeDefined(); + expect(regionLabelled(secondSlotEmpty$({ number: 3 }))).toBeDefined(); + }); +}); + +describe('Placing by click', () => { + it('places a selected response into the slot clicked next', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ).toBeVisible(); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-disabled'); + }); + + it('places into a slot chosen before the response', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(screen.getByLabelText(secondSlotEmpty$({ number: 2 }))); + await fireEvent.click(poolChip(container, 'Tokyo')); + + expect( + screen.getByLabelText(secondSlotFilled$({ number: 2, response: 'Tokyo' })), + ).toBeVisible(); + }); + + it('highlights the empty slots once a response is selected', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + expect(container.querySelectorAll('.qti-associate-slot-target')).toHaveLength(0); + + await fireEvent.click(poolChip(container, 'France')); + + expect(container.querySelectorAll('.qti-associate-slot-target')).toHaveLength(6); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-selected'); + }); + + it('does not highlight a filled slot as a valid target', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + await fireEvent.click(poolChip(container, 'Japan')); + + const highlighted = container.querySelectorAll('.qti-associate-slot-target'); + expect(highlighted).toHaveLength(5); + Array.from(highlighted).forEach(slot => { + expect(slot).not.toHaveClass('qti-associate-slot-filled'); + }); + }); + + it('highlights the pool once a slot is selected', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + expect(container.querySelectorAll('.qti-associate-chip-candidate')).toHaveLength(6); + }); + + it('deselects a response when it is clicked again', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(poolChip(container, 'France')); + + expect(container.querySelectorAll('.qti-associate-slot-target')).toHaveLength(0); + }); + + it('swaps two responses when one filled slot is clicked then the other', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + answerState: { + RESPONSE: [ + ['C1', 'C4'], + ['C2', 'C5'], + ], + }, + }); + + await fireEvent.click( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ); + await fireEvent.click( + screen.getByLabelText(firstSlotFilled$({ number: 2, response: 'Germany' })), + ); + + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'Germany' })), + ).toBeVisible(); + expect( + screen.getByLabelText(firstSlotFilled$({ number: 2, response: 'France' })), + ).toBeVisible(); + expect(poolChip(container, 'France')).toHaveClass('qti-associate-chip-disabled'); + }); + + it('frees the displaced response in the pool when a filled slot is reused', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + await fireEvent.click(poolChip(container, 'Japan')); + await fireEvent.click( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ); + + expect(screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'Japan' }))).toBeVisible(); + expect(poolChip(container, 'France')).not.toHaveClass('qti-associate-chip-disabled'); + }); + + it('ignores clicks in review mode', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + interactive: false, + }); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + expect(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))).toBeVisible(); + }); +}); + +describe('Response variable', () => { + it('reports a completed pair on submit', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + await fireEvent.click(poolChip(container, 'Paris')); + await fireEvent.click(screen.getByLabelText(secondSlotEmpty$({ number: 1 }))); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([['C1', 'C4']]); + }); + }); + + it('omits a row that is only half filled', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([]); + }); + }); + + it('keeps a half-filled row when the completed pairs are written back', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + // Complete row 2, then half-fill row 1: writing the derived pairs must not + // compact the rows and lose the lone response. + await fireEvent.click(poolChip(container, 'Germany')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 2 }))); + await fireEvent.click(poolChip(container, 'Berlin')); + await fireEvent.click(screen.getByLabelText(secondSlotEmpty$({ number: 2 }))); + await fireEvent.click(poolChip(container, 'France')); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number: 1 }))); + + await waitFor(() => { + expect( + screen.getByLabelText(firstSlotFilled$({ number: 1, response: 'France' })), + ).toBeVisible(); + }); + expect( + screen.getByLabelText(firstSlotFilled$({ number: 2, response: 'Germany' })), + ).toBeVisible(); + }); + + it('scores the item through the mapping when the answer is correct', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + const pairs = [ + ['France', 'Paris'], + ['Germany', 'Berlin'], + ['Japan', 'Tokyo'], + ]; + for (const [rowIndex, [left, right]] of pairs.entries()) { + const number = rowIndex + 1; + await fireEvent.click(poolChip(container, left)); + await fireEvent.click(screen.getByLabelText(firstSlotEmpty$({ number }))); + await fireEvent.click(poolChip(container, right)); + await fireEvent.click(screen.getByLabelText(secondSlotEmpty$({ number }))); + } + + await waitFor(() => { + expect(checkAnswer().outcomes.SCORE).toBe(3); + }); + }); + + it('does not write to the variable in review mode', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml, { + interactive: false, + }); + + await fireEvent.click(poolChip(container, 'France')); + + expect(checkAnswer().answerState.RESPONSE).toBeNull(); + }); +}); + +describe('Keyboard', () => { + function slotAt(container, index) { + return container.querySelectorAll('.qti-associate-slot')[index]; + } + + function optionsOf(slot) { + return Array.from(slot.querySelectorAll('[role="option"]')).map(option => ({ + text: option.textContent.trim(), + selected: option.getAttribute('aria-selected'), + id: option.id, + })); + } + + // The responses alone, without the empty option that leads them + function responsesOf(slot) { + return optionsOf(slot).slice(1); + } + + function activeOptionText(slot) { + const id = slot.getAttribute('aria-activedescendant'); + return slot.querySelector(`#${id}`).textContent.trim(); + } + + it('exposes each slot as a listbox that tab can reach', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + slots(container).forEach(slot => { + expect(slot).toHaveAttribute('role', 'listbox'); + expect(slot).toHaveAttribute('tabindex', '0'); + }); + }); + + it('keeps the response pool out of the tab order', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const pool = container.querySelector('.qti-associate-pool'); + + expect(pool.querySelectorAll('[tabindex="0"]')).toHaveLength(0); + expect(pool.querySelectorAll('button, a, input, select')).toHaveLength(0); + }); + + // Decorative: the arrows say the value can be stepped, and CSS shows them only + // while the slot holds keyboard focus. A reader gets the value from the options. + it('gives each slot an up/down affordance that is hidden from screen readers', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + slots(container).forEach(slot => { + const stepper = slot.querySelector('.qti-slot-stepper'); + expect(stepper).not.toBeNull(); + expect(stepper).toHaveAttribute('aria-hidden', 'true'); + }); + }); + + it('leaves the up/down affordance out in review mode', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + interactive: false, + }); + + expect(container.querySelectorAll('.qti-slot-stepper')).toHaveLength(0); + }); + + it('hides the options visually while exposing them to a screen reader', () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const slot = slotAt(container, 0); + + expect(slot.querySelector('[role="option"]').closest('.qti-visually-hidden')).not.toBeNull(); + // the six responses, led by the empty option + expect(optionsOf(slot)).toHaveLength(7); + }); + + it('fills the slot with the first valid response when focused', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const slot = slotAt(container, 0); + const firstCandidate = responsesOf(slot)[0].text; + + await fireEvent.focus(slot); + + expect(slotAt(container, 0)).toHaveAttribute( + 'aria-label', + firstSlotFilled$({ number: 1, response: firstCandidate }), + ); + }); + + it('does not fill the slot when a pointer press is what focused it', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const slot = slotAt(container, 0); + + // A press always precedes the focus it causes + await fireEvent.mouseDown(slot); + await fireEvent.focus(slot); + + expect(slotAt(container, 0)).toHaveAttribute('aria-label', firstSlotEmpty$({ number: 1 })); + }); + + it('cycles the value in place with the arrow keys', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const responses = responsesOf(slotAt(container, 0)).map(option => option.text); + + await fireEvent.focus(slotAt(container, 0)); + expect(activeOptionText(slotAt(container, 0))).toBe(responses[0]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowDown' }); + expect(activeOptionText(slotAt(container, 0))).toBe(responses[1]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowUp' }); + expect(activeOptionText(slotAt(container, 0))).toBe(responses[0]); + }); + + it('empties the slot by stepping back past the first response', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowUp' }); + + expect(activeOptionText(slotAt(container, 0))).toBe(emptyOption$()); + expect(slotAt(container, 0)).toHaveAttribute('aria-label', firstSlotEmpty$({ number: 1 })); + }); + + it('offers the empty option first, so an unanswered slot starts on it', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const slot = slotAt(container, 0); + + // A pointer press focuses without answering, which is how a slot comes to be + // focused and still empty + await fireEvent.mouseDown(slot); + await fireEvent.focus(slot); + + expect(optionsOf(slot)[0].text).toBe(emptyOption$()); + expect(activeOptionText(slotAt(container, 0))).toBe(emptyOption$()); + }); + + it('clamps at the ends and jumps with Home and End', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + // the empty option included: Home lands on it, End on the last response + const candidates = optionsOf(slotAt(container, 0)).map(option => option.text); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowUp' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[0]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'End' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[candidates.length - 1]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowDown' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[candidates.length - 1]); + + await fireEvent.keyDown(slotAt(container, 0), { key: 'Home' }); + expect(activeOptionText(slotAt(container, 0))).toBe(candidates[0]); + }); + + it('tracks the current value with aria-selected', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowDown' }); + + const options = optionsOf(slotAt(container, 0)); + expect(options.filter(option => option.selected === 'true')).toHaveLength(1); + // the second response, since the empty option leads the list + expect(options[2].selected).toBe('true'); + }); + + it('keeps the slot own response among its candidates so cycling is reversible', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + const before = optionsOf(slotAt(container, 0)).map(option => option.text); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.keyDown(slotAt(container, 0), { key: 'ArrowDown' }); + + expect(optionsOf(slotAt(container, 0)).map(option => option.text)).toEqual(before); + }); + + it('empties the slot on Escape', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.keyDown(slotAt(container, 0), { key: 'Escape' }); + + expect(slotAt(container, 0)).toHaveAttribute('aria-label', firstSlotEmpty$({ number: 1 })); + }); + + it('records the pair built with the keyboard in the response variable', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['associate-interaction-1'].xml); + + await fireEvent.focus(slotAt(container, 0)); + await fireEvent.blur(slotAt(container, 0)); + await fireEvent.focus(slotAt(container, 1)); + await fireEvent.blur(slotAt(container, 1)); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toHaveLength(1); + }); + }); + + it('names an image response by its alt text', () => { + const { container } = renderAssessmentItem(items['associate-interaction-images'].xml); + + expect(optionsOf(slotAt(container, 0)).map(option => option.text)).toEqual( + expect.arrayContaining(['A blue circle', 'A pink square', 'A green triangle']), + ); + }); + + it('is not reachable or operable in review mode', async () => { + const { container } = renderAssessmentItem(items['associate-interaction-1'].xml, { + interactive: false, + }); + const slot = slotAt(container, 0); + + expect(slot).not.toHaveAttribute('tabindex'); + expect(slot).not.toHaveAttribute('role', 'listbox'); + expect(slot.querySelectorAll('[role="option"]')).toHaveLength(0); + + await fireEvent.focus(slot); + expect(slotAt(container, 0)).toHaveAttribute('aria-label', firstSlotEmpty$({ number: 1 })); + }); +}); + +describe('Review mode', () => { + it('marks the interaction read-only', async () => { + const { container, setInteractive } = renderAssessmentItem( + items['associate-interaction-1'].xml, + ); + setInteractive(false); + + await waitFor(() => { + expect(container.querySelector('.qti-associate-readonly')).toBeInTheDocument(); + }); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/GapMatchInteraction.spec.js b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/GapMatchInteraction.spec.js new file mode 100644 index 00000000000..6aaa38d8ba1 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/GapMatchInteraction.spec.js @@ -0,0 +1,1067 @@ +import { fireEvent, screen, waitFor, within } from '@testing-library/vue'; +import { slotListboxStrings } from '../../../composables/useSlotListbox'; +import items from '../../__fixtures__/items'; +import { renderAssessmentItem } from '../../__tests__/helpers'; +import { answerGuideStrings } from '../../AnswerGuide.vue'; +import { gapMatchStrings } from '../GapMatchInteraction.vue'; + +const { responsePoolLabel$, gapEmpty$, gapFilled$ } = gapMatchStrings; +const { emptyOption$ } = slotListboxStrings; + +// Choice content comes from the fixture XML rather than a translation, so it is +// matched on the rendered chip instead of through a *ByText query. +function poolChips(container) { + return Array.from( + container.querySelectorAll('.qti-gap-match-pool-entry .qti-gap-match-chip'), + ).map(chip => chip.textContent.trim()); +} + +function gaps(container) { + return Array.from(container.querySelectorAll('.qti-gap')); +} + +function poolChip(container, text) { + return Array.from( + container.querySelectorAll('.qti-gap-match-pool-entry .qti-gap-match-chip'), + ).find(chip => chip.textContent.trim() === text); +} + +// A gap owns a visually hidden option list for its listbox, so what it holds is +// read off the chip rather than the gap's own text. +function gapText(gap) { + const chip = gap.querySelector('.qti-gap-match-chip'); + return chip ? chip.textContent.trim() : ''; +} + +const gapTexts = container => gaps(container).map(gapText); + +describe('Smoke', () => { + it('renders the prompt', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + expect(container).toHaveTextContent( + /Identify the missing words in this famous quote from Shakespeare's Richard III/, + ); + }); + + it('renders the gap match guide', () => { + renderAssessmentItem(items['gap-match-example-1'].xml); + expect( + screen.getByText(answerGuideStrings.gapMatch$(), { + selector: 'p.qti-selection-instructions', + }), + ).toBeVisible(); + }); + + it('puts the gap choices in the pool', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + expect(poolChips(container).sort()).toEqual(['autumn', 'spring', 'summer', 'winter']); + }); + + it('labels the pool', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + expect(within(container).getByLabelText(responsePoolLabel$())).toBeVisible(); + }); + + it('renders a gap for each qti-gap in the passage', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + expect(gaps(container)).toHaveLength(2); + }); + + it('keeps the passage around the gaps intact', () => { + // The item body is parsed as XML, where is self-closing. Read + // back by the HTML parser that is a start tag with no end, which would + // swallow the rest of the quotation into the first gap. + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + expect(container).toHaveTextContent(/Now is the/); + expect(container).toHaveTextContent(/of our discontent/); + expect(container).toHaveTextContent(/In the deep bosom of the ocean buried/); + }); + + it('numbers each gap for a screen reader', () => { + renderAssessmentItem(items['gap-match-example-1'].xml); + expect(screen.getByLabelText(gapEmpty$({ number: 1, total: 2 }))).toBeVisible(); + expect(screen.getByLabelText(gapEmpty$({ number: 2, total: 2 }))).toBeVisible(); + }); +}); + +describe('Gaps in authored content', () => { + it('finds gaps nested in a table', () => { + const { container } = renderAssessmentItem(items['gap-match-example-2'].xml); + expect(gaps(container)).toHaveLength(8); + }); + + it('finds gaps inline in a paragraph', () => { + const { container } = renderAssessmentItem(items['gap-match-example-3'].xml); + expect(gaps(container)).toHaveLength(4); + }); + + it('keeps its own class when the author puts one on a gap', () => { + // example-3's last gap carries class="ets-target" + const { container } = renderAssessmentItem(items['gap-match-example-3'].xml); + const last = gaps(container)[3]; + expect(last).toHaveClass('qti-gap'); + expect(last).toHaveClass('ets-target'); + }); + + it('gives each interaction in an item body only its own gaps', () => { + // sv-3 holds two interactions, both of which name their gaps G1 and G2 + const { container } = renderAssessmentItem(items['q6-gap-match-interaction-sv-3'].xml); + const interactions = container.querySelectorAll('.qti-gap-match-interaction'); + expect(interactions).toHaveLength(2); + interactions.forEach(interaction => { + expect(interaction.querySelectorAll('.qti-gap')).toHaveLength(2); + }); + }); +}); + +describe('Gap widths', () => { + // The styling itself is CSS, so what is worth pinning here is that the + // authored class survives to the gap alongside the component's own — the + // width rule needs both, and an inherited attribute would have replaced one. + it('keeps the authored width class on the gap', () => { + const { container } = renderAssessmentItem(items['q6-gap-match-interaction-sv-4'].xml); + const widths = gaps(container); + + expect(widths).toHaveLength(9); + expect(widths[0]).toHaveClass('qti-gap', 'qti-input-width-1'); + expect(widths[2]).toHaveClass('qti-gap', 'qti-input-width-3'); + expect(widths[8]).toHaveClass('qti-gap', 'qti-input-width-72'); + }); + + it('still renders a gap the author gave no width', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + gaps(container).forEach(gap => { + expect(gap).toHaveClass('qti-gap'); + expect(gap.className).not.toMatch(/qti-input-width/); + }); + }); +}); + +describe('Restoring an answer', () => { + it('shows a restored response in its gap', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + const [first] = gaps(container); + expect(gapText(first)).toBe('winter'); + }); + + it('names the filled gap for a screen reader', () => { + renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + expect( + screen.getByLabelText(gapFilled$({ number: 1, total: 2, response: 'winter' })), + ).toBeVisible(); + }); + + it('reads a pair as choice first', () => { + // 'Su G2' is summer in the second gap, not a gap called Su + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['Su', 'G2']] }, + }); + expect(gapText(gaps(container)[1])).toBe('summer'); + }); + + it('spends the use of a restored response', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + const chip = Array.from( + container.querySelectorAll('.qti-gap-match-pool-entry .qti-gap-match-chip'), + ).find(candidate => candidate.textContent.trim() === 'winter'); + expect(chip).toHaveClass('qti-gap-match-chip-exhausted'); + }); + + it('keeps an unlimited response available once used', () => { + // gap-match-example-3's choices are all match-max="0" + const { container } = renderAssessmentItem(items['gap-match-example-3'].xml, { + answerState: { RESPONSE: [['s1', 't1']] }, + }); + const chip = Array.from( + container.querySelectorAll('.qti-gap-match-pool-entry .qti-gap-match-chip'), + ).find(candidate => candidate.textContent.trim() === 'Earth'); + expect(chip).not.toHaveClass('qti-gap-match-chip-exhausted'); + }); + + it('reports a restored answer back choice first', () => { + const { checkAnswer } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + expect(checkAnswer().answerState.RESPONSE).toEqual([['W', 'G1']]); + }); + + it('ignores a pair naming a gap that is not in the passage', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'nope']] }, + }); + expect(gapTexts(container)).toEqual(['', '']); + }); + + it('ignores a pair written the wrong way round', () => { + // Gap first rather than choice first: 'G1' is not a choice, so nothing fills + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['G1', 'W']] }, + }); + expect(gapTexts(container)).toEqual(['', '']); + }); +}); + +describe('Placing by click', () => { + it('puts a chosen response in the gap chosen next', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'winter')); + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['winter', '']); + }); + + it('takes the response the other way round too', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(gaps(container)[1]); + await fireEvent.click(poolChip(container, 'summer')); + + expect(gapTexts(container)).toEqual(['', 'summer']); + }); + + it('marks the chosen response as selected', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'winter')); + + expect(poolChip(container, 'winter')).toHaveClass('qti-gap-match-chip-selected'); + }); + + it('unselects a response chosen twice', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'winter')); + await fireEvent.click(poolChip(container, 'winter')); + + expect(poolChip(container, 'winter')).not.toHaveClass('qti-gap-match-chip-selected'); + }); + + it('marks a gap awaiting a response as active', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(gaps(container)[0]); + + expect(gaps(container)[0]).toHaveClass('qti-gap-active'); + }); + + it('offers the responses that could fill the active gap', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(gaps(container)[0]); + + expect(poolChip(container, 'winter')).toHaveClass('qti-gap-match-chip-candidate'); + }); + + it('stops waiting when the active gap is chosen again', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(gaps(container)[0]); + await fireEvent.click(gaps(container)[0]); + + expect(gaps(container)[0]).not.toHaveClass('qti-gap-active'); + }); + + it('takes a response back out of a filled gap', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['', '']); + }); + + it('returns a response to the pool when its gap is emptied', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + expect(poolChip(container, 'winter')).toHaveClass('qti-gap-match-chip-exhausted'); + + await fireEvent.click(gaps(container)[0]); + + expect(poolChip(container, 'winter')).not.toHaveClass('qti-gap-match-chip-exhausted'); + }); + + it('replaces what a filled gap holds when a response is carried to it', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + await fireEvent.click(poolChip(container, 'summer')); + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['summer', '']); + }); + + it('does not pick up a response that has no use left', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + await fireEvent.click(poolChip(container, 'winter')); + + // Selecting it would leave the learner carrying something no gap can take + expect(poolChip(container, 'winter')).not.toHaveClass('qti-gap-match-chip-selected'); + }); + + it('refuses a response that has no use left', async () => { + // Every choice in example-1 is match-max="1" + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + await fireEvent.click(poolChip(container, 'winter')); + await fireEvent.click(gaps(container)[1]); + + expect(gapTexts(container)).toEqual(['winter', '']); + }); + + it('lets an unlimited response fill more than one gap', async () => { + // gap-match-example-3's choices are all match-max="0" + const { container } = renderAssessmentItem(items['gap-match-example-3'].xml); + + await fireEvent.click(poolChip(container, 'Earth')); + await fireEvent.click(gaps(container)[0]); + await fireEvent.click(poolChip(container, 'Earth')); + await fireEvent.click(gaps(container)[1]); + + expect(gapTexts(container).slice(0, 2)).toEqual(['Earth', 'Earth']); + }); + + it('refuses a placement past max-associations', async () => { + // sv-3's second interaction declares no max-associations, so QTI's default + // of 1 applies + const { container } = renderAssessmentItem(items['q6-gap-match-interaction-sv-3'].xml); + const second = container.querySelectorAll('.qti-gap-match-interaction')[1]; + + await fireEvent.click(poolChip(second, 'winter')); + await fireEvent.click(gaps(second)[0]); + await fireEvent.click(poolChip(second, 'summer')); + await fireEvent.click(gaps(second)[1]); + + expect(gapTexts(second)).toEqual(['winter', '']); + }); +}); + +describe('Review mode', () => { + it('does not place anything', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + interactive: false, + }); + + await fireEvent.click(poolChip(container, 'winter')); + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['', '']); + }); + + it('still shows the answer that was given', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + interactive: false, + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + expect(gapTexts(container)).toEqual(['winter', '']); + }); + + it('does not empty a filled gap', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + interactive: false, + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['winter', '']); + }); +}); + +describe('Response variable', () => { + it('reports each pairing choice first', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'winter')); + await fireEvent.click(gaps(container)[0]); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([['W', 'G1']]); + }); + }); + + it('scores through the mapping when the answer is correct', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'winter')); + await fireEvent.click(gaps(container)[0]); + await fireEvent.click(poolChip(container, 'summer')); + await fireEvent.click(gaps(container)[1]); + + // 1 + 2 from the fixture's qti-mapping, whose keys are 'W G1' and 'Su G2'. + // A transposition would miss every map key and score the -1 default, so + // this is the check that the directed pairs come out the right way round. + await waitFor(() => { + expect(checkAnswer().outcomes.SCORE).toBe(3); + }); + }); + + it('tells the host the learner has interacted', async () => { + const { container, interactionFn } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'winter')); + await fireEvent.click(gaps(container)[0]); + + await waitFor(() => { + expect(interactionFn).toHaveBeenCalled(); + }); + }); +}); + +// SortableJS cannot be driven in jsdom, so a drag is exercised the way the +// abstraction reports it: handleStart announces the source region, then +// handleEnd inserts into the destination region and emits the source's +// remaining items. +function findRegions() { + const mounted = Array.from(document.body.querySelectorAll('*')).find(el => el.__vue__); + const regions = []; + const walk = vm => { + if (!vm) { + return; + } + if (vm.$options.name === 'DraggableRegion') { + regions.push(vm); + } + (vm.$children || []).forEach(walk); + }; + walk(mounted && mounted.__vue__.$root); + return regions; +} + +// A gap's label changes as it fills, so regions are found by the element they +// render rather than by their label. +function regionOfEl(el) { + return findRegions().find(region => region.$el === el); +} + +const gapRegion = (container, index) => regionOfEl(gaps(container)[index]); +const poolRegion = container => regionOfEl(container.querySelector('.qti-gap-match-pool-items')); + +async function drag(source, target, identifier) { + const sourceItemsBeforeDrag = source.items; + source.$emit('dragstart'); + target.$emit('update:items', [...target.items, { identifier }]); + source.$emit( + 'update:items', + sourceItemsBeforeDrag.filter(item => item.identifier !== identifier), + ); + await target.$nextTick(); +} + +describe('Placing by drag', () => { + it('fills a gap dragged onto from the pool', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await drag(poolRegion(container), gapRegion(container, 0), 'W'); + + expect(gapTexts(container)).toEqual(['winter', '']); + }); + + it('leaves out a response with no uses left', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + // An exhausted chip is still shown, but it is not something to pick up + expect(poolRegion(container).items.map(item => item.identifier)).not.toContain('W'); + }); + + it('empties the gap a response is dragged out of', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + await drag(gapRegion(container, 0), poolRegion(container), 'W'); + + expect(gapTexts(container)).toEqual(['', '']); + }); + + it('moves a response from one gap to another', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-3'].xml, { + answerState: { RESPONSE: [['s1', 't1']] }, + }); + + await drag(gapRegion(container, 0), gapRegion(container, 1), 's1'); + + expect(gapTexts(container).slice(0, 2)).toEqual(['', 'Earth']); + }); + + it('moves a response on its last use to another gap', async () => { + // Every choice in example-1 is match-max="1", so the destination can only + // take it once the origin has given it up + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + await drag(gapRegion(container, 0), gapRegion(container, 1), 'W'); + + expect(gapTexts(container)).toEqual(['', 'winter']); + }); + + it('replaces what a filled gap holds', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + await drag(poolRegion(container), gapRegion(container, 0), 'Su'); + + expect(gapTexts(container)).toEqual(['summer', '']); + }); + + it('reports a dragged placement on the response variable', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await drag(poolRegion(container), gapRegion(container, 0), 'W'); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([['W', 'G1']]); + }); + }); + + it('disables every region in review mode', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + interactive: false, + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + expect(poolRegion(container).disabled).toBe(true); + expect(gapRegion(container, 0).disabled).toBe(true); + }); + + it('places nothing dropped in review mode', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + interactive: false, + }); + + await drag(poolRegion(container), gapRegion(container, 0), 'W'); + + expect(gapTexts(container)).toEqual(['', '']); + }); +}); + +describe('Keyboard', () => { + function optionsOf(gap) { + return Array.from(gap.querySelectorAll('[role="option"]')).map(option => ({ + text: option.textContent.trim(), + selected: option.getAttribute('aria-selected'), + })); + } + + // The responses alone, without the empty option that leads them + const responsesOf = gap => optionsOf(gap).slice(1); + + function activeOptionText(gap) { + const id = gap.getAttribute('aria-activedescendant'); + return gap.querySelector(`#${id}`).textContent.trim(); + } + + it('exposes every gap as a listbox that tab can reach', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + gaps(container).forEach(gap => { + expect(gap).toHaveAttribute('role', 'listbox'); + expect(gap).toHaveAttribute('tabindex', '0'); + }); + }); + + it('keeps the response pool out of the tab order', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + const pool = container.querySelector('.qti-gap-match-pool'); + + expect(pool.querySelectorAll('[tabindex="0"]')).toHaveLength(0); + }); + + it('leads the options with an empty one, so an answer can be taken back out', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + expect(optionsOf(gaps(container)[0])[0].text).toBe(emptyOption$()); + }); + + it('offers every response a gap could take', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + expect(responsesOf(gaps(container)[0]).map(option => option.text)).toEqual([ + 'winter', + 'spring', + 'summer', + 'autumn', + ]); + }); + + it('leaves out a response that has no use left', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + // W is match-max="1" and spent, so the other gap cannot take it + expect(responsesOf(gaps(container)[1]).map(option => option.text)).toEqual([ + 'spring', + 'summer', + 'autumn', + ]); + }); + + it("keeps a gap's own response among its options", () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + + const options = optionsOf(gaps(container)[0]); + expect(options.map(option => option.text)).toContain('winter'); + expect(options.filter(option => option.selected === 'true')).toHaveLength(1); + }); + + it('answers a gap when it is tabbed to', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.focus(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['winter', '']); + }); + + it('does not answer a gap a pointer press moved focus to', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + const gap = gaps(container)[0]; + + await fireEvent.mouseDown(gap); + await fireEvent.focus(gap); + + expect(gapTexts(container)).toEqual(['', '']); + }); + + it('puts down a carried response when the keyboard takes over', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'spring')); + await fireEvent.focus(gaps(container)[0]); + + // The gap answers itself on focus, so a response still held from a pointer + // click would leave the learner carrying something they never placed + expect(poolChip(container, 'spring')).not.toHaveClass('qti-gap-match-chip-selected'); + }); + + it('steps to the next response with the down arrow', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + const gap = gaps(container)[0]; + + await fireEvent.focus(gap); + await fireEvent.keyDown(gap, { key: 'ArrowDown' }); + + expect(gapTexts(container)).toEqual(['spring', '']); + }); + + it('steps back to the empty option with the up arrow', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + const gap = gaps(container)[0]; + + await fireEvent.focus(gap); + await fireEvent.keyDown(gap, { key: 'ArrowUp' }); + + expect(gapTexts(container)).toEqual(['', '']); + }); + + it('jumps to the last response with End', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + const gap = gaps(container)[0]; + + await fireEvent.focus(gap); + await fireEvent.keyDown(gap, { key: 'End' }); + + expect(gapTexts(container)).toEqual(['autumn', '']); + }); + + it('empties the gap with Escape', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + const gap = gaps(container)[0]; + + await fireEvent.keyDown(gap, { key: 'Escape' }); + + expect(gapTexts(container)).toEqual(['', '']); + }); + + it('names the option a gap is resting on', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + const gap = gaps(container)[0]; + + await fireEvent.focus(gap); + + expect(activeOptionText(gaps(container)[0])).toBe('winter'); + }); + + it('fills nothing on focus once max-associations is reached', async () => { + // sv-3's second interaction takes QTI's default of one association + const { container } = renderAssessmentItem(items['q6-gap-match-interaction-sv-3'].xml); + const second = container.querySelectorAll('.qti-gap-match-interaction')[1]; + + await fireEvent.focus(gaps(second)[0]); + await fireEvent.focus(gaps(second)[1]); + + // Tabbing through must not raise a refusal on every gap it passes + expect(gapTexts(second)).toEqual(['winter', '']); + }); + + it('is not a listbox in review mode', () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + interactive: false, + }); + + gaps(container).forEach(gap => { + expect(gap).not.toHaveAttribute('role'); + expect(gap).not.toHaveAttribute('tabindex'); + }); + }); +}); + +// The second mode from the design board: a gap is fed by the responses its +// match-group admits rather than by the whole pool. The fixture groups by word +// class, so each group spans several gaps — a group that mapped onto one gap +// would make the highlight itself an answer key. +describe('Answer-specific distractor pools', () => { + const FIXTURE = 'gap-match-distractor-pools'; + + function optionTexts(gap) { + return Array.from(gap.querySelectorAll('[role="option"]')) + .map(option => option.textContent.trim()) + .slice(1); + } + + it('offers a gap only the responses grouped to it', () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + expect(optionTexts(gaps(container)[0])).toEqual(['sat', 'slept', 'barked', 'ran', 'jumped']); + }); + + it("keeps another group's responses out", () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + expect(optionTexts(gaps(container)[1])).toEqual(['mat', 'door', 'tree', 'roof']); + }); + + it('offers the same group to every gap that shares it', () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + // G1, G3 and G4 all take a verb; G2 and G5 both take a noun + expect(optionTexts(gaps(container)[2])).toEqual(optionTexts(gaps(container)[0])); + expect(optionTexts(gaps(container)[3])).toEqual(optionTexts(gaps(container)[0])); + expect(optionTexts(gaps(container)[4])).toEqual(optionTexts(gaps(container)[1])); + }); + + it('highlights every gap a chosen response could go in', async () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + await fireEvent.click(poolChip(container, 'sat')); + + // Every verb slot, so choosing a response never says which gap it answers + expect(gaps(container)[0]).toHaveClass('qti-gap-target'); + expect(gaps(container)[2]).toHaveClass('qti-gap-target'); + expect(gaps(container)[3]).toHaveClass('qti-gap-target'); + expect(gaps(container)[1]).not.toHaveClass('qti-gap-target'); + expect(gaps(container)[4]).not.toHaveClass('qti-gap-target'); + }); + + it('leaves a gap that is already filled out of the highlight', async () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml, { + answerState: { RESPONSE: [['sat', 'G1']] }, + }); + + await fireEvent.click(poolChip(container, 'ran')); + + // G1 holds a response already, so it is not somewhere free to put this one + expect(gaps(container)[0]).not.toHaveClass('qti-gap-target'); + expect(gaps(container)[2]).toHaveClass('qti-gap-target'); + expect(gaps(container)[3]).toHaveClass('qti-gap-target'); + }); + + it('still lets a filled gap be replaced, unhighlighted', async () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml, { + answerState: { RESPONSE: [['sat', 'G1']] }, + }); + + await fireEvent.click(poolChip(container, 'ran')); + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['ran', '', '', '', '']); + }); + + it('highlights only the responses the chosen gap would take', async () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + await fireEvent.click(gaps(container)[0]); + + expect(poolChip(container, 'sat')).toHaveClass('qti-gap-match-chip-candidate'); + expect(poolChip(container, 'ran')).toHaveClass('qti-gap-match-chip-candidate'); + expect(poolChip(container, 'mat')).not.toHaveClass('qti-gap-match-chip-candidate'); + }); + + it('refuses a response carried to a gap that excludes it', async () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + await fireEvent.click(poolChip(container, 'mat')); + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['', '', '', '', '']); + }); + + it('refuses a gap chosen first and then an excluded response', async () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + await fireEvent.click(gaps(container)[0]); + await fireEvent.click(poolChip(container, 'mat')); + + expect(gapTexts(container)).toEqual(['', '', '', '', '']); + }); + + it('still takes a response the gap admits', async () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + await fireEvent.click(poolChip(container, 'sat')); + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['sat', '', '', '', '']); + }); + + it('takes a response in any gap of its group', async () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + await fireEvent.click(poolChip(container, 'barked')); + await fireEvent.click(gaps(container)[3]); + + expect(gapTexts(container)).toEqual(['', '', '', 'barked', '']); + }); + + it('lets one sentence take two responses from the same group', async () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + await fireEvent.click(poolChip(container, 'sat')); + await fireEvent.click(gaps(container)[0]); + await fireEvent.click(poolChip(container, 'ran')); + await fireEvent.click(gaps(container)[2]); + + expect(gapTexts(container)).toEqual(['sat', '', 'ran', '', '']); + }); + + it('refuses a drop from an excluded response', () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml); + + expect(gapRegion(container, 0).accepts({ identifier: 'mat' })).toBe(false); + expect(gapRegion(container, 0).accepts({ identifier: 'sat' })).toBe(true); + expect(gapRegion(container, 2).accepts({ identifier: 'sat' })).toBe(true); + expect(gapRegion(container, 3).accepts({ identifier: 'sat' })).toBe(true); + expect(gapRegion(container, 1).accepts({ identifier: 'mat' })).toBe(true); + expect(gapRegion(container, 4).accepts({ identifier: 'mat' })).toBe(true); + }); + + it('ignores an excluded pair restored from an answer', () => { + const { container } = renderAssessmentItem(items[FIXTURE].xml, { + answerState: { RESPONSE: [['mat', 'G1']] }, + }); + + expect(gapTexts(container)).toEqual(['', '', '', '', '']); + }); + + it('scores through the mapping when each gap takes its own answer', async () => { + const { container, checkAnswer } = renderAssessmentItem(items[FIXTURE].xml); + + const answers = [ + ['sat', 0], + ['mat', 1], + ['ran', 2], + ['barked', 3], + ['door', 4], + ]; + for (const [text, index] of answers) { + await fireEvent.click(poolChip(container, text)); + await fireEvent.click(gaps(container)[index]); + } + + await waitFor(() => { + expect(checkAnswer().outcomes.SCORE).toBe(5); + }); + }); +}); + +// The rule is symmetric, and an author can write it from either end. The +// fixture above has each choice name the gaps it belongs to; this names the +// same relationship the other way round, from the gaps. +const GAP_SIDE_XML = ` + + + + + + red + green + blue + one + two + three +

A colour: .

+

A number: .

+
+
+
`; + +describe('Groups named by the gap', () => { + function optionTexts(gap) { + return Array.from(gap.querySelectorAll('[role="option"]')) + .map(option => option.textContent.trim()) + .slice(1); + } + + it('offers a gap only the responses it names', () => { + const { container } = renderAssessmentItem(GAP_SIDE_XML); + + expect(optionTexts(gaps(container)[0])).toEqual(['red', 'green', 'blue']); + expect(optionTexts(gaps(container)[1])).toEqual(['one', 'two', 'three']); + }); + + it('refuses a response it does not name', async () => { + const { container } = renderAssessmentItem(GAP_SIDE_XML); + + await fireEvent.click(poolChip(container, 'one')); + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['', '']); + }); + + it('takes a response it does name', async () => { + const { container } = renderAssessmentItem(GAP_SIDE_XML); + + await fireEvent.click(poolChip(container, 'green')); + await fireEvent.click(gaps(container)[0]); + + expect(gapTexts(container)).toEqual(['green', '']); + }); +}); + +describe('Refusing a drag', () => { + // Each interaction provides its own drag universe, so an item body holding + // two has two of them and the one under test has to be named. A drag in + // flight is simulated by putting that universe into the state handleStart + // would, since SortableJS itself cannot be driven in jsdom. + function draggableUniverse(scope) { + let vm = findRegions().find(region => !scope || scope.contains(region.$el)); + while (vm) { + const provided = vm._provided || {}; + const found = Object.getOwnPropertySymbols(provided) + .map(symbol => provided[symbol]) + .find(value => value && value.isDragging && value.draggedItem); + if (found) { + return found; + } + vm = vm.$parent; + } + return null; + } + + async function carry(container, identifier, scope) { + const universe = draggableUniverse(scope); + universe.isDragging.value = true; + universe.draggedItem.value = { identifier }; + await poolRegion(container).$nextTick(); + } + + it('marks every gap that would take what is being carried', async () => { + const { container } = renderAssessmentItem(items['gap-match-distractor-pools'].xml); + + await carry(container, 'sat'); + + expect(gaps(container)[0]).toHaveClass('qti-gap-target'); + expect(gaps(container)[2]).toHaveClass('qti-gap-target'); + expect(gaps(container)[3]).toHaveClass('qti-gap-target'); + }); + + it('does not mark a gap that is already filled', async () => { + const { container } = renderAssessmentItem(items['gap-match-distractor-pools'].xml, { + answerState: { RESPONSE: [['sat', 'G1']] }, + }); + + await carry(container, 'ran'); + + expect(gaps(container)[0]).not.toHaveClass('qti-gap-target'); + expect(gaps(container)[2]).toHaveClass('qti-gap-target'); + }); + + it('marks the gaps that would refuse it', async () => { + const { container } = renderAssessmentItem(items['gap-match-distractor-pools'].xml); + + await carry(container, 'sat'); + + expect(gaps(container)[1]).toHaveClass('qti-gap-refusing'); + expect(gaps(container)[4]).toHaveClass('qti-gap-refusing'); + }); + + it('tells a screen reader a refusing gap is unavailable', async () => { + const { container } = renderAssessmentItem(items['gap-match-distractor-pools'].xml); + + await carry(container, 'sat'); + + expect(gaps(container)[1]).toHaveAttribute('aria-disabled', 'true'); + expect(gaps(container)[0]).not.toHaveAttribute('aria-disabled'); + }); + + it('marks every gap for a response with no group of its own', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await carry(container, 'W'); + + gaps(container).forEach(gap => expect(gap).toHaveClass('qti-gap-target')); + }); + + it('marks nothing refusing in an item body with no match-group at all', async () => { + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml); + + await carry(container, 'W'); + + gaps(container).forEach(gap => expect(gap).not.toHaveClass('qti-gap-refusing')); + }); + + it('marks the gap a response is being carried out of as a place to put it back', async () => { + // W is match-max="1" and already spent on G1, so without discounting the + // gap it is leaving it would report that there is nowhere left to put it + const { container } = renderAssessmentItem(items['gap-match-example-1'].xml, { + answerState: { RESPONSE: [['W', 'G1']] }, + }); + const universe = draggableUniverse(); + universe.isDragging.value = true; + universe.draggedItem.value = { identifier: 'W' }; + gapRegion(container, 0).$emit('dragstart'); + await poolRegion(container).$nextTick(); + + expect(gaps(container)[1]).toHaveClass('qti-gap-target'); + }); + + it('marks nothing once max-associations is reached', async () => { + // sv-3's second interaction takes QTI's default of one association + const { container } = renderAssessmentItem(items['q6-gap-match-interaction-sv-3'].xml, { + answerState: { RESPONSE1: [], RESPONSE2: [['W', 'G1']] }, + }); + const second = container.querySelectorAll('.qti-gap-match-interaction')[1]; + + await carry(container, 'Sp', second); + + // G1 already holds the one association the interaction allows + expect(gaps(second)[1]).not.toHaveClass('qti-gap-target'); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/MatchInteraction.spec.js b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/MatchInteraction.spec.js new file mode 100644 index 00000000000..fac25976f5c --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/components/interactions/__tests__/MatchInteraction.spec.js @@ -0,0 +1,877 @@ +import { fireEvent, screen, waitFor, within } from '@testing-library/vue'; +import items from '../../__fixtures__/items'; +import { renderAssessmentItem } from '../../__tests__/helpers'; +import { answerGuideStrings } from '../../AnswerGuide.vue'; +import { slotListboxStrings } from '../../../composables/useSlotListbox'; +import { matchStrings } from '../MatchInteraction.vue'; + +const { + responsePoolLabel$, + entryEmpty$, + entryFilled$, + rowLabel$, + refusedAlreadyInRow$, + refusedMaxAssociations$, +} = matchStrings; + +const { emptyOption$ } = slotListboxStrings; + +// Response content comes from the fixture XML rather than a translation, so it +// is matched on the rendered chip instead of through a *ByText query. +function poolChips(container) { + return Array.from(container.querySelectorAll('.qti-match-pool-entry .qti-match-chip')).map(chip => + chip.textContent.trim(), + ); +} + +function poolChip(container, text) { + return Array.from(container.querySelectorAll('.qti-match-pool-entry .qti-match-chip')).find( + chip => chip.textContent.trim() === text, + ); +} + +function sourceLabels(container) { + return Array.from(container.querySelectorAll('.qti-match-source')).map(source => + source.textContent.trim(), + ); +} + +// The whole answer field is the drop area, so it is what highlights +function fieldOfRow(container, sourceText) { + const row = Array.from(container.querySelectorAll('.qti-match-row')).find( + candidate => candidate.querySelector('.qti-match-source').textContent.trim() === sourceText, + ); + return row.querySelector('.qti-match-field'); +} + +function entriesOfRow(container, sourceText) { + const row = Array.from(container.querySelectorAll('.qti-match-row')).find( + candidate => candidate.querySelector('.qti-match-source').textContent.trim() === sourceText, + ); + return Array.from(row.querySelectorAll('.qti-match-entry')); +} + +describe('Smoke', () => { + it('renders the prompt', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(container).toHaveTextContent( + /Match the following characters to the Shakespeare play they appeared in:/, + ); + }); + + it('renders the shared choose-then-target guide', () => { + renderAssessmentItem(items['match-example-1'].xml); + expect( + screen.getByText(answerGuideStrings.chooseThenTarget$(), { + selector: 'p.qti-selection-instructions', + }), + ).toBeVisible(); + }); + + it('puts the second set in the pool', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(poolChips(container).sort()).toEqual([ + "A Midsummer-Night's Dream", + 'Romeo and Juliet', + 'The Tempest', + ]); + }); + + it('gives the first set a row each', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(sourceLabels(container).sort()).toEqual([ + 'Capulet', + 'Demetrius', + 'Lysander', + 'Prospero', + ]); + }); + + it('does not put the sources in the pool', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(poolChip(container, 'Capulet')).toBeUndefined(); + }); + + it('labels the pool', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(within(container).getByLabelText(responsePoolLabel$())).toBeVisible(); + }); +}); + +describe('Row capacity', () => { + it('offers one empty entry per row when the source takes a single match', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + // every source has match-max="1" + expect(entriesOfRow(container, 'Capulet')).toHaveLength(1); + }); + + it('keeps an empty entry available while an unlimited source has room', () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { RESPONSE: [['r2', 'h1']] }, + }); + // r2 is match-max="0", so it keeps offering another position + expect(entriesOfRow(container, 'Endothermic')).toHaveLength(2); + }); + + it('labels each row for the source it belongs to', () => { + renderAssessmentItem(items['match-example-1'].xml); + expect(screen.getByLabelText(rowLabel$({ source: 'Capulet' }))).toBeVisible(); + }); + + it('names an empty entry after its source', () => { + renderAssessmentItem(items['match-example-1'].xml); + expect(screen.getByLabelText(entryEmpty$({ source: 'Prospero' }))).toBeVisible(); + }); +}); + +describe('Restoring an answer', () => { + it('fills rows from injected answerState', () => { + renderAssessmentItem(items['match-example-1'].xml, { + answerState: { + RESPONSE: [ + ['C', 'R'], + ['P', 'T'], + ], + }, + }); + + expect( + screen.getByLabelText( + entryFilled$({ number: 1, source: 'Capulet', response: 'Romeo and Juliet' }), + ), + ).toBeVisible(); + expect( + screen.getByLabelText( + entryFilled$({ number: 1, source: 'Prospero', response: 'The Tempest' }), + ), + ).toBeVisible(); + }); + + it('keeps a reused target available in the pool', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml, { + answerState: { + RESPONSE: [ + ['D', 'M'], + ['L', 'M'], + ], + }, + }); + + // M is match-max="4", so two uses leave it live + expect(poolChip(container, "A Midsummer-Night's Dream")).not.toHaveClass( + 'qti-match-chip-exhausted', + ); + }); + + it('marks a target spent once its match-max is used up', () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { + RESPONSE: [ + ['r1', 'h1'], + ['r2', 'h1'], + ], + }, + }); + + // h1 is match-max="2" + expect(poolChip(container, 'Birds')).toHaveClass('qti-match-chip-exhausted'); + expect(poolChip(container, 'Mammals')).not.toHaveClass('qti-match-chip-exhausted'); + }); + + it('shows both targets of a row that holds more than one', () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { + RESPONSE: [ + ['r2', 'h1'], + ['r2', 'h3'], + ], + }, + }); + + const filled = entriesOfRow(container, 'Endothermic').filter(entry => + entry.classList.contains('qti-match-entry-filled'), + ); + // the visible chip, not the entry, which also carries the hidden listbox options + expect(filled.map(entry => entry.querySelector('.qti-match-chip').textContent.trim())).toEqual([ + 'Birds', + 'Mammals', + ]); + }); + + it('re-renders when the answer state changes', async () => { + const { setAnswerState } = renderAssessmentItem(items['match-example-1'].xml); + + setAnswerState({ RESPONSE: [['C', 'R']] }); + + await waitFor(() => { + expect( + screen.getByLabelText( + entryFilled$({ number: 1, source: 'Capulet', response: 'Romeo and Juliet' }), + ), + ).toBeVisible(); + }); + }); +}); + +describe('Shuffle', () => { + it('leaves both sets in authored order when shuffle is off', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(sourceLabels(container)).toEqual(['Capulet', 'Demetrius', 'Lysander', 'Prospero']); + expect(poolChips(container)).toEqual([ + "A Midsummer-Night's Dream", + 'Romeo and Juliet', + 'The Tempest', + ]); + }); +}); + +// SortableJS cannot be driven in jsdom, so a drag is exercised the way the +// abstraction reports it: useDraggableRegion's handleEnd inserts into the +// destination region first, then emits the source region's remaining items. +function findRegions() { + const mounted = Array.from(document.body.querySelectorAll('*')).find(el => el.__vue__); + const regions = []; + const walk = vm => { + if (!vm) { + return; + } + if (vm.$options.name === 'DraggableRegion') { + regions.push(vm); + } + (vm.$children || []).forEach(walk); + }; + walk(mounted && mounted.__vue__.$root); + return regions; +} + +function regionLabelled(label) { + return findRegions().find(region => region.label === label); +} + +async function dragInto(targetLabel, identifier, sourceLabel) { + const source = regionLabelled(sourceLabel); + const target = regionLabelled(targetLabel); + const sourceItemsBeforeDrag = source.items; + + source.$emit('dragstart'); + target.$emit('update:items', [...target.items, { identifier }]); + source.$emit( + 'update:items', + sourceItemsBeforeDrag.filter(item => item.identifier !== identifier), + ); + await target.$nextTick(); +} + +describe('Placing by drag', () => { + const poolLabel = () => responsePoolLabel$(); + const rowFor = source => rowLabel$({ source }); + + it('adds a target dragged from the pool onto a row', async () => { + renderAssessmentItem(items['match-example-1'].xml); + + await dragInto(rowFor('Capulet'), 'R', poolLabel()); + + expect( + screen.getByLabelText( + entryFilled$({ number: 1, source: 'Capulet', response: 'Romeo and Juliet' }), + ), + ).toBeVisible(); + }); + + it('leaves the target in the pool, since it has uses left', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + + await dragInto(rowFor('Capulet'), 'M', poolLabel()); + + expect(poolChip(container, "A Midsummer-Night's Dream")).not.toHaveClass( + 'qti-match-chip-exhausted', + ); + }); + + it('moves a target between rows without losing it on the way', async () => { + // h1 has match-max="2" and is already at its limit, so the move only works + // if the origin's use is freed before the destination checks for room + renderAssessmentItem(items['match-example-2'].xml, { + answerState: { + RESPONSE: [ + ['r1', 'h1'], + ['r2', 'h1'], + ], + }, + }); + + await dragInto(rowFor('Possess Gills'), 'h1', rowFor('Asexual')); + + expect( + screen.getByLabelText( + entryFilled$({ number: 1, source: 'Possess Gills', response: 'Birds' }), + ), + ).toBeVisible(); + expect(screen.getByLabelText(entryEmpty$({ source: 'Asexual' }))).toBeVisible(); + }); + + it('takes the pairing out when a target is dragged back to the pool', async () => { + renderAssessmentItem(items['match-example-1'].xml, { + answerState: { RESPONSE: [['C', 'R']] }, + }); + + await dragInto(poolLabel(), 'R', rowFor('Capulet')); + + expect(screen.getByLabelText(entryEmpty$({ source: 'Capulet' }))).toBeVisible(); + }); + + it('replaces the pairing when dropped onto a row with no room', async () => { + renderAssessmentItem(items['match-example-1'].xml, { + answerState: { RESPONSE: [['C', 'R']] }, + }); + + // Capulet is match-max="1": the drop has nowhere to be added, so rather + // than springing back it takes the place of what is already there + await dragInto(rowFor('Capulet'), 'T', poolLabel()); + + expect( + screen.getByLabelText( + entryFilled$({ number: 1, source: 'Capulet', response: 'The Tempest' }), + ), + ).toBeVisible(); + }); + + it('updates the response variable after a drag', async () => { + const { checkAnswer } = renderAssessmentItem(items['match-example-1'].xml); + + await dragInto(rowFor('Prospero'), 'T', poolLabel()); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([['P', 'T']]); + }); + }); + + it('leaves an exhausted target out of the pool region items', () => { + renderAssessmentItem(items['match-example-2'].xml, { + answerState: { + RESPONSE: [ + ['r1', 'h1'], + ['r2', 'h1'], + ], + }, + }); + + const identifiers = regionLabelled(responsePoolLabel$()).items.map(item => item.identifier); + expect(identifiers).not.toContain('h1'); + expect(identifiers).toContain('h2'); + }); + + it('disables the regions in review mode', () => { + renderAssessmentItem(items['match-example-1'].xml, { interactive: false }); + + expect(findRegions().every(region => region.disabled)).toBe(true); + }); +}); + +describe('Placing by click', () => { + it('places a selected response into the entry clicked next', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'Romeo and Juliet')); + await fireEvent.click(entriesOfRow(container, 'Capulet')[0]); + + expect( + screen.getByLabelText( + entryFilled$({ number: 1, source: 'Capulet', response: 'Romeo and Juliet' }), + ), + ).toBeVisible(); + }); + + it('places into an entry chosen before the response', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + + await fireEvent.click(entriesOfRow(container, 'Prospero')[0]); + await fireEvent.click(poolChip(container, 'The Tempest')); + + expect( + screen.getByLabelText( + entryFilled$({ number: 1, source: 'Prospero', response: 'The Tempest' }), + ), + ).toBeVisible(); + }); + + it('highlights every answer field a selected response could go in', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(container.querySelectorAll('.qti-match-field-target')).toHaveLength(0); + + await fireEvent.click(poolChip(container, 'Romeo and Juliet')); + + // one field per source, and the response has four uses + expect(container.querySelectorAll('.qti-match-field-target')).toHaveLength(4); + expect(poolChip(container, 'Romeo and Juliet')).toHaveClass('qti-match-chip-selected'); + }); + + it('highlights a filled field too, since a response can be replaced', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml, { + answerState: { RESPONSE: [['C', 'R']] }, + }); + + await fireEvent.click(poolChip(container, 'The Tempest')); + + expect(entriesOfRow(container, 'Capulet')[0]).toHaveClass('qti-match-entry-filled'); + expect(fieldOfRow(container, 'Capulet')).toHaveClass('qti-match-field-target'); + }); + + it('does not highlight a field already holding that response', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml, { + answerState: { RESPONSE: [['C', 'R']] }, + }); + + await fireEvent.click(poolChip(container, 'Romeo and Juliet')); + + // Capulet takes one response and already has this one + expect(fieldOfRow(container, 'Capulet')).not.toHaveClass('qti-match-field-target'); + expect(fieldOfRow(container, 'Demetrius')).toHaveClass('qti-match-field-target'); + }); + + it('highlights the pool once an entry is selected', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + + await fireEvent.click(entriesOfRow(container, 'Capulet')[0]); + + expect(container.querySelectorAll('.qti-match-chip-candidate')).toHaveLength(3); + }); + + it('deselects a response when it is clicked again', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'The Tempest')); + await fireEvent.click(poolChip(container, 'The Tempest')); + + expect(container.querySelectorAll('.qti-match-field-target')).toHaveLength(0); + }); + + it('takes a pairing back out when its entry is clicked', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml, { + answerState: { RESPONSE: [['C', 'R']] }, + }); + + await fireEvent.click(entriesOfRow(container, 'Capulet')[0]); + + expect(screen.getByLabelText(entryEmpty$({ source: 'Capulet' }))).toBeVisible(); + }); + + it('ignores a response with no uses left', async () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { + RESPONSE: [ + ['r1', 'h1'], + ['r2', 'h1'], + ], + }, + }); + + await fireEvent.click(poolChip(container, 'Birds')); + + expect(container.querySelectorAll('.qti-match-field-target')).toHaveLength(0); + }); + + it('ignores clicks in review mode', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml, { + interactive: false, + }); + + await fireEvent.click(poolChip(container, 'The Tempest')); + await fireEvent.click(entriesOfRow(container, 'Capulet')[0]); + + expect(screen.getByLabelText(entryEmpty$({ source: 'Capulet' }))).toBeVisible(); + }); +}); + +describe('Response variable', () => { + it('reports each pairing source first', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['match-example-1'].xml); + + await fireEvent.click(poolChip(container, 'Romeo and Juliet')); + await fireEvent.click(entriesOfRow(container, 'Capulet')[0]); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([['C', 'R']]); + }); + }); + + it('reports every pairing of a row holding more than one', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['match-example-2'].xml); + + await fireEvent.click(poolChip(container, 'Birds')); + await fireEvent.click(entriesOfRow(container, 'Endothermic')[0]); + await fireEvent.click(poolChip(container, 'Mammals')); + await fireEvent.click(entriesOfRow(container, 'Endothermic')[1]); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toEqual([ + ['r2', 'h1'], + ['r2', 'h3'], + ]); + }); + }); + + it('scores through the mapping when the answer is correct', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['match-example-1'].xml); + + const matches = [ + ['Capulet', 'Romeo and Juliet'], + ['Demetrius', "A Midsummer-Night's Dream"], + ['Lysander', "A Midsummer-Night's Dream"], + ['Prospero', 'The Tempest'], + ]; + for (const [source, target] of matches) { + await fireEvent.click(poolChip(container, target)); + await fireEvent.click(entriesOfRow(container, source)[0]); + } + + // 1 + 0.5 + 0.5 + 1 from the fixture's qti-mapping. A source/target + // transposition would miss every map key and score 0, so this is the check + // that the directed pairs come out the right way round. + await waitFor(() => { + expect(checkAnswer().outcomes.SCORE).toBe(3); + }); + }); + + it('does not write to the variable in review mode', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['match-example-1'].xml, { + interactive: false, + }); + + await fireEvent.click(poolChip(container, 'The Tempest')); + + expect(checkAnswer().answerState.RESPONSE).toBeNull(); + }); +}); + +function noticeText(container) { + return container.querySelector('.qti-match-notice').textContent.trim(); +} + +describe('Explaining a refusal', () => { + it('says nothing until something is actually refused', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(noticeText(container)).toBe(''); + }); + + it('explains that the pairing already exists', async () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { RESPONSE: [['r2', 'h1']] }, + }); + + await fireEvent.click(poolChip(container, 'Birds')); + await fireEvent.click(entriesOfRow(container, 'Endothermic')[1]); + + expect(noticeText(container)).toBe( + refusedAlreadyInRow$({ response: 'Birds', source: 'Endothermic' }), + ); + }); + + it('explains that the response has no matches left', async () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { + RESPONSE: [ + ['r1', 'h1'], + ['r2', 'h1'], + ], + }, + }); + + // h1 is spent, so it is no longer offered at all; reach it from a row + await fireEvent.click(entriesOfRow(container, 'Asexual')[0]); + await fireEvent.click(poolChip(container, 'Birds')); + + expect(noticeText(container)).toBe(''); + }); + + it('announces the explanation as a live region', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(container.querySelector('.qti-match-notice')).toHaveAttribute('role', 'status'); + }); + + it('drops the explanation once the learner places something', async () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { RESPONSE: [['r2', 'h1']] }, + }); + + await fireEvent.click(poolChip(container, 'Birds')); + await fireEvent.click(entriesOfRow(container, 'Endothermic')[1]); + expect(noticeText(container)).not.toBe(''); + + await fireEvent.click(poolChip(container, 'Mammals')); + await fireEvent.click(entriesOfRow(container, 'Endothermic')[1]); + + await waitFor(() => expect(noticeText(container)).toBe('')); + }); +}); + +describe('Reaching max-associations', () => { + // match-example-2 caps the question at three matches + const atTheCap = { + RESPONSE: [ + ['r1', 'h1'], + ['r2', 'h2'], + ['r3', 'h3'], + ], + }; + + it('says nothing while there is still room', () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { RESPONSE: [['r1', 'h1']] }, + }); + expect(noticeText(container)).toBe(''); + }); + + it('warns as soon as the last match is made, before anything is refused', async () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { + RESPONSE: [ + ['r1', 'h1'], + ['r2', 'h2'], + ], + }, + }); + expect(noticeText(container)).toBe(''); + + await fireEvent.click(poolChip(container, 'Mammals')); + await fireEvent.click(entriesOfRow(container, 'Possess Gills')[0]); + + await waitFor(() => expect(noticeText(container)).toBe(refusedMaxAssociations$({ count: 3 }))); + }); + + it('keeps the warning up when a further placement is refused', async () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: atTheCap, + }); + + await fireEvent.click(poolChip(container, 'Reptiles')); + await fireEvent.click(entriesOfRow(container, 'Bear Live Young')[0]); + + expect(noticeText(container)).toBe(refusedMaxAssociations$({ count: 3 })); + }); + + it('drops the warning once a match is taken back out', async () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: atTheCap, + }); + expect(noticeText(container)).toBe(refusedMaxAssociations$({ count: 3 })); + + await fireEvent.click(entriesOfRow(container, 'Asexual')[0]); + + await waitFor(() => expect(noticeText(container)).toBe('')); + }); +}); + +describe('Keyboard', () => { + function optionsOf(entry) { + return Array.from(entry.querySelectorAll('[role="option"]')).map(option => ({ + text: option.textContent.trim(), + selected: option.getAttribute('aria-selected'), + })); + } + + // The responses alone, without the empty option that leads them + function responsesOf(entry) { + return optionsOf(entry).slice(1); + } + + function activeOptionText(entry) { + const id = entry.getAttribute('aria-activedescendant'); + return entry.querySelector(`#${id}`).textContent.trim(); + } + + it('exposes every entry as a listbox that tab can reach', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + + container.querySelectorAll('.qti-match-entry').forEach(entry => { + expect(entry).toHaveAttribute('role', 'listbox'); + expect(entry).toHaveAttribute('tabindex', '0'); + }); + }); + + it('keeps the response pool out of the tab order', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + const pool = container.querySelector('.qti-match-pool'); + + expect(pool.querySelectorAll('[tabindex="0"]')).toHaveLength(0); + }); + + it('offers only the targets still available for that row', () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { + RESPONSE: [ + ['r1', 'h1'], + ['r2', 'h1'], + ], + }, + }); + + // h1 is spent at match-max="2", so no other row may take it + const texts = responsesOf(entriesOfRow(container, 'Possess Gills')[0]).map( + option => option.text, + ); + expect(texts).toEqual(['Reptiles', 'Mammals']); + }); + + it("keeps an entry's own target among its options", () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml, { + answerState: { RESPONSE: [['C', 'R']] }, + }); + + const options = optionsOf(entriesOfRow(container, 'Capulet')[0]); + expect(options.map(option => option.text)).toContain('Romeo and Juliet'); + expect(options.filter(option => option.selected === 'true')).toHaveLength(1); + }); + + it("answers a row's first entry when it is tabbed to", async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + const entry = entriesOfRow(container, 'Capulet')[0]; + const first = responsesOf(entry)[0].text; + + await fireEvent.focus(entry); + + expect(entriesOfRow(container, 'Capulet')[0]).toHaveAttribute( + 'aria-label', + entryFilled$({ number: 1, source: 'Capulet', response: first }), + ); + }); + + it('leaves the add-another position alone when it is tabbed to', async () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { RESPONSE: [['r2', 'h1']] }, + }); + + // r2 is match-max="0", so its row offers a trailing empty position + await fireEvent.focus(entriesOfRow(container, 'Endothermic')[1]); + + expect( + entriesOfRow(container, 'Endothermic').filter(entry => + entry.classList.contains('qti-match-entry-filled'), + ), + ).toHaveLength(1); + }); + + it('does not answer an entry a pointer press focused', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + const entry = entriesOfRow(container, 'Capulet')[0]; + + await fireEvent.mouseDown(entry); + await fireEvent.focus(entry); + + expect(screen.getByLabelText(entryEmpty$({ source: 'Capulet' }))).toBeVisible(); + }); + + it('cycles the value in place with the arrow keys', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + const responses = responsesOf(entriesOfRow(container, 'Capulet')[0]).map(o => o.text); + + await fireEvent.focus(entriesOfRow(container, 'Capulet')[0]); + expect(activeOptionText(entriesOfRow(container, 'Capulet')[0])).toBe(responses[0]); + + await fireEvent.keyDown(entriesOfRow(container, 'Capulet')[0], { key: 'ArrowDown' }); + expect(activeOptionText(entriesOfRow(container, 'Capulet')[0])).toBe(responses[1]); + + await fireEvent.keyDown(entriesOfRow(container, 'Capulet')[0], { key: 'ArrowUp' }); + expect(activeOptionText(entriesOfRow(container, 'Capulet')[0])).toBe(responses[0]); + }); + + it('takes the pairing back out by stepping past the first response', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + + await fireEvent.focus(entriesOfRow(container, 'Capulet')[0]); + await fireEvent.keyDown(entriesOfRow(container, 'Capulet')[0], { key: 'ArrowUp' }); + + expect(activeOptionText(entriesOfRow(container, 'Capulet')[0])).toBe(emptyOption$()); + expect(screen.getByLabelText(entryEmpty$({ source: 'Capulet' }))).toBeVisible(); + }); + + it('starts the add-another position on the empty option', async () => { + const { container } = renderAssessmentItem(items['match-example-2'].xml, { + answerState: { RESPONSE: [['r2', 'h1']] }, + }); + + // r2 is match-max="0", so its row offers a trailing position that tabbing to + // leaves unanswered + await fireEvent.focus(entriesOfRow(container, 'Endothermic')[1]); + + expect(optionsOf(entriesOfRow(container, 'Endothermic')[1])[0].text).toBe(emptyOption$()); + expect(activeOptionText(entriesOfRow(container, 'Endothermic')[1])).toBe(emptyOption$()); + }); + + it('jumps to the ends with Home and End', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + // the empty option included: Home lands on it, End on the last response + const candidates = optionsOf(entriesOfRow(container, 'Capulet')[0]).map(o => o.text); + + await fireEvent.focus(entriesOfRow(container, 'Capulet')[0]); + await fireEvent.keyDown(entriesOfRow(container, 'Capulet')[0], { key: 'End' }); + expect(activeOptionText(entriesOfRow(container, 'Capulet')[0])).toBe( + candidates[candidates.length - 1], + ); + + await fireEvent.keyDown(entriesOfRow(container, 'Capulet')[0], { key: 'Home' }); + expect(activeOptionText(entriesOfRow(container, 'Capulet')[0])).toBe(candidates[0]); + }); + + it('takes the pairing back out on Escape', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml, { + answerState: { RESPONSE: [['C', 'R']] }, + }); + + await fireEvent.keyDown(entriesOfRow(container, 'Capulet')[0], { key: 'Escape' }); + + expect(screen.getByLabelText(entryEmpty$({ source: 'Capulet' }))).toBeVisible(); + }); + + it('records the pairing built with the keyboard in the response variable', async () => { + const { container, checkAnswer } = renderAssessmentItem(items['match-example-1'].xml); + + await fireEvent.focus(entriesOfRow(container, 'Capulet')[0]); + + await waitFor(() => { + expect(checkAnswer().answerState.RESPONSE).toHaveLength(1); + expect(checkAnswer().answerState.RESPONSE[0][0]).toBe('C'); + }); + }); + + it('shows the stepper affordance on an operable entry', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml); + expect(entriesOfRow(container, 'Capulet')[0].querySelector('.qti-slot-stepper')).not.toBeNull(); + }); + + it('is not reachable or operable in review mode', async () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml, { + interactive: false, + }); + const entry = entriesOfRow(container, 'Capulet')[0]; + + expect(entry).not.toHaveAttribute('tabindex'); + expect(entry.querySelectorAll('[role="option"]')).toHaveLength(0); + expect(entry.querySelector('.qti-slot-stepper')).toBeNull(); + + await fireEvent.focus(entry); + expect(screen.getByLabelText(entryEmpty$({ source: 'Capulet' }))).toBeVisible(); + }); +}); + +describe('Review mode', () => { + it('marks the interaction read-only', async () => { + const { container, setInteractive } = renderAssessmentItem(items['match-example-1'].xml); + setInteractive(false); + + await waitFor(() => { + expect(container.querySelector('.qti-match-readonly')).toBeInTheDocument(); + }); + }); + + it('still shows the restored answer', () => { + const { container } = renderAssessmentItem(items['match-example-1'].xml, { + interactive: false, + answerState: { RESPONSE: [['C', 'R']] }, + }); + + expect(within(container).getByLabelText(rowLabel$({ source: 'Capulet' }))).toHaveTextContent( + 'Romeo and Juliet', + ); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/composables/__tests__/useAssociateSlots.spec.js b/kolibri/plugins/qti_viewer/frontend/composables/__tests__/useAssociateSlots.spec.js new file mode 100644 index 00000000000..1b0f9cb911a --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/composables/__tests__/useAssociateSlots.spec.js @@ -0,0 +1,292 @@ +import { ref } from 'vue'; +import useAssociateSlots from '../useAssociateSlots.js'; + +const CHOICES = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6']; + +// Slot sides within an association row +const FIRST = 0; +const SECOND = 1; + +function setup({ identifiers = CHOICES, rowCount = 3 } = {}) { + return useAssociateSlots(identifiers, rowCount); +} + +describe('slots', () => { + it('starts with the requested number of empty rows', () => { + const { slots } = setup({ rowCount: 3 }); + expect(slots.value).toEqual([ + [null, null], + [null, null], + [null, null], + ]); + }); + + it('keeps what is already placed when the row count grows', () => { + const rowCount = ref(1); + const { slots, place } = setup({ rowCount }); + place('C1', 0, FIRST); + + rowCount.value = 2; + + expect(slots.value).toEqual([ + ['C1', null], + [null, null], + ]); + }); + + it('drops the trailing rows when the row count shrinks', () => { + const rowCount = ref(2); + const { slots, pool, place } = setup({ rowCount }); + place('C1', 1, FIRST); + + rowCount.value = 1; + + expect(slots.value).toEqual([[null, null]]); + expect(pool.value).toContain('C1'); + }); +}); + +describe('pool', () => { + it('starts as every response, in presentation order', () => { + const { pool } = setup(); + expect(pool.value).toEqual(CHOICES); + }); + + it('drops a response once it is placed, preserving the order of the rest', () => { + const { pool, place } = setup(); + place('C3', 0, FIRST); + expect(pool.value).toEqual(['C1', 'C2', 'C4', 'C5', 'C6']); + }); + + it('takes the response back when its slot is cleared', () => { + const { pool, place, clear } = setup(); + place('C3', 0, FIRST); + clear(0, FIRST); + expect(pool.value).toEqual(CHOICES); + }); +}); + +describe('place', () => { + it('fills an empty slot from the pool', () => { + const { slots, place } = setup(); + place('C1', 1, SECOND); + expect(slots.value[1]).toEqual([null, 'C1']); + }); + + it('returns the displaced response to the pool when the incoming one came from the pool', () => { + const { slots, pool, place } = setup(); + place('C1', 0, FIRST); + place('C2', 0, FIRST); + + expect(slots.value[0]).toEqual(['C2', null]); + expect(pool.value).toContain('C1'); + }); + + it('swaps two responses when the incoming one came from another slot', () => { + const { slots, pool, place } = setup(); + place('C1', 0, FIRST); + place('C2', 1, FIRST); + + place('C1', 1, FIRST); + + expect(slots.value[0]).toEqual(['C2', null]); + expect(slots.value[1]).toEqual(['C1', null]); + expect(pool.value).not.toContain('C1'); + expect(pool.value).not.toContain('C2'); + }); + + it('empties the origin slot when moving a response to an empty slot', () => { + const { slots, place } = setup(); + place('C1', 0, FIRST); + place('C1', 2, SECOND); + + expect(slots.value[0]).toEqual([null, null]); + expect(slots.value[2]).toEqual([null, 'C1']); + }); + + it('leaves state untouched when the response is already in that slot', () => { + const { slots, place } = setup(); + place('C1', 0, FIRST); + const before = slots.value; + + place('C1', 0, FIRST); + + expect(slots.value).toEqual(before); + }); + + it('ignores a row index outside the available rows', () => { + const { slots, pool, place } = setup({ rowCount: 2 }); + place('C1', 5, FIRST); + + expect(slots.value).toEqual([ + [null, null], + [null, null], + ]); + expect(pool.value).toEqual(CHOICES); + }); +}); + +describe('remove', () => { + it('sends a placed response back to the pool', () => { + const { slots, pool, place, remove } = setup(); + place('C4', 1, SECOND); + + remove('C4'); + + expect(slots.value[1]).toEqual([null, null]); + expect(pool.value).toEqual(CHOICES); + }); + + it('ignores a response that is not placed', () => { + const { slots, pool, remove } = setup(); + remove('C4'); + + expect(slots.value).toEqual([ + [null, null], + [null, null], + [null, null], + ]); + expect(pool.value).toEqual(CHOICES); + }); +}); + +describe('candidatesFor', () => { + it('offers every response for an empty slot', () => { + const { candidatesFor } = setup(); + expect(candidatesFor(0, FIRST)).toEqual(CHOICES); + }); + + it('excludes responses placed in another slot', () => { + const { candidatesFor, place } = setup(); + place('C2', 1, FIRST); + place('C5', 2, SECOND); + + expect(candidatesFor(0, FIRST)).toEqual(['C1', 'C3', 'C4', 'C6']); + }); + + it("keeps the slot's own response as a candidate", () => { + const { candidatesFor, place } = setup(); + place('C2', 0, FIRST); + + expect(candidatesFor(0, FIRST)).toEqual(['C1', 'C2', 'C3', 'C4', 'C5', 'C6']); + }); +}); + +describe('pairs', () => { + it('is empty until a row is filled on both sides', () => { + const { pairs, place } = setup(); + place('C1', 0, FIRST); + expect(pairs.value).toEqual([]); + }); + + it('reports each fully filled row as a pair', () => { + const { pairs, place } = setup(); + place('C1', 0, FIRST); + place('C4', 0, SECOND); + place('C2', 2, FIRST); + place('C5', 2, SECOND); + + expect(pairs.value).toEqual([ + ['C1', 'C4'], + ['C2', 'C5'], + ]); + }); + + it('omits a row again once one of its sides is cleared', () => { + const { pairs, place, clear } = setup(); + place('C1', 0, FIRST); + place('C4', 0, SECOND); + clear(0, SECOND); + + expect(pairs.value).toEqual([]); + }); +}); + +describe('hydrate', () => { + it('restores slots from a response variable value', () => { + const { slots, pool, hydrate } = setup(); + hydrate([ + ['C1', 'C4'], + ['C2', 'C5'], + ]); + + expect(slots.value).toEqual([ + ['C1', 'C4'], + ['C2', 'C5'], + [null, null], + ]); + expect(pool.value).toEqual(['C3', 'C6']); + }); + + it('round-trips the value derived from pairs', () => { + const { pairs, place, hydrate } = setup(); + place('C3', 0, FIRST); + place('C6', 0, SECOND); + const derived = pairs.value; + + hydrate(derived); + + expect(pairs.value).toEqual(derived); + }); + + it('clears any existing placements', () => { + const { slots, place, hydrate } = setup(); + place('C1', 2, FIRST); + + hydrate([['C2', 'C5']]); + + expect(slots.value).toEqual([ + ['C2', 'C5'], + [null, null], + [null, null], + ]); + }); + + it('drops pairs naming a response the item does not declare', () => { + const { slots, hydrate } = setup(); + hydrate([ + ['C1', 'NOPE'], + ['C2', 'C5'], + ]); + + expect(slots.value[0]).toEqual(['C2', 'C5']); + }); + + it('drops a later pair that reuses an already placed response', () => { + const { slots, hydrate } = setup(); + hydrate([ + ['C1', 'C4'], + ['C1', 'C5'], + ]); + + expect(slots.value).toEqual([ + ['C1', 'C4'], + [null, null], + [null, null], + ]); + }); + + it('ignores malformed entries and non-array values', () => { + const { slots, hydrate } = setup(); + + hydrate([['C1'], 'C2 C5', null, ['C3', 'C6']]); + expect(slots.value[0]).toEqual(['C3', 'C6']); + + hydrate(null); + expect(slots.value).toEqual([ + [null, null], + [null, null], + [null, null], + ]); + }); + + it('keeps only as many pairs as there are rows', () => { + const { slots, hydrate } = setup({ rowCount: 1 }); + hydrate([ + ['C1', 'C4'], + ['C2', 'C5'], + ]); + + expect(slots.value).toEqual([['C1', 'C4']]); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/composables/__tests__/useMatchRows.spec.js b/kolibri/plugins/qti_viewer/frontend/composables/__tests__/useMatchRows.spec.js new file mode 100644 index 00000000000..aad158a0c4e --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/composables/__tests__/useMatchRows.spec.js @@ -0,0 +1,463 @@ +import { ref } from 'vue'; +import useMatchRows, { PAIR_ORDER } from '../useMatchRows.js'; + +// Shaped after match-example-1: four sources used once each, three targets +// reusable up to four times. +const SOURCES = ['C', 'D', 'L', 'P']; +const TARGETS = ['M', 'R', 'T']; +const MATCH_MAX = { C: 1, D: 1, L: 1, P: 1, M: 4, R: 4, T: 4 }; + +function setup({ + sourceIds = SOURCES, + targetIds = TARGETS, + matchMax = MATCH_MAX, + maxAssociations = 0, + pairOrder = undefined, +} = {}) { + return useMatchRows({ + sourceIds, + targetIds, + matchMaxOf: identifier => matchMax[identifier] ?? 1, + maxAssociations, + ...(pairOrder ? { pairOrder } : {}), + }); +} + +describe('rows', () => { + it('starts with one empty row per source', () => { + const { rows } = setup(); + expect(rows.value).toEqual([[], [], [], []]); + }); + + it('offers one empty position per row until the row is full', () => { + const { entriesFor, place } = setup(); + expect(entriesFor(0)).toEqual([null]); + + place('M', 0, 0); + + // sources have match-max 1, so the row is now full and offers no more + expect(entriesFor(0)).toEqual(['M']); + }); + + it('keeps offering an empty position while a row has room', () => { + // match-example-2 shape: unlimited sources, targets capped at 2 + const { entriesFor, place } = setup({ + sourceIds: ['r1', 'r2'], + targetIds: ['h1', 'h2', 'h3'], + matchMax: { r1: 0, r2: 0, h1: 2, h2: 2, h3: 2 }, + }); + + place('h1', 1, 0); + expect(entriesFor(1)).toEqual(['h1', null]); + + place('h3', 1, 1); + expect(entriesFor(1)).toEqual(['h1', 'h3', null]); + }); +}); + +describe('reuse across rows', () => { + it('lets a target be paired with several sources', () => { + const { rows, place } = setup(); + place('M', 1, 0); + place('M', 2, 0); + + expect(rows.value[1]).toEqual(['M']); + expect(rows.value[2]).toEqual(['M']); + }); + + it('keeps the target available until its match-max is spent', () => { + const { isExhausted, remainingOf, place } = setup({ + matchMax: { ...MATCH_MAX, M: 2 }, + }); + expect(remainingOf('M')).toBe(2); + + place('M', 0, 0); + expect(isExhausted('M')).toBe(false); + + place('M', 1, 0); + expect(remainingOf('M')).toBe(0); + expect(isExhausted('M')).toBe(true); + }); + + it('refuses a target that has no uses left', () => { + const { rows, place } = setup({ matchMax: { ...MATCH_MAX, M: 1 } }); + place('M', 0, 0); + + place('M', 1, 0); + + expect(rows.value[1]).toEqual([]); + }); + + it('frees a use when the target is taken out again', () => { + const { remainingOf, place, clear } = setup({ matchMax: { ...MATCH_MAX, M: 1 } }); + place('M', 0, 0); + expect(remainingOf('M')).toBe(0); + + clear(0, 0); + + expect(remainingOf('M')).toBe(1); + }); + + it('treats match-max="0" on a target as one use per source', () => { + const { remainingOf } = setup({ matchMax: { ...MATCH_MAX, M: 0 } }); + expect(remainingOf('M')).toBe(SOURCES.length); + }); +}); + +describe('place', () => { + it('appends at the empty position', () => { + const { rows, place } = setup({ + sourceIds: ['r1'], + targetIds: ['h1', 'h2'], + matchMax: { r1: 0, h1: 2, h2: 2 }, + }); + + place('h1', 0, 0); + place('h2', 0, 1); + + expect(rows.value[0]).toEqual(['h1', 'h2']); + }); + + it('replaces the target already at that position', () => { + const { rows, place } = setup(); + place('M', 0, 0); + + place('R', 0, 0); + + expect(rows.value[0]).toEqual(['R']); + }); + + it('never pairs a source with the same target twice', () => { + const { rows, place } = setup({ + sourceIds: ['r1'], + targetIds: ['h1', 'h2'], + matchMax: { r1: 0, h1: 2, h2: 2 }, + }); + place('h1', 0, 0); + + place('h1', 0, 1); + + expect(rows.value[0]).toEqual(['h1']); + }); + + it('refuses to exceed the row capacity from its source match-max', () => { + const { rows, place } = setup(); + place('M', 0, 0); + + // C has match-max 1, so there is no second position to place into + place('R', 0, 1); + + expect(rows.value[0]).toEqual(['M']); + }); + + it('refuses to exceed max-associations across all rows', () => { + const { pairs, place } = setup({ maxAssociations: 2 }); + place('M', 0, 0); + place('R', 1, 0); + + place('T', 2, 0); + + expect(pairs.value).toHaveLength(2); + }); + + it('ignores an unknown target or a row that does not exist', () => { + const { rows, place } = setup(); + place('NOPE', 0, 0); + place('M', 9, 0); + + expect(rows.value).toEqual([[], [], [], []]); + }); +}); + +describe('candidatesFor', () => { + it('offers every target for an empty row', () => { + const { candidatesFor } = setup(); + expect(candidatesFor(0, 0)).toEqual(TARGETS); + }); + + it('excludes a target already paired with that source', () => { + const { candidatesFor, place } = setup({ + sourceIds: ['r1'], + targetIds: ['h1', 'h2', 'h3'], + matchMax: { r1: 0, h1: 2, h2: 2, h3: 2 }, + }); + place('h1', 0, 0); + + expect(candidatesFor(0, 1)).toEqual(['h2', 'h3']); + }); + + it('excludes a target with no uses left, but keeps it for its own position', () => { + const { candidatesFor, place } = setup({ matchMax: { ...MATCH_MAX, M: 1 } }); + place('M', 0, 0); + + expect(candidatesFor(1, 0)).toEqual(['R', 'T']); + expect(candidatesFor(0, 0)).toContain('M'); + }); + + it('never offers a placement that place would refuse', () => { + const { candidatesFor, canPlace, place } = setup({ matchMax: { ...MATCH_MAX, M: 1 } }); + place('M', 0, 0); + + [0, 1, 2, 3].forEach(rowIndex => { + candidatesFor(rowIndex, 0).forEach(identifier => { + const isOwnValue = identifier === 'M' && rowIndex === 0; + expect(isOwnValue || canPlace(identifier, rowIndex, 0)).toBe(true); + }); + }); + }); +}); + +describe('isPlaceable', () => { + it('is false once the target has no uses left', () => { + const { isPlaceable, place } = setup({ matchMax: { ...MATCH_MAX, M: 1 } }); + place('M', 0, 0); + + expect(isPlaceable('M')).toBe(false); + expect(isPlaceable('R')).toBe(true); + }); + + it('stays true at max-associations while a pairing could be replaced', () => { + // Replacing does not add a pair, so the cap does not strand the target + const { isPlaceable, place } = setup({ maxAssociations: 2 }); + place('M', 0, 0); + place('R', 1, 0); + + expect(isPlaceable('T')).toBe(true); + }); + + it('is false once every row already holds that target', () => { + const { isPlaceable, place } = setup({ + sourceIds: ['r1', 'r2'], + targetIds: ['h1'], + matchMax: { r1: 0, r2: 0, h1: 2 }, + }); + place('h1', 0, 0); + place('h1', 1, 0); + + expect(isPlaceable('h1')).toBe(false); + }); + + it('stays true while a full row could still have a target replaced', () => { + const { isPlaceable, place } = setup(); + // every source is match-max="1", so all rows fill up + place('M', 0, 0); + place('M', 1, 0); + place('M', 2, 0); + place('M', 3, 0); + + // R can still replace M in any of them + expect(isPlaceable('R')).toBe(true); + }); +}); + +describe('canPlace while moving between rows', () => { + it('discounts the use held by the row the target is leaving', () => { + const { canPlace, place } = setup({ matchMax: { ...MATCH_MAX, M: 1 } }); + place('M', 0, 0); + + // M has no uses left, so it cannot be added to another row + expect(canPlace('M', 1, 0)).toBe(false); + // but moving it out of row 0 frees the one use it holds + expect(canPlace('M', 1, 0, { fromRow: 0 })).toBe(true); + }); + + it('still refuses a row that already holds that target', () => { + const { canPlace, place } = setup({ + sourceIds: ['r1', 'r2'], + targetIds: ['h1', 'h2'], + matchMax: { r1: 0, r2: 0, h1: 1, h2: 1 }, + }); + place('h1', 0, 0); + + expect(canPlace('h1', 0, 1, { fromRow: 0 })).toBe(false); + }); + + it('does not let a move breach max-associations, since the total is unchanged', () => { + const { canPlace, place } = setup({ maxAssociations: 1 }); + place('M', 0, 0); + + expect(canPlace('R', 1, 0)).toBe(false); + expect(canPlace('M', 1, 0, { fromRow: 0 })).toBe(true); + }); +}); + +describe('pairs', () => { + it('reports each entry as a directed pair, source first', () => { + const { pairs, place } = setup(); + place('R', 0, 0); + place('M', 1, 0); + + expect(pairs.value).toEqual([ + ['C', 'R'], + ['D', 'M'], + ]); + }); + + it('reports every entry of a multi-target row', () => { + const { pairs, place } = setup({ + sourceIds: ['r1', 'r2'], + targetIds: ['h1', 'h3'], + matchMax: { r1: 0, r2: 0, h1: 2, h3: 2 }, + }); + place('h1', 1, 0); + place('h3', 1, 1); + + expect(pairs.value).toEqual([ + ['r2', 'h1'], + ['r2', 'h3'], + ]); + }); +}); + +describe('hydrate', () => { + it('restores rows from a response variable value', () => { + const { rows, hydrate } = setup(); + hydrate([ + ['C', 'R'], + ['D', 'M'], + ['L', 'M'], + ['P', 'T'], + ]); + + expect(rows.value).toEqual([['R'], ['M'], ['M'], ['T']]); + }); + + it('round-trips the value derived from pairs', () => { + const { pairs, place, hydrate } = setup(); + place('R', 0, 0); + place('M', 2, 0); + const derived = pairs.value; + + hydrate(derived); + + expect(pairs.value).toEqual(derived); + }); + + it('drops pairs naming an unknown source or target', () => { + const { rows, hydrate } = setup(); + hydrate([ + ['NOPE', 'M'], + ['C', 'NOPE'], + ['D', 'R'], + ]); + + expect(rows.value).toEqual([[], ['R'], [], []]); + }); + + it('drops a repeated pair', () => { + const { rows, hydrate } = setup({ + sourceIds: ['r1'], + targetIds: ['h1'], + matchMax: { r1: 0, h1: 2 }, + }); + hydrate([ + ['r1', 'h1'], + ['r1', 'h1'], + ]); + + expect(rows.value[0]).toEqual(['h1']); + }); + + it('drops pairs that would break a match-max', () => { + const { rows, hydrate } = setup({ matchMax: { ...MATCH_MAX, M: 1 } }); + hydrate([ + ['C', 'M'], + ['D', 'M'], + ]); + + expect(rows.value).toEqual([['M'], [], [], []]); + }); + + it('drops pairs beyond max-associations', () => { + const { pairs, hydrate } = setup({ maxAssociations: 1 }); + hydrate([ + ['C', 'R'], + ['D', 'M'], + ]); + + expect(pairs.value).toEqual([['C', 'R']]); + }); + + it('ignores malformed entries and non-array values', () => { + const { rows, hydrate } = setup(); + + hydrate([['C'], 'D M', null, ['L', 'T']]); + expect(rows.value).toEqual([[], [], ['T'], []]); + + hydrate(null); + expect(rows.value).toEqual([[], [], [], []]); + }); +}); + +describe('reactive choices', () => { + it('follows a change to the pool order', () => { + const targetIds = ref(['M', 'R', 'T']); + const { pool } = setup({ targetIds }); + + targetIds.value = ['T', 'R', 'M']; + + expect(pool.value).toEqual(['T', 'R', 'M']); + }); +}); + +// `qti-match-interaction` authors a pair source-first, `qti-gap-match-interaction` +// pool-choice-first. The rows are the same either way; only the value the +// interaction reads and writes is turned around. +describe('pair order', () => { + it('names the source first by default', () => { + const { pairs, place } = setup(); + place('M', 0, 0); + + expect(pairs.value).toEqual([['C', 'M']]); + }); + + it('names the pool choice first when asked to', () => { + const { pairs, place } = setup({ pairOrder: PAIR_ORDER.POOL_FIRST }); + place('M', 0, 0); + + expect(pairs.value).toEqual([['M', 'C']]); + }); + + it('reads a pool-first value back into the row it came from', () => { + const { rows, hydrate } = setup({ pairOrder: PAIR_ORDER.POOL_FIRST }); + hydrate([ + ['M', 'C'], + ['R', 'L'], + ]); + + expect(rows.value).toEqual([['M'], [], ['R'], []]); + }); + + it('round-trips its own pairs', () => { + const { pairs, rows, place, hydrate } = setup({ pairOrder: PAIR_ORDER.POOL_FIRST }); + place('M', 0, 0); + place('T', 3, 0); + const written = pairs.value; + + hydrate(written); + + expect(pairs.value).toEqual(written); + expect(rows.value).toEqual([['M'], [], [], ['T']]); + }); + + it('still applies every limit to a pool-first value', () => { + const { pairs, hydrate } = setup({ + maxAssociations: 1, + pairOrder: PAIR_ORDER.POOL_FIRST, + }); + hydrate([ + ['R', 'C'], + ['M', 'D'], + ]); + + expect(pairs.value).toEqual([['R', 'C']]); + }); + + it('drops a pool-first pair naming an unknown source', () => { + const { rows, hydrate } = setup({ pairOrder: PAIR_ORDER.POOL_FIRST }); + // Source-first order, so 'C' lands where a target belongs and is refused + hydrate([['C', 'M']]); + + expect(rows.value).toEqual([[], [], [], []]); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/composables/useAssociateSlots.js b/kolibri/plugins/qti_viewer/frontend/composables/useAssociateSlots.js new file mode 100644 index 00000000000..07c7c1f5575 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/composables/useAssociateSlots.js @@ -0,0 +1,115 @@ +import { computed, ref, unref } from 'vue'; + +const SLOTS_PER_ROW = 2; + +function emptyRow() { + return new Array(SLOTS_PER_ROW).fill(null); +} + +// Find where an identifier is in the slots +function locate(rows, identifier) { + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + const side = rows[rowIndex].indexOf(identifier); + if (side !== -1) { + return [rowIndex, side]; + } + } + return null; +} + +export default function useAssociateSlots(identifiers, rowCount) { + const stored = ref([]); + + // Normalise on read rather than resizing on change + const slots = computed(() => + Array.from({ length: unref(rowCount) }, (_, index) => + stored.value[index] ? [...stored.value[index]] : emptyRow(), + ), + ); + + const placed = computed(() => new Set(slots.value.flat().filter(Boolean))); + + const pool = computed(() => unref(identifiers).filter(id => !placed.value.has(id))); + + const pairs = computed(() => slots.value.filter(row => row.every(Boolean))); + + function place(identifier, rowIndex, side) { + const next = slots.value; + const displaced = next[rowIndex]?.[side]; + // Out of range, or the response is already where it is being put + if (displaced === undefined || displaced === identifier) { + return; + } + const origin = locate(next, identifier); + next[rowIndex][side] = identifier; + if (origin) { + next[origin[0]][origin[1]] = displaced; + } + // A response arriving from the pool displaces its predecessor back to the + // pool implicitly, since the pool is whatever is not in a slot. + stored.value = next; + } + + function clear(rowIndex, side) { + const next = slots.value; + if (!next[rowIndex]) { + return; + } + next[rowIndex][side] = null; + stored.value = next; + } + + // Send a response back to the pool, wherever it currently sits + function remove(identifier) { + const next = slots.value; + const origin = locate(next, identifier); + if (!origin) { + return; + } + next[origin[0]][origin[1]] = null; + stored.value = next; + } + + function candidatesFor(rowIndex, side) { + const current = slots.value[rowIndex]?.[side] ?? null; + // The slot's own response stays a candidate so the candidate can step past + // it and back again without losing their answer. + return unref(identifiers).filter(id => !placed.value.has(id) || id === current); + } + + function hydrate(value) { + const known = new Set(unref(identifiers)); + const next = []; + const used = new Set(); + + for (const pair of Array.isArray(value) ? value : []) { + if (next.length >= unref(rowCount)) { + break; + } + // Skip any pair that is not an array of the right length, or contains + // identifiers that are not known or already used. + if (!Array.isArray(pair) || pair.length !== SLOTS_PER_ROW) { + continue; + } + if (pair.some(id => !known.has(id) || used.has(id))) { + continue; + } + pair.forEach(id => used.add(id)); + next.push([...pair]); + } + + stored.value = next; + } + + return { + slots, + pool, + placed, + pairs, + place, + clear, + remove, + candidatesFor, + hydrate, + }; +} diff --git a/kolibri/plugins/qti_viewer/frontend/composables/useMatchRows.js b/kolibri/plugins/qti_viewer/frontend/composables/useMatchRows.js new file mode 100644 index 00000000000..57ca8eac43d --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/composables/useMatchRows.js @@ -0,0 +1,286 @@ +import { computed, ref, unref } from 'vue'; + +function countUses(rows, identifier) { + return rows.reduce((total, row) => total + row.filter(id => id === identifier).length, 0); +} + +function countPairs(rows) { + return rows.reduce((total, row) => total + row.length, 0); +} + +/** + * Why a placement was refused. The rules live in one place so the explanation + * a learner is shown can never drift from the decision that produced it. + */ +export const PROBLEM = Object.freeze({ + UNKNOWN: 'unknown', + ALREADY_HERE: 'alreadyHere', + ALREADY_IN_ROW: 'alreadyInRow', + ROW_FULL: 'rowFull', + MAX_ASSOCIATIONS: 'maxAssociations', + NO_USES_LEFT: 'noUsesLeft', +}); + +/** + * Which end of a directed pair the row's source is. A pair is ordered, so an + * interaction has to say which of its two sets the base type names first: + * `qti-match-interaction` authors its pairs source-first, `qti-gap-match-interaction` + * authors them pool-choice-first. + */ +export const PAIR_ORDER = Object.freeze({ + ROW_FIRST: 'rowFirst', + POOL_FIRST: 'poolFirst', +}); + +/** + * Track which targets are paired with each source, and the limits on doing so. + * @param {object} options - The interaction's choices and limits + * @param {import('vue').Ref|string[]} options.sourceIds - First set, + * in row order + * @param {import('vue').Ref|string[]} options.targetIds - Second set, + * in pool presentation order + * @param {Function} options.matchMaxOf - `(identifier) => number`, the choice's + * own `match-max`, where 0 means no limit + * @param {import('vue').Ref|number} [options.maxAssociations] - Cap on + * the total number of pairs, where 0 means no limit + * @param {string} [options.pairOrder] - Which end of each directed pair the row's + * source is, see {@link PAIR_ORDER}. Fixed for an interaction, so not a ref. + * @returns {object} The row state and the operations over it + */ +export default function useMatchRows({ + sourceIds, + targetIds, + matchMaxOf, + maxAssociations = 0, + pairOrder = PAIR_ORDER.ROW_FIRST, +}) { + const stored = ref([]); + + const sources = () => unref(sourceIds); + const targets = () => unref(targetIds); + + // Reading and writing a pair go through these two, so the order can never be + // applied in one direction and forgotten in the other. + const poolFirst = pairOrder === PAIR_ORDER.POOL_FIRST; + const toPair = (source, target) => (poolFirst ? [target, source] : [source, target]); + const fromPair = pair => (poolFirst ? [pair[1], pair[0]] : [pair[0], pair[1]]); + + // Normalise on read, so a change to the choices cannot leave stale rows + const rows = computed(() => + sources().map((_, index) => (stored.value[index] ? [...stored.value[index]] : [])), + ); + + // `match-max="0"` is unlimited, but a directed pair is unique, so a source can + // hold each target at most once and a target can serve each source at most once + function rowCapacity(rowIndex) { + return matchMaxOf(sources()[rowIndex]) || targets().length; + } + + function targetCapacity(identifier) { + return matchMaxOf(identifier) || sources().length; + } + + function maxPairs() { + return unref(maxAssociations) || Infinity; + } + + const usesOf = computed(() => { + const counts = new Map(); + rows.value.flat().forEach(id => counts.set(id, (counts.get(id) || 0) + 1)); + return counts; + }); + + function remainingOf(identifier) { + return targetCapacity(identifier) - (usesOf.value.get(identifier) || 0); + } + + /** Targets that still have a use left, in presentation order. */ + const pool = computed(() => targets()); + + /** + * Whether the pool should show this target as spent. + * @param {string} identifier - A target identifier + * @returns {boolean} True once every use of it has been taken + */ + function isExhausted(identifier) { + return remainingOf(identifier) <= 0; + } + + /** + * The addressable positions in a row: its entries, plus one trailing empty + * position while the row still has room, which is the "add" affordance. + * @param {number} rowIndex - Which row + * @returns {Array} Target identifiers, with a trailing null for the + * empty position + */ + function entriesFor(rowIndex) { + const row = rows.value[rowIndex] || []; + return row.length < rowCapacity(rowIndex) ? [...row, null] : [...row]; + } + + function currentValue(rowIndex, entryIndex) { + return entriesFor(rowIndex)[entryIndex] ?? null; + } + + /** + * Whether placing this target here would be accepted. `candidatesFor` is + * built from it, so the keyboard can never offer a placement that `place` + * would then refuse. + * @param {string} identifier - The target being placed + * @param {number} rowIndex - Which row + * @param {number} entryIndex - Which position within the row + * @param {object} [options] - Placement context + * @param {?number} [options.fromRow] - A row the target is being moved out + * of, whose use is discounted. Without it a target on its last use would + * report that it cannot be moved to a row it is about to have room in. + * @returns {boolean} True when the placement is allowed + */ + function placementProblem(identifier, rowIndex, entryIndex, { fromRow = null } = {}) { + const row = rows.value[rowIndex]; + if (!row || !targets().includes(identifier)) { + return PROBLEM.UNKNOWN; + } + const current = currentValue(rowIndex, entryIndex); + if (current === identifier) { + return PROBLEM.ALREADY_HERE; + } + // A directed pair is unique: a source is never paired with the same target twice + if (row.includes(identifier)) { + return PROBLEM.ALREADY_IN_ROW; + } + const movingOut = + fromRow !== null && fromRow !== rowIndex && (rows.value[fromRow] || []).includes(identifier); + const appending = current === null; + if (appending && row.length >= rowCapacity(rowIndex)) { + return PROBLEM.ROW_FULL; + } + if (appending && !movingOut && countPairs(rows.value) >= maxPairs()) { + return PROBLEM.MAX_ASSOCIATIONS; + } + // Replacing frees the use the displaced target was holding, and so does + // moving the target out of the row it currently sits in + if (remainingOf(identifier) + (movingOut ? 1 : 0) <= 0) { + return PROBLEM.NO_USES_LEFT; + } + return null; + } + + function canPlace(identifier, rowIndex, entryIndex, options) { + return placementProblem(identifier, rowIndex, entryIndex, options) === null; + } + + /** + * Whether this target has any legal destination left. A target can be spent + * by its own match-max, but it can just as well be stranded by + * max-associations or by every row already holding it, so the pool asks this + * rather than counting uses alone. + * @param {string} identifier - A target identifier + * @returns {boolean} True while some entry would still accept it + */ + function isPlaceable(identifier) { + return rows.value.some((row, rowIndex) => + entriesFor(rowIndex).some((_, entryIndex) => canPlace(identifier, rowIndex, entryIndex)), + ); + } + + function place(identifier, rowIndex, entryIndex) { + if (!canPlace(identifier, rowIndex, entryIndex)) { + return; + } + const next = rows.value; + if (currentValue(rowIndex, entryIndex) === null) { + next[rowIndex].push(identifier); + } else { + next[rowIndex][entryIndex] = identifier; + } + stored.value = next; + } + + function clear(rowIndex, entryIndex) { + const next = rows.value; + if (!next[rowIndex] || next[rowIndex][entryIndex] === undefined) { + return; + } + next[rowIndex].splice(entryIndex, 1); + stored.value = next; + } + + /** + * Take a target out of a row, wherever in it the target sits. + * @param {string} identifier - The target to take out + * @param {number} rowIndex - The row to take it out of + */ + function remove(identifier, rowIndex) { + const next = rows.value; + const entryIndex = (next[rowIndex] || []).indexOf(identifier); + if (entryIndex === -1) { + return; + } + next[rowIndex].splice(entryIndex, 1); + stored.value = next; + } + + function candidatesFor(rowIndex, entryIndex) { + const current = currentValue(rowIndex, entryIndex); + return targets().filter( + identifier => identifier === current || canPlace(identifier, rowIndex, entryIndex), + ); + } + + /** One directed pair per entry, ordered as the interaction's base type requires. */ + const pairs = computed(() => + rows.value.flatMap((row, index) => row.map(identifier => toPair(sources()[index], identifier))), + ); + + function hydrate(value) { + const rowOf = new Map(sources().map((identifier, index) => [identifier, index])); + const known = new Set(targets()); + const next = sources().map(() => []); + + for (const pair of Array.isArray(value) ? value : []) { + if (!Array.isArray(pair) || pair.length !== 2) { + continue; + } + const [source, target] = fromPair(pair); + const rowIndex = rowOf.get(source); + // Drop anything a learner could not have produced, so a malformed value + // cannot push the rows past the limits the item declares + if (rowIndex === undefined || !known.has(target)) { + continue; + } + if (next[rowIndex].includes(target)) { + continue; + } + if (next[rowIndex].length >= rowCapacity(rowIndex)) { + continue; + } + if (countUses(next, target) >= targetCapacity(target)) { + continue; + } + if (countPairs(next) >= maxPairs()) { + continue; + } + next[rowIndex].push(target); + } + + stored.value = next; + } + + return { + rows, + pool, + pairs, + entriesFor, + currentValue, + remainingOf, + isExhausted, + isPlaceable, + canPlace, + placementProblem, + place, + clear, + remove, + candidatesFor, + hydrate, + }; +} diff --git a/kolibri/plugins/qti_viewer/frontend/composables/useSlotListbox.js b/kolibri/plugins/qti_viewer/frontend/composables/useSlotListbox.js new file mode 100644 index 00000000000..ca0cf979276 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/composables/useSlotListbox.js @@ -0,0 +1,228 @@ +/** + * Keyboard operation for interactions whose answer slots are filled from a set + * of candidate responses. + * + * Tab reaches the slots and skips the response pool entirely; each slot behaves + * like a compact select being stepped through rather than a menu that opens. A + * slot is a `role="listbox"` owning visually hidden options, with + * `aria-activedescendant` naming the current one, so a screen reader announces + * the value without focus ever leaving the slot. + * + * The options lead with an empty one, so undoing an answer is a step like any + * other rather than a shortcut a learner has to know about. + * + * A slot is addressed by however many parts the interaction needs — associate + * uses (row, side), match uses (row, entry) — and those parts are passed + * straight back to the callbacks. + * @module useSlotListbox + */ +import { h, ref, unref } from 'vue'; +import { createTranslator } from 'kolibri/utils/i18n'; + +export const slotListboxStrings = createTranslator('SlotListboxStrings', { + emptyOption: { + message: 'No response', + context: + 'The option a learner steps to with the arrow keys to take their answer back out of a slot, leaving it empty', + }, +}); + +const { emptyOption$ } = slotListboxStrings; + +const ARROW_OFFSETS = { ArrowDown: 1, ArrowUp: -1 }; + +const EMPTY = null; + +let listboxCounter = 0; + +export default function useSlotListbox({ + candidatesFor, + currentValue, + commit, + clear, + labelFor, + disabled, + onKeyboardFocus, + autoFillOnFocus = () => true, +}) { + listboxCounter += 1; + const idPrefix = `qti-slot-listbox-${listboxCounter}`; + + const focusedKey = ref(null); + + // Only a focus the learner tabbed to fills the slot. A pointer press also + // focuses it, and filling then would answer a slot the learner only meant to + // choose as a click target. A press always precedes the focus it causes, + // which is what distinguishes the two. + let focusFromPointer = false; + + const isDisabled = () => Boolean(unref(disabled)); + const keyOf = address => address.join('-'); + const optionId = (address, index) => `${idPrefix}-${keyOf(address)}-${index}`; + + function optionsFor(address) { + return [EMPTY, ...candidatesFor(...address)]; + } + + function activeIndex(address) { + const current = currentValue(...address) || EMPTY; + return Math.max(0, optionsFor(address).indexOf(current)); + } + + // Stepping is one operation over the options, whichever of them is landed on + function chooseAt(address, index) { + const options = optionsFor(address); + const chosen = options[Math.min(Math.max(index, 0), options.length - 1)]; + if (chosen === EMPTY) { + clear(...address); + return; + } + commit(chosen, ...address); + } + + function notePointerDown() { + focusFromPointer = true; + } + + function handleFocus(address) { + if (isDisabled()) { + return; + } + focusedKey.value = keyOf(address); + const fromPointer = focusFromPointer; + focusFromPointer = false; + if (fromPointer) { + return; + } + if (onKeyboardFocus) { + onKeyboardFocus(...address); + } + // An interaction whose slots grow on demand refuses this for its trailing + // "add another" position: tabbing through would otherwise answer every one. + if (!autoFillOnFocus(...address)) { + return; + } + const candidates = candidatesFor(...address); + if (candidates.length && !currentValue(...address)) { + commit(candidates[0], ...address); + } + } + + function handleBlur(address) { + if (focusedKey.value === keyOf(address)) { + focusedKey.value = null; + } + } + + function handleKeydown(event, address) { + if (isDisabled()) { + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + clear(...address); + return; + } + const options = optionsFor(address); + if (options.length < 2) { + return; + } + let next; + if (event.key in ARROW_OFFSETS) { + next = activeIndex(address) + ARROW_OFFSETS[event.key]; + } else if (event.key === 'Home') { + next = 0; + } else if (event.key === 'End') { + next = options.length - 1; + } else { + return; + } + event.preventDefault(); + chooseAt(address, next); + } + + return { + /** A press that never became a focus must not suppress the next tab */ + forgetPointer() { + focusFromPointer = false; + }, + + /** + * Attributes making the slot element the listbox. Empty while disabled. + * @param {...(number|string)} address - The slot's address parts + * @returns {object} Attributes to spread onto the slot element + */ + slotAttrs(...address) { + if (isDisabled()) { + return {}; + } + return { + role: 'listbox', + tabindex: '0', + 'data-focus': 'true', + 'aria-activedescendant': + focusedKey.value === keyOf(address) ? optionId(address, activeIndex(address)) : null, + }; + }, + + /** + * Native listeners for the slot element. + * @param {...(number|string)} address - The slot's address parts + * @returns {object} Listeners to bind natively on the slot element + */ + handlers(...address) { + return { + mousedown: notePointerDown, + touchstart: notePointerDown, + keydown: event => handleKeydown(event, address), + focus: () => handleFocus(address), + blur: () => handleBlur(address), + }; + }, + + /** + * The up/down affordance, telling a sighted keyboard learner that the slot's + * value can be stepped. Decorative: the options below carry the value for a + * screen reader, and CSS reveals this only while the slot has keyboard focus. + * @returns {?object} The vnode for the stepper, or null while disabled + */ + renderStepper() { + if (isDisabled()) { + return null; + } + return h('span', { class: 'qti-slot-stepper', attrs: { 'aria-hidden': 'true' } }, [ + h('KIcon', { props: { icon: 'chevronUp' }, class: 'qti-slot-stepper-icon' }), + h('KIcon', { props: { icon: 'chevronDown' }, class: 'qti-slot-stepper-icon' }), + ]); + }, + + /** + * The options the slot owns. Visually hidden, so a screen reader steps + * through the responses a sighted learner sees in the pool while the slot + * shows only the current one. + * @param {...(number|string)} address - The slot's address parts + * @returns {object} The vnode for the slot's option list + */ + renderOptions(...address) { + const current = currentValue(...address) || EMPTY; + return h( + 'ul', + { class: 'qti-visually-hidden', attrs: { role: 'presentation' } }, + optionsFor(address).map((identifier, index) => + h( + 'li', + { + key: identifier || 'empty', + attrs: { + id: optionId(address, index), + role: 'option', + 'aria-selected': String(identifier === current), + }, + }, + identifier === EMPTY ? emptyOption$() : labelFor(identifier), + ), + ), + ); + }, + }; +} diff --git a/kolibri/plugins/qti_viewer/frontend/styles/qti-v3-core.scss b/kolibri/plugins/qti_viewer/frontend/styles/qti-v3-core.scss index ca9ae1d892a..4a18c9d0aa7 100644 --- a/kolibri/plugins/qti_viewer/frontend/styles/qti-v3-core.scss +++ b/kolibri/plugins/qti_viewer/frontend/styles/qti-v3-core.scss @@ -115,6 +115,30 @@ $container-lg: 1170px; border: 0; } +/* +* The up/down affordance on a slot listbox (see useSlotListbox). Only a learner +* stepping the slot with the arrow keys needs it, so it is revealed on keyboard +* focus alone: KDS's modality tracker sets body[modality=keyboard] when focus +* arrives by key rather than by pointer. Hidden with `visibility` rather than +* `display` so the slot's contents do not shift as focus moves along a row. + */ +.qti-slot-stepper { + display: inline-flex; + flex-direction: column; + flex-shrink: 0; + margin-inline-start: 8px; + visibility: hidden; +} + +body[modality='keyboard'] [role='listbox']:focus > .qti-slot-stepper { + visibility: visible; +} + +.qti-slot-stepper-icon { + width: 12px; + height: 12px; +} + /* ============================= Special flex styles ============================= */ diff --git a/kolibri/plugins/qti_viewer/frontend/utils/__tests__/choices.spec.js b/kolibri/plugins/qti_viewer/frontend/utils/__tests__/choices.spec.js new file mode 100644 index 00000000000..f34d0368d91 --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/utils/__tests__/choices.spec.js @@ -0,0 +1,117 @@ +import { render } from '@testing-library/vue'; +import { findVNodes, getComponentTag } from '../choices'; + +// Stand-ins for the QTI elements SafeHTML registers. What matters is that they +// are real components carrying a `tag`, so the vnodes below are the same shape +// an interaction is handed at runtime rather than hand-built lookalikes. +function qtiElement(tag) { + return { + name: tag, + tag, + props: { identifier: { type: String, default: null } }, + render(h) { + return h('span', this.$slots.default); + }, + }; +} + +const Gap = qtiElement('qti-gap'); +const GapText = qtiElement('qti-gap-text'); + +// Render `buildContent(h)` as the default slot of an interaction-like component +// and return what findVNodes picks out of it. +function search(buildContent, tags) { + let found = null; + const Interaction = { + name: 'interaction', + render(h) { + found = findVNodes(this.$slots.default, tags); + return h('div', this.$slots.default); + }, + }; + render({ + render(h) { + return h(Interaction, buildContent(h)); + }, + }); + return found; +} + +const identifiersOf = vnodes => vnodes.map(vnode => vnode.componentOptions.propsData.identifier); + +describe('findVNodes', () => { + it('finds a gap nested inside authored markup', () => { + const found = search( + h => [h('blockquote', [h('p', ['Now is the ', h(Gap, { props: { identifier: 'G1' } })])])], + ['qti-gap'], + ); + + expect(identifiersOf(found)).toEqual(['G1']); + }); + + it('returns matches in document order, whatever depth they sit at', () => { + // Shaped after gap-match-example-2, whose gaps live in table cells + const found = search( + h => [ + h(GapText, { props: { identifier: 's1' } }, ['Earth']), + h('table', [ + h('tbody', [ + h('tr', [ + h('td', [h('p', [h(Gap, { props: { identifier: 't1' } })])]), + h('td', [h('p', [h(Gap, { props: { identifier: 't2' } })])]), + ]), + h('tr', [h('td', [h(Gap, { props: { identifier: 't3' } })])]), + ]), + ]), + h('p', ['trailing ', h(Gap, { props: { identifier: 't4' } })]), + ], + ['qti-gap'], + ); + + expect(identifiersOf(found)).toEqual(['t1', 't2', 't3', 't4']); + }); + + it('matches any of the tags it is given', () => { + const found = search( + h => [ + h(GapText, { props: { identifier: 'W' } }, ['winter']), + h('p', [h(Gap, { props: { identifier: 'G1' } })]), + ], + ['qti-gap-text', 'qti-gap'], + ); + + expect(identifiersOf(found)).toEqual(['W', 'G1']); + }); + + it('leaves content that matches nothing alone', () => { + const found = search(h => [h('p', ['just a passage'])], ['qti-gap']); + + expect(found).toEqual([]); + }); + + it('does not descend into a match', () => { + // A gap-text holding a gap is not valid QTI, but it pins the contract: the + // caller gets the outer element, not something buried inside it. + const found = search( + h => [h(GapText, { props: { identifier: 'W' } }, [h(Gap, { props: { identifier: 'G1' } })])], + ['qti-gap-text', 'qti-gap'], + ); + + expect(identifiersOf(found)).toEqual(['W']); + }); + + it('walks text and empty content without tripping over it', () => { + const found = search( + h => ['bare text', h('p', []), h('div', [h(Gap, { props: { identifier: 'G1' } })])], + ['qti-gap'], + ); + + expect(identifiersOf(found)).toEqual(['G1']); + }); + + it('reads the tags the same way getComponentTag does', () => { + const found = search(h => [h('p', [h(Gap, { props: { identifier: 'G1' } })])], ['qti-gap']); + + expect(getComponentTag(found[0])).toBe('qti-gap'); + }); +}); diff --git a/kolibri/plugins/qti_viewer/frontend/utils/choices.js b/kolibri/plugins/qti_viewer/frontend/utils/choices.js new file mode 100644 index 00000000000..ff1377d110a --- /dev/null +++ b/kolibri/plugins/qti_viewer/frontend/utils/choices.js @@ -0,0 +1,127 @@ +/** + * Reading the choice vnodes an interaction receives in its default slot. + * + * The choice-based interactions are all handed the item body's authored content + * as vnodes and have to pick their own choices out of it, label them, and decide + * what order to present them in. That is the same work in each one. + * @module choices + */ +import get from 'lodash/get'; +import shuffled from 'kolibri-common/utils/shuffled'; +import { coerceBoolean } from './qti/values'; + +/** + * The QTI tag a vnode was rendered from, or undefined for plain content. + * @param {object} vnode - A vnode from an interaction's default slot + * @returns {string|undefined} e.g. `qti-simple-choice` + */ +export function getComponentTag(vnode) { + return get(vnode, ['componentOptions', 'Ctor', 'extendOptions', 'tag']); +} + +/** + * Every vnode in a tree that was rendered from one of the given QTI tags, in + * document order. + * + * Most interactions pick their choices out of the default slot with a flat + * filter, because that is where the author has to put them. An interaction + * whose answer slots are embedded in its own flow content cannot: a `qti-gap` + * sits wherever the passage puts it — inside a blockquote, a table cell, a + * paragraph — so finding them means walking the tree. + * + * A match is not descended into: the QTI elements this looks for do not nest + * inside one another. + * @param {Array} vnodes - The vnodes to search, e.g. an interaction's slot content + * @param {string[]} tags - The QTI tags to match, e.g. `['qti-gap']` + * @returns {Array} The matching vnodes, in the order they appear in the item body + */ +export function findVNodes(vnodes, tags) { + const found = []; + + function visit(nodes) { + for (const vnode of nodes || []) { + if (!vnode) { + continue; + } + if (tags.includes(getComponentTag(vnode))) { + found.push(vnode); + continue; + } + // A component keeps the children it was given under componentOptions; + // a plain element keeps them directly. Text vnodes have neither. + visit(vnode.componentOptions ? vnode.componentOptions.children : vnode.children); + } + } + + visit(vnodes); + return found; +} + +/** + * Whether a choice is marked `fixed`, and so keeps its authored position when + * the rest are shuffled. + * @param {object} vnode - A choice vnode + * @returns {boolean} True when the choice declares fixed="true" + */ +export function isFixed(vnode) { + return coerceBoolean(get(vnode, ['componentOptions', 'propsData', 'fixed'])); +} + +/** + * Plain text for a vnode's content. Image content contributes its alt text, + * which is all a choice made of a single image has to identify it by. + * @param {object} vnode - Any vnode + * @returns {string} The text, or '' when there is none + */ +export function vnodeToText(vnode) { + if (!vnode) { + return ''; + } + if (vnode.text) { + return vnode.text.trim(); + } + const alt = vnode.componentOptions?.propsData?.alt ?? vnode.data?.attrs?.alt; + if (alt) { + return String(alt).trim(); + } + const children = vnode.componentOptions?.children ?? vnode.children; + if (children) { + return children.map(vnodeToText).join(' ').trim(); + } + return ''; +} + +/** + * A choice's own content as a single line of text, for accessible names and + * live-region announcements. + * @param {object} vnode - A choice vnode + * @returns {string} The collapsed text of the choice's children + */ +export function choiceText(vnode) { + return (get(vnode, ['componentOptions', 'children']) || []) + .map(vnodeToText) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Present choices in their authored order, or shuffled when the item asks for + * it. Choices marked `fixed` keep their authored position either way, and the + * shuffle is seeded so a learner sees the same order every time they return. + * @param {Array<{fixed: boolean}>} choices - Choices in authored order + * @param {object} options - Ordering options + * @param {boolean} options.shuffle - The interaction's shuffle attribute + * @param {string} options.seed - Seed for the shuffle, usually the candidate id + * @returns {Array} The choices in presentation order + */ +export function orderChoices(choices, { shuffle, seed }) { + if (!shuffle) { + return choices; + } + const shuffleable = shuffled( + choices.filter(choice => !choice.fixed), + seed, + ); + return choices.map(choice => (choice.fixed ? choice : shuffleable.shift())); +}