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()));
+}