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 8a9d19fe02..9708a3397c 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
@@ -3,6 +3,7 @@ import VueRouter from 'vue-router';
import QTIItemEditor from '../index.vue';
import { qtiEditorStrings } from '../../../qtiEditorStrings';
import { AssessmentItemTypes } from '../../../constants';
+import { VALID_ASSOCIATE_ITEM_DOCUMENT } from '../../../utils/testingFixtures';
jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor');
jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
@@ -13,7 +14,13 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
};
});
-const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;
+const {
+ closeBtnLabel$,
+ questionContentPlaceholder$,
+ associateLabel$,
+ unknownTypeLabel$,
+ responsePoolLabel$,
+} = qtiEditorStrings;
const defaultProps = {
item: {
@@ -77,6 +84,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(new RegExp(associateLabel$()))).toBeInTheDocument();
+ expect(screen.queryByText(new RegExp(unknownTypeLabel$()))).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 40711ad6f9..ee2f245975 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -132,6 +132,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..dc6ef2a073
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js
@@ -0,0 +1,115 @@
+import { ref } from 'vue';
+import { useAssociateInteraction } from '../useAssociateInteraction';
+import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../utils/testingFixtures';
+import { QuestionType, ValidationError } from '../../constants';
+
+const GENERATED_ID = /^choice_[a-zA-Z0-9]{8}$/;
+
+const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content));
+
+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(state.value.pairs).toHaveLength(3);
+ const [first, second] = state.value.pairs[2];
+ expect(first.content).toBe('');
+ expect(second.content).toBe('');
+ expect(first.id).toMatch(GENERATED_ID);
+ expect(second.id).toMatch(GENERATED_ID);
+ expect(first.id).not.toBe(second.id);
+ });
+
+ it('leaves the existing pairs untouched', () => {
+ const { state, addPair } = setup();
+ addPair();
+ expect(contentsOf(state.value.pairs.slice(0, 2))).toEqual([
+ ['Antonio', 'Prospero'],
+ ['Capulet', 'Montague'],
+ ]);
+ });
+
+ it('rebuilds bodyXml so the mutation reaches the emitted interaction', () => {
+ const { bodyXml, addPair } = setup();
+ const before = bodyXml.value;
+ addPair();
+ expect(bodyXml.value).not.toBe(before);
+ });
+ });
+
+ 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']]);
+ });
+ });
+
+ 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);
+ });
+ });
+
+ 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
']);
+ });
+ });
+
+ describe('runValidation()', () => {
+ it('populates errors for an invalid state', () => {
+ const { setPrompt, runValidation, errors } = setup();
+ setPrompt('');
+ runValidation();
+ expect(errors.value.map(e => e.code)).toContain(ValidationError.PROMPT_REQUIRED);
+ });
+ });
+});
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..1e4903a92f
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js
@@ -0,0 +1,71 @@
+import { readonly } from 'vue';
+import { generateRandomSlug } from '../utils/generateRandomSlug';
+import { associateInteractionDescriptor } from '../interactions/associate/AssociateInteractionDescriptor';
+import { useInteraction } from './useInteraction';
+
+const blankChoice = () => ({ id: generateRandomSlug('choice'), content: '' });
+
+/**
+ * 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, [blankChoice(), blankChoice()]] };
+ }
+
+ function removePair(index) {
+ 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() {
+ state.value = { ...state.value, distractors: [...state.value.distractors, blankChoice()] };
+ }
+
+ 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 3fa2bcd6a4..c668382b56 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',
});
@@ -81,6 +82,7 @@ export const QuestionType = Object.freeze({
TEXT_ENTRY: 'textEntry',
FREE_RESPONSE: 'freeResponse',
ORDERING: 'ordering',
+ ASSOCIATE: 'associate',
});
/**
@@ -98,6 +100,8 @@ 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',
});
export const RESPONSE_IDENTIFIER = 'RESPONSE';
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionDescriptor.js
new file mode 100644
index 0000000000..bac3347bd3
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionDescriptor.js
@@ -0,0 +1,85 @@
+import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants';
+import { parseAssociateInteraction, buildAssociateInteractionXML } from './parse';
+import { validateAssociateInteraction } from './validate';
+
+/**
+ * Owns all associate-specific interaction logic: schema, parse, buildXML, and validate.
+ */
+export class AssociateInteractionDescriptor {
+ constructor({ editorComponent = null } = {}) {
+ this.type = QtiInteraction.ASSOCIATE;
+ this.placement = 'block';
+ this.questionTypes = [QuestionType.ASSOCIATE];
+ this.editorComponent = editorComponent;
+ this.convertsFrom = [];
+ }
+
+ getTypeOptions(tr) {
+ return [
+ {
+ value: QuestionType.ASSOCIATE,
+ label: tr.associateLabel$(),
+ description: tr.associateDescription$(),
+ },
+ ];
+ }
+
+ /** @param {Element} el */
+ matches(el) {
+ return el.tagName.toLowerCase() === QtiInteraction.ASSOCIATE;
+ }
+
+ /**
+ * 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/AssociateInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionEditor.vue
new file mode 100644
index 0000000000..d6088435bb
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionEditor.vue
@@ -0,0 +1,854 @@
+
+
+
+
+
+
+ {{ errorPromptRequired$() }}
+
+
+ {{ questionLabel$() }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ responsePoolLabel$() }}
+
+
+
+
+
+
+
+
+ {{ errorTooFewPairs$() }}
+
+
+
+
+
+ -
+
+ {{ pairNumberLabel$({ number: index + 1 }) }}
+
+
+
+
+
+
+ setPairItemContent(index, position, html)"
+ @minimize="closeOpenTarget"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ pairErrorMessage(index) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+ setDistractorContent(index, html)"
+ @minimize="closeOpenTarget"
+ />
+
+
+
+
+
+
+ {{ errorEmptyChoiceContent$() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.spec.js
new file mode 100644
index 0000000000..292a81fdf4
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.spec.js
@@ -0,0 +1,49 @@
+import { associateInteractionDescriptor as descriptor } from '../AssociateInteractionDescriptor';
+import { parseXML } from '../../../serialization/parseItem';
+import { qtiEditorStrings } from '../../../qtiEditorStrings';
+import { QtiInteraction, QuestionType } from '../../../constants';
+import {
+ ASSOCIATE_XML,
+ ASSOCIATE_DECL_XML,
+ ORDERING_XML,
+ CHOICE_SINGLE_SELECT_XML,
+} from '../../../utils/testingFixtures';
+
+const elementOf = xml => parseXML(xml).documentElement;
+
+describe('AssociateInteractionDescriptor', () => {
+ it('declares the associate interaction tag, block placement, and question type', () => {
+ expect(descriptor.type).toBe(QtiInteraction.ASSOCIATE);
+ expect(descriptor.placement).toBe('block');
+ expect(descriptor.questionTypes).toEqual([QuestionType.ASSOCIATE]);
+ });
+
+ describe('matches()', () => {
+ it('matches a element', () => {
+ expect(descriptor.matches(elementOf(ASSOCIATE_XML))).toBe(true);
+ });
+
+ it('does not match other interaction elements', () => {
+ expect(descriptor.matches(elementOf(ORDERING_XML))).toBe(false);
+ expect(descriptor.matches(elementOf(CHOICE_SINGLE_SELECT_XML))).toBe(false);
+ });
+ });
+
+ it('getQuestionType() returns the associate question type', () => {
+ expect(descriptor.getQuestionType()).toBe(QuestionType.ASSOCIATE);
+ });
+
+ 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__/AssociateInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionEditor.spec.js
new file mode 100644
index 0000000000..aa3631bab9
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionEditor.spec.js
@@ -0,0 +1,225 @@
+import { render, screen, within, fireEvent } from '@testing-library/vue';
+import { nextTick } from 'vue';
+import VueRouter from 'vue-router';
+import AssociateInteractionEditor from '../AssociateInteractionEditor.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');
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => {
+ const { ref } = require('vue');
+ return {
+ __esModule: true,
+ default: () => ({ windowIsSmall: ref(false) }),
+ };
+});
+
+const POOL_CONTENTS = ['Antonio', 'Prospero', 'Capulet', 'Montague', 'Lysander'];
+
+// The mock TipTapEditor renders a