diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js
index e1b2e27350..a3c831b10c 100644
--- a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js
+++ b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js
@@ -215,6 +215,50 @@ export const ORDERING_ITEM_XML = `
`;
+/**
+ * Demo item 7: associate interaction — learner connects countries to capitals.
+ * Uses cardinality="multiple" and base-type="pair" per QTI 3.0 §3.2.13.
+ */
+export const ASSOCIATE_ITEM_XML = `
+
+
+
+ choice_kenya choice_nairobi
+ choice_japan choice_tokyo
+ choice_brazil choice_brasilia
+
+
+
+
+
+ Match each country with its capital city:
+ Kenya
+ Nairobi
+ Japan
+ Tokyo
+ Brazil
+ Brasília
+ Mombasa
+ Osaka
+
+
+`;
+
/**
* Hardcoded items covering different states:
* - item-1: single-select choice interaction
@@ -223,6 +267,7 @@ export const ORDERING_ITEM_XML = `
* - item-text-entry: string text-entry with case-sensitive answers
* - item-free-response: free-response text-entry (no correct answer)
* - item-ordering: ordering interaction (planets by distance from the Sun)
+ * - item-associate: associate interaction (countries to capitals, with distractors)
*/
export const INITIAL_ASSESSMENTS = [
{
@@ -255,4 +300,9 @@ export const INITIAL_ASSESSMENTS = [
type: AssessmentItemTypes.QTI,
raw_data: ORDERING_ITEM_XML,
},
+ {
+ assessment_id: 'demo-item-associate',
+ type: AssessmentItemTypes.QTI,
+ raw_data: ASSOCIATE_ITEM_XML,
+ },
];
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
index 4e84bf71cb..1404d9753f 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
@@ -12,6 +12,7 @@ import {
NO_INTERACTION_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_WITH_HINTS,
NO_INTERACTION_ITEM_WITH_HINTS,
+ VALID_ASSOCIATE_ITEM_DOCUMENT,
} from '../../../utils/testingFixtures';
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
@@ -29,6 +30,9 @@ const {
unsupportedItemMessage$,
incompleteItemIndicatorLabel$,
hintsLabel$,
+ associateLabel$,
+ unknownTypeLabel$,
+ responsePoolLabel$,
} = qtiEditorStrings;
const defaultProps = {
@@ -291,6 +295,29 @@ describe('QTIItemEditor', () => {
});
});
+ describe('associate interaction', () => {
+ const renderAssociateItem = () =>
+ renderComponent({
+ item: {
+ assessment_id: 'test-item-id',
+ type: AssessmentItemTypes.QTI,
+ raw_data: VALID_ASSOCIATE_ITEM_DOCUMENT,
+ },
+ });
+
+ test('names the associate question type rather than falling back to unknown', async () => {
+ renderAssociateItem();
+ expect(await screen.findByText(associateLabel$(), { exact: false })).toBeInTheDocument();
+ expect(screen.queryByText(unknownTypeLabel$(), { exact: false })).not.toBeInTheDocument();
+ });
+
+ test('renders the associate editor for the parsed interaction', async () => {
+ renderAssociateItem();
+ expect(await screen.findByText(responsePoolLabel$())).toBeInTheDocument();
+ expect(screen.getByText('Antonio')).toBeInTheDocument();
+ });
+ });
+
describe('toolbarActions slot', () => {
test('renders content injected into the toolbarActions slot', () => {
renderComponent({}, { toolbarActions: '' });
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
index 3ec1e5a803..8c36a87748 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -182,6 +182,7 @@
[QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$,
[QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$,
[QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$,
+ [QuestionType.ASSOCIATE]: qtiEditorStrings.associateLabel$,
};
return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)();
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js
new file mode 100644
index 0000000000..697bbf07c9
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js
@@ -0,0 +1,122 @@
+import { ref } from 'vue';
+import { useAssociateInteraction } from '../useAssociateInteraction';
+import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../utils/testingFixtures';
+import { parseXML } from '../../serialization/xml';
+import { QuestionType } from '../../constants';
+
+const GENERATED_ID = /^choice_[a-z0-9]{8}$/;
+
+const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content));
+
+const maxAssociations = bodyXml =>
+ parseXML(bodyXml).documentElement.getAttribute('max-associations');
+
+describe('useAssociateInteraction', () => {
+ function setup(bodyXml = ASSOCIATE_XML, declarationXml = ASSOCIATE_DECL_XML) {
+ const questionType = ref(QuestionType.ASSOCIATE);
+ return useAssociateInteraction(
+ { bodyXml, responseDeclarations: [declarationXml] },
+ questionType,
+ );
+ }
+
+ describe('initial state', () => {
+ it('parses pairs and distractors from the fixture XML', () => {
+ const { state } = setup();
+ expect(contentsOf(state.value.pairs)).toEqual([
+ ['Antonio', 'Prospero'],
+ ['Capulet', 'Montague'],
+ ]);
+ expect(state.value.distractors.map(d => d.content)).toEqual(['Lysander']);
+ });
+ });
+
+ describe('addPair()', () => {
+ it('appends a pair of two blank choices with distinct generated ids', () => {
+ const { state, addPair } = setup();
+ addPair();
+ expect(contentsOf(state.value.pairs)).toEqual([
+ ['Antonio', 'Prospero'],
+ ['Capulet', 'Montague'],
+ ['', ''],
+ ]);
+ const [first, second] = state.value.pairs[2];
+ expect(first.id).toMatch(GENERATED_ID);
+ expect(second.id).toMatch(GENERATED_ID);
+ expect(first.id).not.toBe(second.id);
+ });
+
+ it('rebuilds bodyXml with the new max-associations', () => {
+ const { bodyXml, addPair } = setup();
+ addPair();
+ expect(maxAssociations(bodyXml.value)).toBe('3');
+ });
+ });
+
+ describe('removePair()', () => {
+ it('drops the pair at the given index and keeps the rest in order', () => {
+ const { state, removePair } = setup();
+ removePair(0);
+ expect(contentsOf(state.value.pairs)).toEqual([['Capulet', 'Montague']]);
+ });
+
+ it('is a no-op when only one pair remains', () => {
+ const { state, removePair } = setup();
+ removePair(0);
+ removePair(0);
+ expect(contentsOf(state.value.pairs)).toEqual([['Capulet', 'Montague']]);
+ });
+
+ it('drops the pair from the emitted correct response', () => {
+ const { responseDeclarations, removePair } = setup();
+ removePair(0);
+ expect(responseDeclarations.value[0]).not.toContain('choice_aaa11111');
+ });
+ });
+
+ describe('setPair()', () => {
+ it('replaces only the pair at the given index', () => {
+ const { state, setPair } = setup();
+ const [first, second] = state.value.pairs[0];
+ setPair(0, [{ ...first, content: '
Updated
' }, second]);
+ expect(contentsOf(state.value.pairs)).toEqual([
+ ['Updated
', 'Prospero'],
+ ['Capulet', 'Montague'],
+ ]);
+ });
+ });
+
+ describe('addDistractor()', () => {
+ it('appends one blank choice with a generated id', () => {
+ const { state, addDistractor } = setup();
+ addDistractor();
+ expect(state.value.distractors).toHaveLength(2);
+ expect(state.value.distractors[1].content).toBe('');
+ expect(state.value.distractors[1].id).toMatch(GENERATED_ID);
+ });
+
+ it('appends the given content when the distractor is written before it is added', () => {
+ const { state, addDistractor } = setup();
+ addDistractor('Demetrius
');
+ expect(state.value.distractors[1].content).toBe('Demetrius
');
+ expect(state.value.distractors[1].id).toMatch(GENERATED_ID);
+ });
+ });
+
+ describe('removeDistractor()', () => {
+ it('drops the distractor at the given index', () => {
+ const { state, removeDistractor } = setup();
+ removeDistractor(0);
+ expect(state.value.distractors).toEqual([]);
+ });
+ });
+
+ describe('setDistractorContent()', () => {
+ it('updates only the targeted distractor', () => {
+ const { state, addDistractor, setDistractorContent } = setup();
+ addDistractor();
+ setDistractorContent(1, 'Updated
');
+ expect(state.value.distractors.map(d => d.content)).toEqual(['Lysander', 'Updated
']);
+ });
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js
new file mode 100644
index 0000000000..1b2223cd79
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js
@@ -0,0 +1,74 @@
+import { readonly } from 'vue';
+import { associateInteractionDescriptor } from '../interactions/associate/Descriptor';
+import { newChoice } from '../interactions/associate/parse';
+import { useInteraction } from './useInteraction';
+
+/**
+ * Composable for the associate interaction editor.
+ *
+ * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock
+ * @param {import('vue').Ref} questionType
+ */
+export function useAssociateInteraction(interactionBlock, questionType) {
+ const base = useInteraction(associateInteractionDescriptor, interactionBlock, questionType);
+ const { state } = base;
+
+ function addPair() {
+ state.value = { ...state.value, pairs: [...state.value.pairs, [newChoice(), newChoice()]] };
+ }
+
+ function removePair(index) {
+ // An associate question is meaningless without a pair to associate.
+ if (state.value.pairs.length <= 1) return;
+ state.value = {
+ ...state.value,
+ pairs: state.value.pairs.filter((_, i) => i !== index),
+ };
+ }
+
+ function setPair(index, newPair) {
+ state.value = {
+ ...state.value,
+ pairs: state.value.pairs.map((pair, i) => (i === index ? newPair : pair)),
+ };
+ }
+
+ function addDistractor(content = '') {
+ state.value = {
+ ...state.value,
+ distractors: [...state.value.distractors, newChoice(content)],
+ };
+ }
+
+ function removeDistractor(index) {
+ state.value = {
+ ...state.value,
+ distractors: state.value.distractors.filter((_, i) => i !== index),
+ };
+ }
+
+ function setDistractorContent(index, html) {
+ state.value = {
+ ...state.value,
+ distractors: state.value.distractors.map((choice, i) =>
+ i === index ? { ...choice, content: html } : choice,
+ ),
+ };
+ }
+
+ function setPrompt(html) {
+ state.value = { ...state.value, prompt: html };
+ }
+
+ return {
+ ...base,
+ state: readonly(state),
+ addPair,
+ removePair,
+ setPair,
+ addDistractor,
+ removeDistractor,
+ setDistractorContent,
+ setPrompt,
+ };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js
index 1ebd3f003e..22572b1467 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js
@@ -53,6 +53,7 @@ export const QtiInteraction = Object.freeze({
CHOICE: 'qti-choice-interaction',
ORDER: 'qti-order-interaction',
MATCH: 'qti-match-interaction',
+ ASSOCIATE: 'qti-associate-interaction',
TEXT_ENTRY: 'qti-text-entry-interaction',
EXTENDED_TEXT: 'qti-extended-text-interaction',
});
@@ -82,6 +83,7 @@ export const QuestionType = Object.freeze({
TEXT_ENTRY: 'textEntry',
FREE_RESPONSE: 'freeResponse',
ORDERING: 'ordering',
+ ASSOCIATE: 'associate',
});
/**
@@ -103,6 +105,9 @@ export const ValidationError = Object.freeze({
EMPTY_ANSWER_CONTENT: 'EMPTY_ANSWER_CONTENT',
DUPLICATE_ANSWER_CONTENT: 'DUPLICATE_ANSWER_CONTENT',
TOO_FEW_CHOICES: 'TOO_FEW_CHOICES',
+ TOO_FEW_PAIRS: 'TOO_FEW_PAIRS',
+ DUPLICATE_PAIR_CONTENT: 'DUPLICATE_PAIR_CONTENT',
+ DUPLICATE_DISTRACTOR_CONTENT: 'DUPLICATE_DISTRACTOR_CONTENT',
});
export const RESPONSE_IDENTIFIER = 'RESPONSE';
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js
new file mode 100644
index 0000000000..2b4d103d84
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js
@@ -0,0 +1,81 @@
+import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants';
+import { InteractionDescriptor } from '../InteractionDescriptor';
+import { parseAssociateInteraction, buildAssociateInteractionXML } from './parse';
+import { validateAssociateInteraction } from './validation';
+
+/**
+ * Owns all associate-specific interaction logic: schema, parse, buildXML, and validate.
+ */
+export class AssociateInteractionDescriptor extends InteractionDescriptor {
+ constructor() {
+ super({
+ type: QtiInteraction.ASSOCIATE,
+ questionTypes: [QuestionType.ASSOCIATE],
+ });
+ this.convertsFrom = [];
+ }
+
+ getTypeOptions(tr) {
+ return [
+ {
+ value: QuestionType.ASSOCIATE,
+ label: tr.associateLabel$(),
+ description: tr.associateDescription$(),
+ },
+ ];
+ }
+
+ /**
+ * Associate always has exactly one question type.
+ *
+ * @returns {string}
+ */
+ getQuestionType() {
+ return QuestionType.ASSOCIATE;
+ }
+
+ /**
+ * @returns {{ baseType: string, cardinality: string }}
+ */
+ getResponseDeclarationSchema() {
+ return {
+ baseType: BaseType.PAIR,
+ cardinality: Cardinality.MULTIPLE,
+ };
+ }
+
+ /**
+ * Parse body XML + response declarations → AssociateState.
+ *
+ * @param {string} bodyXml
+ * @param {string[]} responseDeclarations
+ * @returns {object} AssociateState
+ */
+ parse(bodyXml, responseDeclarations) {
+ return parseAssociateInteraction(bodyXml, responseDeclarations);
+ }
+
+ /**
+ * Serialize AssociateState → { bodyXml, responseDeclarations }.
+ *
+ * @param {object} state - AssociateState
+ * @param {string} questionType
+ * @returns {{ bodyXml: string, responseDeclarations: string[] }}
+ */
+ buildXML(state, questionType) {
+ return buildAssociateInteractionXML(state, questionType, this.getResponseDeclarationSchema());
+ }
+
+ /**
+ * Validate AssociateState → ValidationError[].
+ *
+ * @param {object} state - AssociateState
+ * @returns {Array<{ code: string, id?: string, index?: number }>}
+ */
+ validate(state) {
+ return validateAssociateInteraction(state);
+ }
+}
+
+/** Singleton — safe to import from any file in the associate module tree. */
+export const associateInteractionDescriptor = new AssociateInteractionDescriptor();
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue
new file mode 100644
index 0000000000..1b7ad2c114
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue
@@ -0,0 +1,1124 @@
+
+
+
+
+
+
+ {{ errorPromptRequired$() }}
+
+
+ {{ questionLabel$() }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ responsePoolLabel$() }}
+
+
+
+
+
+
+
+
+ {{ errorTooFewPairs$() }}
+
+
+
+
+
+ -
+
+ {{ pairNumberLabel$({ number: index + 1 }) }}
+
+
+
+
+
+
+ setPairItemContent(index, position, html)"
+ @minimize="closeOpenTarget"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+ setDistractorContent(index, html)"
+ @minimize="closeOpenTarget"
+ />
+
+
+
+
+
+
+
+
+ {{ distractorErrorMessages[index] }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Descriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Descriptor.spec.js
new file mode 100644
index 0000000000..b6eda3e34c
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Descriptor.spec.js
@@ -0,0 +1,20 @@
+import { associateInteractionDescriptor as descriptor } from '../Descriptor';
+import { qtiEditorStrings } from '../../../qtiEditorStrings';
+import { QuestionType } from '../../../constants';
+import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../../utils/testingFixtures';
+
+describe('AssociateInteractionDescriptor', () => {
+ it('getTypeOptions() offers the associate question type to the type selector', () => {
+ const options = descriptor.getTypeOptions(qtiEditorStrings);
+ expect(options).toHaveLength(1);
+ expect(options[0].value).toBe(QuestionType.ASSOCIATE);
+ expect(options[0].label).toBe(qtiEditorStrings.$tr('associateLabel'));
+ });
+
+ it('buildXML() forwards its own declaration schema', () => {
+ const state = descriptor.parse(ASSOCIATE_XML, [ASSOCIATE_DECL_XML]);
+ const [declXml] = descriptor.buildXML(state, QuestionType.ASSOCIATE).responseDeclarations;
+ expect(declXml).toContain('base-type="pair"');
+ expect(declXml).toContain('cardinality="multiple"');
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js
new file mode 100644
index 0000000000..c35d630b3d
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js
@@ -0,0 +1,603 @@
+import { render, screen, within } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
+import { nextTick } from 'vue';
+import VueRouter from 'vue-router';
+import AssociateEditor from '../Editor.vue';
+
+import {
+ ASSOCIATE_XML,
+ ASSOCIATE_DECL_XML,
+ mockInteractionBlock as block,
+ mockInteractionBlockWithDecl as blockWithDecl,
+} from '../../../utils/testingFixtures';
+import { QuestionType } from '../../../constants';
+import { qtiEditorStrings as tr } from '../../../qtiEditorStrings';
+
+jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
+
+let mockWindowIsLarge = true;
+let mockWindowIsSmall = false;
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
+ const { ref } = require('vue');
+ return {
+ __esModule: true,
+ default: () => ({
+ windowIsLarge: ref(mockWindowIsLarge),
+ windowIsSmall: ref(mockWindowIsSmall),
+ }),
+ };
+});
+
+const POOL_CONTENTS = ['Antonio', 'Prospero', 'Capulet', 'Montague', 'Lysander'];
+
+// One pair whose second member has no content, so the pool carries a blank
+// distractor the author still has to fill in.
+const BLANK_DISTRACTOR_XML = `
+ Match each character to his adversary.
+ Antonio
+ Prospero
+
+`;
+
+const ONE_PAIR_DECL_XML = `
+
+ choice_aaa11111 choice_bbb22222
+
+`;
+
+// Antonio is paired twice, so its match-max is 2.
+const SHARED_CHOICE_XML = `
+ Match each character to his adversary.
+ Antonio
+ Prospero
+ Capulet
+`;
+
+const SHARED_CHOICE_DECL_XML = `
+
+ choice_aaa11111 choice_bbb22222
+ choice_aaa11111 choice_ccc33333
+
+`;
+
+// The mock TipTapEditor renders a