From 9f6468ee440c26895fb6c297de1518a2e2031efa Mon Sep 17 00:00:00 2001
From: rtibblesbot
Date: Mon, 31 Aug 2026 09:06:17 -0700
Subject: [PATCH 1/7] Add associate interaction parsing, serialization, and
validation
Splits the single flat pool of elements into
authoring state: `pairs` from the correct response, `distractors` from the
match-max capacity the correct response does not consume. buildXML re-merges
them, normalizing ids so equal content shares one pool entry.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../shared/views/QTIEditor/constants.js | 5 +
.../associate/__tests__/parse.spec.js | 438 ++++++++++++++++++
.../associate/__tests__/validation.spec.js | 214 +++++++++
.../QTIEditor/interactions/associate/parse.js | 209 +++++++++
.../interactions/associate/validation.js | 77 +++
.../views/QTIEditor/qtiEditorStrings.js | 75 +++
.../views/QTIEditor/utils/testingFixtures.js | 33 ++
7 files changed, 1051 insertions(+)
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validation.spec.js
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validation.js
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/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js
new file mode 100644
index 0000000000..c32e3ab262
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js
@@ -0,0 +1,438 @@
+/* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */
+// The jest-dom matchers reject XML nodes produced by DOMParser(..., 'text/xml').
+
+import {
+ buildAssociateInteractionXML as buildXML,
+ parseAssociateInteraction as parse,
+} from '../parse';
+import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../../utils/testingFixtures';
+import { BaseType, Cardinality, QuestionType } from '../../../constants';
+import { parseXML } from '../../../serialization/xml';
+
+const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content));
+
+const SCHEMA = { baseType: BaseType.PAIR, cardinality: Cardinality.MULTIPLE };
+
+const parseXmlString = xml => parseXML(xml).documentElement;
+
+describe('parse()', () => {
+ describe('fallbacks', () => {
+ it('seeds one pair of two blank choices with distinct ids when bodyXml is empty', () => {
+ const state = parse('', [ASSOCIATE_DECL_XML]);
+ expect(contentsOf(state.pairs)).toEqual([['', '']]);
+ expect(state.distractors).toEqual([]);
+ const [[first, second]] = state.pairs;
+ expect(first.id).toMatch(/^choice_/);
+ expect(second.id).toMatch(/^choice_/);
+ expect(first.id).not.toBe(second.id);
+ });
+
+ it('returns the default state when bodyXml is invalid XML', () => {
+ const state = parse(' is absent', () => {
+ const xml = `
+ A
+ `;
+ expect(parse(xml, []).prompt).toBe('');
+ });
+ });
+
+ describe('prompt and pairs', () => {
+ it('reads the prompt HTML', () => {
+ expect(parse(ASSOCIATE_XML, [ASSOCIATE_DECL_XML]).prompt).toContain('Match each character');
+ });
+
+ it('builds one pair per declared ', () => {
+ expect(parse(ASSOCIATE_XML, [ASSOCIATE_DECL_XML]).pairs).toHaveLength(2);
+ });
+
+ it('resolves pair members to their pool content', () => {
+ const state = parse(ASSOCIATE_XML, [ASSOCIATE_DECL_XML]);
+ expect(contentsOf(state.pairs)).toEqual([
+ ['Antonio', 'Prospero'],
+ ['Capulet', 'Montague'],
+ ]);
+ });
+
+ it('preserves the member order written in the ', () => {
+ const decl = `
+
+ choice_bbb22222 choice_aaa11111
+
+ `;
+ expect(contentsOf(parse(ASSOCIATE_XML, [decl]).pairs)).toEqual([['Prospero', 'Antonio']]);
+ });
+
+ it('drops a pair naming an identifier absent from the pool', () => {
+ const decl = `
+
+ choice_aaa11111 choice_missing
+ choice_ccc33333 choice_ddd44444
+
+ `;
+ expect(contentsOf(parse(ASSOCIATE_XML, [decl]).pairs)).toEqual([['Capulet', 'Montague']]);
+ });
+
+ it('keeps an empty as a pair of two blank choices', () => {
+ const decl = `
+
+
+ choice_ccc33333 choice_ddd44444
+
+ `;
+ expect(contentsOf(parse(ASSOCIATE_XML, [decl]).pairs)).toEqual([
+ ['', ''],
+ ['Capulet', 'Montague'],
+ ]);
+ });
+
+ it('gives the blank pair from an empty generated ids', () => {
+ const decl = `
+
+
+
+ `;
+ const [[first, second]] = parse(ASSOCIATE_XML, [decl]).pairs;
+ expect(first.id).toMatch(/^choice_/);
+ expect(second.id).toMatch(/^choice_/);
+ expect(first.id).not.toBe(second.id);
+ });
+ });
+
+ describe('distractors', () => {
+ it('treats a choice absent from the correct response as a distractor', () => {
+ const state = parse(ASSOCIATE_XML, [ASSOCIATE_DECL_XML]);
+ expect(state.distractors.map(choice => choice.content)).toEqual(['Lysander']);
+ });
+
+ it('yields one distractor per unused match-max on an unpaired choice', () => {
+ const xml = `
+ Lysander
+ `;
+ const state = parse(xml, []);
+ expect(state.distractors.map(choice => choice.id)).toEqual([
+ 'choice_eee55555',
+ 'choice_eee55555',
+ ]);
+ });
+
+ it('subtracts pair appearances from match-max when counting distractors', () => {
+ const xml = `
+ Antonio
+ Prospero
+ `;
+ const decl = `
+
+ choice_aaa11111 choice_bbb22222
+
+ `;
+ const state = parse(xml, [decl]);
+ expect(state.distractors.map(choice => choice.id)).toEqual(['choice_aaa11111']);
+ });
+
+ it('puts every choice in distractors when no declarations are passed', () => {
+ const state = parse(ASSOCIATE_XML, []);
+ expect(state.pairs).toEqual([]);
+ expect(state.distractors.map(choice => choice.content)).toEqual([
+ 'Antonio',
+ 'Prospero',
+ 'Capulet',
+ 'Montague',
+ 'Lysander',
+ ]);
+ });
+ });
+
+ describe('identifiers', () => {
+ it('assigns a generated choice_ slug to a choice without an identifier', () => {
+ const xml = `
+ No ID
+ `;
+ expect(parse(xml, []).distractors[0].id).toMatch(/^choice_/);
+ });
+
+ // Identifiers are used as lookup keys, so one that names an inherited Object
+ // member must not resolve to that member.
+ it('resolves a choice whose identifier is an Object.prototype member name', () => {
+ const xml = `
+ Antonio
+ Prospero
+ `;
+ const decl = `
+
+ constructor choice_bbb22222
+
+ `;
+ const state = parse(xml, [decl]);
+ expect(contentsOf(state.pairs)).toEqual([['Antonio', 'Prospero']]);
+ expect(state.distractors.map(choice => choice.id)).toEqual(['constructor']);
+ });
+ });
+});
+
+describe('buildXML()', () => {
+ const baseState = {
+ responseIdentifier: 'RESPONSE',
+ prompt: 'Match each character to his adversary.
',
+ pairs: [
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_bbb22222', content: 'Prospero' },
+ ],
+ [
+ { id: 'choice_ccc33333', content: 'Capulet' },
+ { id: 'choice_ddd44444', content: 'Montague' },
+ ],
+ ],
+ distractors: [{ id: 'choice_eee55555', content: 'Lysander' }],
+ };
+
+ const build = state => buildXML(state, QuestionType.ASSOCIATE, SCHEMA);
+ const choicesOf = root => [...root.querySelectorAll('qti-simple-associable-choice')];
+ const valuesOf = decl => [...decl.querySelectorAll('qti-value')].map(n => n.textContent.trim());
+
+ describe('interaction attributes', () => {
+ it('emits the response identifier, shuffle, and one association per pair', () => {
+ const root = parseXmlString(build(baseState).bodyXml);
+ expect(root.getAttribute('response-identifier')).toBe('RESPONSE');
+ expect(root.getAttribute('shuffle')).toBe('true');
+ expect(root.getAttribute('max-associations')).toBe('2');
+ });
+
+ it('omits when prompt is empty', () => {
+ const root = parseXmlString(build({ ...baseState, prompt: '' }).bodyXml);
+ expect(root.querySelector('qti-prompt')).toBeNull();
+ });
+ });
+
+ describe('choice pool', () => {
+ it('merges pairs and distractors into one pool of singly-used choices', () => {
+ const root = parseXmlString(build(baseState).bodyXml);
+ expect(choicesOf(root).map(el => el.getAttribute('identifier'))).toEqual([
+ 'choice_aaa11111',
+ 'choice_bbb22222',
+ 'choice_ccc33333',
+ 'choice_ddd44444',
+ 'choice_eee55555',
+ ]);
+ expect(choicesOf(root).map(el => el.getAttribute('match-max'))).toEqual([
+ '1',
+ '1',
+ '1',
+ '1',
+ '1',
+ ]);
+ });
+
+ it('emits content once for a choice reused across two pairs, with match-max="2"', () => {
+ const pairs = [
+ baseState.pairs[0],
+ [
+ { id: 'choice_ccc33333', content: 'Capulet' },
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ ],
+ ];
+ const { bodyXml, responseDeclarations } = build({ ...baseState, pairs });
+ const antonio = choicesOf(parseXmlString(bodyXml)).filter(el => el.textContent === 'Antonio');
+ expect(antonio).toHaveLength(1);
+ expect(antonio[0].getAttribute('match-max')).toBe('2');
+ expect(valuesOf(parseXmlString(responseDeclarations[0]))).toEqual([
+ 'choice_aaa11111 choice_bbb22222',
+ 'choice_ccc33333 choice_aaa11111',
+ ]);
+ });
+
+ it('counts a distractor repeat of paired content towards match-max', () => {
+ const distractors = [{ id: 'choice_zzz00000', content: 'Antonio' }];
+ const root = parseXmlString(build({ ...baseState, distractors }).bodyXml);
+ const antonio = choicesOf(root).filter(el => el.textContent === 'Antonio');
+ expect(antonio).toHaveLength(1);
+ expect(antonio[0].getAttribute('match-max')).toBe('2');
+ });
+
+ it('keeps the first appearance of an id and reassigns the later choice reusing it', () => {
+ const pairs = [
+ baseState.pairs[0],
+ [
+ { id: 'choice_aaa11111', content: 'Capulet' },
+ { id: 'choice_ddd44444', content: 'Montague' },
+ ],
+ ];
+ const { bodyXml, responseDeclarations } = build({
+ ...baseState,
+ pairs,
+ distractors: [],
+ });
+ const root = parseXmlString(bodyXml);
+ const [capulet] = choicesOf(root).filter(el => el.textContent === 'Capulet');
+ const [antonio] = choicesOf(root).filter(el => el.textContent === 'Antonio');
+ expect(antonio.getAttribute('identifier')).toBe('choice_aaa11111');
+ expect(capulet.getAttribute('identifier')).toMatch(/^choice_/);
+ expect(capulet.getAttribute('identifier')).not.toBe('choice_aaa11111');
+ expect(valuesOf(parseXmlString(responseDeclarations[0]))[1]).toBe(
+ `${capulet.getAttribute('identifier')} choice_ddd44444`,
+ );
+ });
+
+ // Every permutation of the two normalization rules — one id per content,
+ // one content per id — resolved in favour of the first appearance.
+ describe('id normalization', () => {
+ const idsOf = root => choicesOf(root).map(el => el.getAttribute('identifier'));
+ const idOf = (root, content) =>
+ choicesOf(root)
+ .find(el => el.textContent === content)
+ .getAttribute('identifier');
+
+ it('keeps the id of the first appearance when a repeat carries a different id', () => {
+ const pairs = [
+ baseState.pairs[0],
+ [
+ { id: 'choice_ccc33333', content: 'Capulet' },
+ { id: 'choice_zzz00000', content: 'Antonio' },
+ ],
+ ];
+ const { bodyXml, responseDeclarations } = build({ ...baseState, pairs, distractors: [] });
+ const root = parseXmlString(bodyXml);
+ expect(idsOf(root)).not.toContain('choice_zzz00000');
+ expect(idOf(root, 'Antonio')).toBe('choice_aaa11111');
+ expect(valuesOf(parseXmlString(responseDeclarations[0]))[1]).toBe(
+ 'choice_ccc33333 choice_aaa11111',
+ );
+ });
+
+ it('resolves a repeat and then an id conflict on the same choice', () => {
+ const pairs = [
+ baseState.pairs[0],
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_ccc33333', content: 'Capulet' },
+ ],
+ [
+ { id: 'choice_aaa11111', content: 'Montague' },
+ { id: 'choice_ddd44444', content: 'Demetrius' },
+ ],
+ ];
+ const { bodyXml, responseDeclarations } = build({ ...baseState, pairs, distractors: [] });
+ const root = parseXmlString(bodyXml);
+
+ expect(idOf(root, 'Antonio')).toBe('choice_aaa11111');
+ expect(
+ choicesOf(root)
+ .find(el => el.textContent === 'Antonio')
+ .getAttribute('match-max'),
+ ).toBe('2');
+
+ const montagueId = idOf(root, 'Montague');
+ expect(montagueId).toMatch(/^choice_/);
+ expect(montagueId).not.toBe('choice_aaa11111');
+ expect(valuesOf(parseXmlString(responseDeclarations[0]))).toEqual([
+ 'choice_aaa11111 choice_bbb22222',
+ 'choice_aaa11111 choice_ccc33333',
+ `${montagueId} choice_ddd44444`,
+ ]);
+ });
+
+ it('gives a third choice repeating reassigned content the reassigned id', () => {
+ const pairs = [
+ baseState.pairs[0],
+ [
+ { id: 'choice_aaa11111', content: 'Montague' },
+ { id: 'choice_ccc33333', content: 'Capulet' },
+ ],
+ [
+ { id: 'choice_ddd44444', content: 'Montague' },
+ { id: 'choice_eee55555', content: 'Lysander' },
+ ],
+ ];
+ const root = parseXmlString(build({ ...baseState, pairs, distractors: [] }).bodyXml);
+ const montague = choicesOf(root).filter(el => el.textContent === 'Montague');
+ expect(montague).toHaveLength(1);
+ expect(montague[0].getAttribute('match-max')).toBe('2');
+ expect(idsOf(root)).not.toContain('choice_ddd44444');
+ });
+ });
+
+ it('keeps two blank choices in a pair as two separate elements', () => {
+ const pairs = [
+ [
+ { id: 'choice_blank111', content: '' },
+ { id: 'choice_blank222', content: '' },
+ ],
+ ];
+ const root = parseXmlString(build({ ...baseState, pairs, distractors: [] }).bodyXml);
+ expect(choicesOf(root)).toHaveLength(2);
+ });
+ });
+
+ describe('response declaration', () => {
+ it('emits one space-separated per pair, preserving order', () => {
+ const decl = parseXmlString(build(baseState).responseDeclarations[0]);
+ expect(valuesOf(decl)).toEqual([
+ 'choice_aaa11111 choice_bbb22222',
+ 'choice_ccc33333 choice_ddd44444',
+ ]);
+ });
+
+ it('takes cardinality and base-type from the declaration schema', () => {
+ const decl = parseXmlString(build(baseState).responseDeclarations[0]);
+ expect(decl.getAttribute('cardinality')).toBe('multiple');
+ expect(decl.getAttribute('base-type')).toBe('pair');
+ });
+
+ it('omits when there are no pairs', () => {
+ const { bodyXml, responseDeclarations } = build({ ...baseState, pairs: [] });
+ expect(
+ parseXmlString(responseDeclarations[0]).querySelector('qti-correct-response'),
+ ).toBeNull();
+ expect(parseXmlString(bodyXml).getAttribute('max-associations')).toBe('0');
+ });
+ });
+});
+
+describe('parse → buildXML → parse round-trip', () => {
+ const roundTrip = state => {
+ const { bodyXml, responseDeclarations } = buildXML(state, QuestionType.ASSOCIATE, SCHEMA);
+ return parse(bodyXml, responseDeclarations);
+ };
+
+ it('preserves pair contents and ids for a full associate XML', () => {
+ const original = parse(ASSOCIATE_XML, [ASSOCIATE_DECL_XML]);
+ const reparsed = roundTrip(original);
+ expect(contentsOf(reparsed.pairs)).toEqual(contentsOf(original.pairs));
+ expect(reparsed.pairs.map(pair => pair.map(choice => choice.id))).toEqual(
+ original.pairs.map(pair => pair.map(choice => choice.id)),
+ );
+ });
+
+ it('preserves distractor contents for a full associate XML', () => {
+ const original = parse(ASSOCIATE_XML, [ASSOCIATE_DECL_XML]);
+ const reparsed = roundTrip(original);
+ expect(reparsed.distractors.map(choice => choice.content).sort()).toEqual(
+ original.distractors.map(choice => choice.content).sort(),
+ );
+ });
+
+ it('preserves a pair declared as an empty ', () => {
+ const decl = `
+
+
+ choice_ccc33333 choice_ddd44444
+
+ `;
+ const reparsed = roundTrip(parse(ASSOCIATE_XML, [decl]));
+ expect(contentsOf(reparsed.pairs)).toEqual([
+ ['', ''],
+ ['Capulet', 'Montague'],
+ ]);
+ });
+
+ it('preserves the default state as one pair of two blank choices', () => {
+ const reparsed = roundTrip(parse('', []));
+ expect(contentsOf(reparsed.pairs)).toEqual([['', '']]);
+ expect(reparsed.distractors).toEqual([]);
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validation.spec.js
new file mode 100644
index 0000000000..433b0639a5
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validation.spec.js
@@ -0,0 +1,214 @@
+import { validateAssociateInteraction } from '../validation';
+import { ValidationError } from '../../../constants';
+
+function makeState(overrides = {}) {
+ return {
+ prompt: 'Match each character to his adversary.
',
+ pairs: [
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_bbb22222', content: 'Prospero' },
+ ],
+ ],
+ distractors: [{ id: 'choice_eee55555', content: 'Lysander' }],
+ ...overrides,
+ };
+}
+
+const errorCodes = errors => errors.map(e => e.code);
+
+describe('validateAssociateInteraction()', () => {
+ it('returns an empty array for a valid state', () => {
+ expect(validateAssociateInteraction(makeState())).toEqual([]);
+ });
+
+ describe('PROMPT_REQUIRED', () => {
+ it('returns error when the prompt is empty', () => {
+ expect(errorCodes(validateAssociateInteraction(makeState({ prompt: '' })))).toContain(
+ ValidationError.PROMPT_REQUIRED,
+ );
+ });
+
+ it('returns error when the prompt is tags-and-whitespace only', () => {
+ expect(
+ errorCodes(validateAssociateInteraction(makeState({ prompt: '
' }))),
+ ).toContain(ValidationError.PROMPT_REQUIRED);
+ });
+ });
+
+ describe('EMPTY_CHOICE_CONTENT', () => {
+ it('flags a blank pair member by id and leaves the pair invalid', () => {
+ const state = makeState({
+ pairs: [
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_bbb22222', content: ' ' },
+ ],
+ ],
+ });
+ const errors = validateAssociateInteraction(state);
+ expect(errors).toContainEqual({
+ code: ValidationError.EMPTY_CHOICE_CONTENT,
+ id: 'choice_bbb22222',
+ });
+ expect(errorCodes(errors)).toContain(ValidationError.TOO_FEW_PAIRS);
+ });
+
+ it('flags a blank distractor by id without invalidating the pairs', () => {
+ const state = makeState({ distractors: [{ id: 'choice_eee55555', content: '' }] });
+ const errors = validateAssociateInteraction(state);
+ expect(errors).toContainEqual({
+ code: ValidationError.EMPTY_CHOICE_CONTENT,
+ id: 'choice_eee55555',
+ });
+ expect(errorCodes(errors)).not.toContain(ValidationError.TOO_FEW_PAIRS);
+ });
+ });
+
+ describe('DUPLICATE_PAIR_CONTENT', () => {
+ it('flags a pair whose two members hold the same content, by pair index', () => {
+ const state = makeState({
+ pairs: [
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_bbb22222', content: 'Antonio' },
+ ],
+ ],
+ });
+ const errors = validateAssociateInteraction(state);
+ expect(errors).toContainEqual({ code: ValidationError.DUPLICATE_PAIR_CONTENT, index: 0 });
+ expect(errorCodes(errors)).toContain(ValidationError.TOO_FEW_PAIRS);
+ });
+
+ it('treats content differing only by markup as duplicate', () => {
+ const state = makeState({
+ pairs: [
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_bbb22222', content: 'Antonio' },
+ ],
+ ],
+ });
+ expect(errorCodes(validateAssociateInteraction(state))).toContain(
+ ValidationError.DUPLICATE_PAIR_CONTENT,
+ );
+ });
+
+ it('does not flag a pair whose members are both blank', () => {
+ const state = makeState({
+ pairs: [
+ [
+ { id: 'choice_aaa11111', content: '' },
+ { id: 'choice_bbb22222', content: '' },
+ ],
+ ],
+ });
+ expect(errorCodes(validateAssociateInteraction(state))).not.toContain(
+ ValidationError.DUPLICATE_PAIR_CONTENT,
+ );
+ });
+
+ it('does not flag content reused across two different pairs', () => {
+ const state = makeState({
+ pairs: [
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_bbb22222', content: 'Prospero' },
+ ],
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_ccc33333', content: 'Capulet' },
+ ],
+ ],
+ });
+ expect(validateAssociateInteraction(state)).toEqual([]);
+ });
+ });
+
+ describe('DUPLICATE_DISTRACTOR_CONTENT', () => {
+ it('flags a distractor repeated among the distractors, by its text', () => {
+ const state = makeState({
+ distractors: [
+ { id: 'choice_eee55555', content: 'Lysander' },
+ { id: 'choice_fff66666', content: 'Lysander' },
+ ],
+ });
+ const errors = validateAssociateInteraction(state);
+ expect(errors).toContainEqual({
+ code: ValidationError.DUPLICATE_DISTRACTOR_CONTENT,
+ text: 'Lysander',
+ });
+ expect(
+ errors.filter(e => e.code === ValidationError.DUPLICATE_DISTRACTOR_CONTENT),
+ ).toHaveLength(1);
+ });
+
+ it('flags a distractor that duplicates an item in a pair', () => {
+ const state = makeState({ distractors: [{ id: 'choice_eee55555', content: 'Antonio' }] });
+ expect(validateAssociateInteraction(state)).toContainEqual({
+ code: ValidationError.DUPLICATE_DISTRACTOR_CONTENT,
+ text: 'Antonio',
+ });
+ });
+
+ it('does not flag blank distractors as duplicates of each other', () => {
+ const state = makeState({
+ distractors: [
+ { id: 'choice_eee55555', content: '' },
+ { id: 'choice_fff66666', content: '
' },
+ ],
+ });
+ expect(errorCodes(validateAssociateInteraction(state))).not.toContain(
+ ValidationError.DUPLICATE_DISTRACTOR_CONTENT,
+ );
+ });
+ });
+
+ describe('TOO_FEW_PAIRS', () => {
+ it('returns error when there are no pairs at all', () => {
+ expect(errorCodes(validateAssociateInteraction(makeState({ pairs: [] })))).toContain(
+ ValidationError.TOO_FEW_PAIRS,
+ );
+ });
+
+ it('returns error when every pair is invalid for a different reason', () => {
+ const state = makeState({
+ pairs: [
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_bbb22222', content: '' },
+ ],
+ [
+ { id: 'choice_ccc33333', content: 'Capulet' },
+ { id: 'choice_ddd44444', content: 'Capulet' },
+ ],
+ ],
+ });
+ expect(errorCodes(validateAssociateInteraction(state))).toContain(
+ ValidationError.TOO_FEW_PAIRS,
+ );
+ });
+
+ it('does not return error when one valid pair sits among invalid ones', () => {
+ const state = makeState({
+ pairs: [
+ [
+ { id: 'choice_aaa11111', content: 'Antonio' },
+ { id: 'choice_bbb22222', content: 'Antonio' },
+ ],
+ [
+ { id: 'choice_ccc33333', content: '' },
+ { id: 'choice_ddd44444', content: 'Prospero' },
+ ],
+ [
+ { id: 'choice_eee55555', content: 'Capulet' },
+ { id: 'choice_fff66666', content: 'Montague' },
+ ],
+ ],
+ });
+ expect(errorCodes(validateAssociateInteraction(state))).not.toContain(
+ ValidationError.TOO_FEW_PAIRS,
+ );
+ });
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js
new file mode 100644
index 0000000000..f1daff5d50
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js
@@ -0,0 +1,209 @@
+import flatMap from 'lodash/flatMap';
+import flatten from 'lodash/flatten';
+import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration';
+import { getPromptHTML, parseXML } from '../../serialization/xml';
+import { buildXmlNode } from '../../serialization/assembleItem';
+import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
+import { generateRandomSlug } from '../../utils/generateRandomSlug';
+import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText';
+import { RESPONSE_IDENTIFIER } from '../../constants';
+
+const serializer = new XMLSerializer();
+
+/**
+ * @typedef {object} AssociateChoice
+ * @property {string} id - QTI identifier, e.g. "choice_xlqTuVoq"
+ * @property {string} content - HTML content of the
+ */
+
+/**
+ * @typedef {object} AssociateState
+ * @property {string} responseIdentifier - Response identifier attribute
+ * @property {string} prompt - HTML content of ; default ""
+ * @property {AssociateChoice[]} distractors - Flat pool of unpaired choices
+ * @property {Array<[AssociateChoice, AssociateChoice]>} pairs - Correctly associated pairs
+ */
+
+/**
+ * @param {string} [content]
+ * @returns {AssociateChoice}
+ */
+export const newChoice = (content = '') => ({ id: generateRandomSlug('choice'), content });
+
+/**
+ * @returns {AssociateState}
+ */
+export function _defaultState() {
+ return {
+ responseIdentifier: RESPONSE_IDENTIFIER,
+ prompt: '',
+ distractors: [],
+ pairs: [[newChoice(), newChoice()]],
+ };
+}
+
+/**
+ * Extract the correct pairs from a response declaration string as id couples.
+ *
+ * An empty coerces to null and is kept as such: it stands for a
+ * pair the author has yet to fill in, which the editor shows as a blank pair.
+ *
+ * @param {string[]} declarations
+ * @returns {Array}
+ */
+export function _extractCorrectPairIds(declarations) {
+ const [declXml] = declarations || [];
+ if (!declXml) return [];
+
+ try {
+ const declEl = parseXML(declXml).documentElement;
+ const declaration = QTIDeclaration.fromXML(declEl);
+ return (declaration.correctResponse ?? []).map(value => (Array.isArray(value) ? value : null));
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Parse body XML + response declarations → AssociateState.
+ *
+ * @param {string} bodyXml
+ * @param {string[]} responseDeclarations
+ * @returns {AssociateState}
+ */
+export function parseAssociateInteraction(bodyXml, responseDeclarations) {
+ if (!bodyXml) return _defaultState();
+
+ let root;
+ try {
+ root = parseXML(bodyXml).documentElement;
+ } catch {
+ return _defaultState();
+ }
+
+ const pool = [...root.querySelectorAll('qti-simple-associable-choice')].map(el => ({
+ id: el.getAttribute('identifier') || generateRandomSlug('choice'),
+ content: el.innerHTML,
+ matchMax: parseInt(el.getAttribute('match-max'), 10) || 1,
+ }));
+ const poolById = new Map(pool.map(choice => [choice.id, choice]));
+
+ const pairs = _extractCorrectPairIds(responseDeclarations)
+ .map(ids => (ids ? ids.map(id => poolById.get(id)) : [newChoice(), newChoice()]))
+ .filter(members => members.every(Boolean))
+ .map(members => members.map(({ id, content }) => ({ id, content })));
+
+ // match-max is how many pairs a choice may join; the capacity the correct
+ // response leaves unused is what the author added as a loose option.
+ const pairedCount = new Map();
+ for (const { id } of flatten(pairs)) {
+ pairedCount.set(id, (pairedCount.get(id) || 0) + 1);
+ }
+
+ const distractors = flatMap(pool, ({ id, content, matchMax }) =>
+ Array.from({ length: Math.max(matchMax - (pairedCount.get(id) || 0), 0) }, () => ({
+ id,
+ content,
+ })),
+ );
+
+ return {
+ responseIdentifier: root.getAttribute('response-identifier') || RESPONSE_IDENTIFIER,
+ prompt: getPromptHTML(root),
+ distractors,
+ pairs,
+ };
+}
+
+/**
+ * Serialize AssociateState → { bodyXml, responseDeclarations }.
+ *
+ * @param {AssociateState} state
+ * @param {string} _questionType - unused (associate has one question type); kept for API parity
+ * @param {object} declarationSchema - { baseType: string, cardinality: string }
+ * @returns {{ bodyXml: string, responseDeclarations: string[] }}
+ */
+export function buildAssociateInteractionXML(state, _questionType, declarationSchema) {
+ const { responseIdentifier = RESPONSE_IDENTIFIER, prompt, pairs = [], distractors = [] } = state;
+
+ const idByContent = new Map();
+ const contentById = new Map();
+
+ // Preserves the id a choice already carries unless it is already bound to
+ // different content.
+ function resolveChoice({ id, content }) {
+ const key = hasRichTextContent(content) ? richTextComparisonKey(content) : '';
+
+ // Blank choices are never deduped — a freshly added pair holds two of them,
+ // and collapsing them would leave the pair unable to round-trip.
+ if (key && idByContent.has(key)) {
+ return { id: idByContent.get(key), content };
+ }
+
+ const boundKey = contentById.get(id);
+ const resolvedId =
+ boundKey === undefined || boundKey === key ? id : generateRandomSlug('choice');
+ contentById.set(resolvedId, key);
+ if (key) {
+ idByContent.set(key, resolvedId);
+ }
+ return { id: resolvedId, content };
+ }
+
+ const resolvedPairs = pairs.map(pair => pair.map(resolveChoice));
+ const resolvedDistractors = distractors.map(resolveChoice);
+
+ // Every appearance of a choice is one pairing it may take part in.
+ const pool = new Map();
+ for (const { id, content } of [...flatten(resolvedPairs), ...resolvedDistractors]) {
+ const entry = pool.get(id);
+ if (entry) {
+ entry.matchMax += 1;
+ } else {
+ pool.set(id, { id, content, matchMax: 1 });
+ }
+ }
+
+ const children = [];
+ if (prompt) {
+ children.push(buildXmlNode({ tag: 'qti-prompt', innerHTML: prompt }));
+ }
+ for (const { id, content, matchMax } of pool.values()) {
+ children.push(
+ buildXmlNode({
+ tag: 'qti-simple-associable-choice',
+ attrs: { identifier: id, 'match-max': matchMax },
+ innerHTML: content,
+ }),
+ );
+ }
+
+ const interactionEl = buildXmlNode({
+ tag: 'qti-associate-interaction',
+ attrs: {
+ 'response-identifier': responseIdentifier,
+ shuffle: 'true',
+ 'max-associations': pairs.length,
+ },
+ children,
+ });
+
+ const { cardinality, baseType } = declarationSchema;
+ const declaration = new QTIDeclaration({
+ identifier: responseIdentifier,
+ baseType,
+ cardinality,
+ tag: 'qti-response-declaration',
+ });
+ if (resolvedPairs.length > 0) {
+ new CorrectResponse(
+ resolvedPairs.map(pair => pair.map(choice => choice.id)),
+ declaration,
+ );
+ }
+
+ return {
+ bodyXml: serializer.serializeToString(interactionEl),
+ responseDeclarations: [serializer.serializeToString(declaration.getXML())],
+ };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validation.js
new file mode 100644
index 0000000000..7a728d9cf4
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validation.js
@@ -0,0 +1,77 @@
+import flatten from 'lodash/flatten';
+import { ValidationError } from '../../constants';
+import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText';
+
+/**
+ * Validate AssociateState → ValidationError[].
+ *
+ * Choice-scoped errors carry `id`; pair-scoped errors carry the pair's `index`,
+ * because both members of a broken pair may be blank or share an id;
+ * duplication errors carry the repeated `text`, because an id may be repeated
+ * across choices and so cannot single one out.
+ *
+ * @param {object} state - AssociateState
+ * @returns {Array<{ code: string, id?: string, index?: number, text?: string }>}
+ */
+export function validateAssociateInteraction(state) {
+ const errors = [];
+ const { prompt, pairs = [], distractors = [] } = state;
+
+ if (!hasRichTextContent(prompt)) {
+ errors.push({ code: ValidationError.PROMPT_REQUIRED });
+ }
+
+ // Every rule below asks the same two questions of a choice, and answering
+ // either one parses its HTML — so each choice is read once, here.
+ const read = ({ id, content }) => ({
+ id,
+ filled: hasRichTextContent(content),
+ key: richTextComparisonKey(content),
+ });
+ const readPairs = pairs.map(pair => pair.map(read));
+ const readDistractors = distractors.map(read);
+ const allChoices = [...flatten(readPairs), ...readDistractors];
+
+ for (const { id, filled } of allChoices) {
+ if (!filled) {
+ errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id });
+ }
+ }
+
+ let validPairs = 0;
+ readPairs.forEach(([first, second], index) => {
+ if (!first.filled || !second.filled) {
+ return;
+ }
+ if (first.key === second.key) {
+ errors.push({ code: ValidationError.DUPLICATE_PAIR_CONTENT, index });
+ } else {
+ validPairs += 1;
+ }
+ });
+
+ if (validPairs < 1) {
+ errors.push({ code: ValidationError.TOO_FEW_PAIRS });
+ }
+
+ // A distractor is there to be the wrong answer, so repeating another
+ // distractor or an item the author already paired makes it unanswerable.
+ // Content reused across two pairs stays valid — only distractors are flagged.
+ const occurrences = new Map();
+ for (const { filled, key } of allChoices) {
+ if (filled) {
+ occurrences.set(key, (occurrences.get(key) || 0) + 1);
+ }
+ }
+
+ const repeated = new Set(
+ readDistractors
+ .filter(({ filled, key }) => filled && occurrences.get(key) > 1)
+ .map(({ key }) => key),
+ );
+ for (const text of repeated) {
+ errors.push({ code: ValidationError.DUPLICATE_DISTRACTOR_CONTENT, text });
+ }
+
+ return errors;
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
index 43a7fc06bc..71d674f63a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
@@ -105,6 +105,81 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
message: 'Duplicate items are not allowed',
context: 'Validation error when two or more ordering items have identical content',
},
+
+ associateLabel: {
+ message: 'Associate',
+ context: 'Display name for an associate question type shown in the question type selector',
+ },
+ associateDescription: {
+ message: 'Learners must associate pairs of items.',
+ context: 'Description for the associate question type in the info modal',
+ },
+ correctPairsLabel: {
+ message: 'Matching pairs',
+ context: 'Section header above the list of correctly associated pairs',
+ },
+ correctPairsDescription: {
+ message:
+ 'Learners match responses from a shuffled set to each prompt. Include distractors to increase difficulty.',
+ context: 'Subtitle under the matching pairs header',
+ },
+ distractorsLabel: {
+ message: 'Distractors (optional)',
+ context: 'Section header above the choices that belong to no correct pair',
+ },
+ distractorsDescription: {
+ message: "Extra items shown in the response pool that don't match any prompt",
+ context: 'Subtitle under the distractors header',
+ },
+ responsePoolLabel: {
+ message: 'Response pool (shuffled)',
+ context: 'Header above the shuffled pool of choices learners will pick from',
+ },
+ correctAnswersLabel: {
+ message: 'Answers',
+ context: 'Header above the list of correct pairs shown when answers are revealed',
+ },
+ pairNumberLabel: {
+ message: 'Pair {number}',
+ context: 'Label to the left of a pair row, e.g. "Pair 2"',
+ },
+ addPairBtn: {
+ message: 'Add pair',
+ context: 'Button that appends a new pair',
+ },
+ deletePairBtn: {
+ message: 'Delete pair {number}',
+ context: 'Accessible label for the delete icon button next to a pair row',
+ },
+ addDistractorBtn: {
+ message: 'Add distractor',
+ context: 'Button that opens the editor for a new distractor',
+ },
+ deleteDistractorBtn: {
+ message: 'Delete distractor {number}',
+ context: 'Accessible label for the delete icon button on a distractor',
+ },
+ editPairItemLabel: {
+ message: 'Edit pair {number}, item {position}',
+ context: 'Accessible label for the clickable region to edit one item of a pair',
+ },
+ editDistractorLabel: {
+ message: 'Edit distractor {number}',
+ context: 'Accessible label for the clickable region to edit a distractor',
+ },
+ errorTooFewPairs: {
+ message: '1 or more valid pairs are required',
+ context: 'Validation error when no pair has two distinct, non-empty items',
+ },
+ errorDuplicatePairContent: {
+ message: 'Answers within a pair cannot be the same',
+ context: 'Validation error when both items of a pair have identical content',
+ },
+ errorDuplicateDistractorContent: {
+ message: 'Distractors cannot repeat another item',
+ context:
+ 'Validation error when a distractor has the same content as another distractor or as an item in a pair',
+ },
matchLabel: {
message: 'Match',
context: 'Display name for a match question type',
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
index 0cd93d610d..409fff95b7 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -61,6 +61,23 @@ export const ORDERING_DECL_XML = `
+ Match each character to his adversary.
+ Antonio
+ Prospero
+ Capulet
+ Montague
+ Lysander
+`;
+
+export const ASSOCIATE_DECL_XML = `
+
+ choice_aaa11111 choice_bbb22222
+ choice_ccc33333 choice_ddd44444
+
+`;
+
// Full QTI Assessment Item XML Documents
export const VALID_CHOICE_ITEM_DOCUMENT = `
@@ -281,6 +298,22 @@ export const NO_INTERACTION_ITEM_WITH_HINTS = `
`;
+export const VALID_ASSOCIATE_ITEM_DOCUMENT = `
+
+ ${ASSOCIATE_DECL_XML}
+
+
+ ${ASSOCIATE_XML}
+
+`;
+
export const TWO_INTERACTIONS_DOCUMENT = `
Date: Mon, 31 Aug 2026 09:06:23 -0700
Subject: [PATCH 2/7] Add associate interaction descriptor and composable
Co-Authored-By: Claude Opus 5 (1M context)
---
.../__tests__/useAssociateInteraction.spec.js | 122 ++++++++++++++++++
.../composables/useAssociateInteraction.js | 74 +++++++++++
.../interactions/associate/Descriptor.js | 81 ++++++++++++
.../associate/__tests__/Descriptor.spec.js | 20 +++
4 files changed, 297 insertions(+)
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Descriptor.js
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Descriptor.spec.js
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/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/__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"');
+ });
+});
From 2c9b477962a5620a6acd70f3ee82ac74760bd37c Mon Sep 17 00:00:00 2001
From: rtibblesbot
Date: Mon, 21 Sep 2026 12:53:41 -0700
Subject: [PATCH 3/7] Flush editor content up before emitting minimize
Toolbar buttons carry `@mousedown.prevent` so a formatting press keeps the
caret, which means the blur that syncs content to the parent never happens
when the author finishes by pressing minimize. A parent that acts on the
close then reads the content as it stood before the last edit.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../TipTapEditor/TipTapEditor.vue | 6 ++-
.../__tests__/TipTapEditor.spec.js | 43 +++++++++++++++++++
2 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue
index bb2eb37666..26fe780340 100644
--- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue
@@ -272,7 +272,7 @@
{ immediate: true },
);
- // sync changes from the editor to the parent component, only on blur
+ // sync changes from the editor to the parent component
const emitContentUpdate = () => {
if (!editor.value || !isReady.value) {
return;
@@ -316,6 +316,10 @@
sharedEventHandlers,
editorMode: computed(() => props.mode),
emitMinimize: () => {
+ // Toolbar buttons suppress blur to keep the caret, so content written since
+ // the last blur is still unsynced. Flush it first: a parent acting on the
+ // close would otherwise read the content as it stood before that edit.
+ emitContentUpdate();
emit('minimize');
},
handleContainerKeydown,
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js
index 7f412e7ef5..143dcf05c5 100644
--- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js
@@ -1,3 +1,7 @@
+import { render, screen } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
+import { nextTick } from 'vue';
+import VueRouter from 'vue-router';
import TipTapEditor from '../TipTapEditor/TipTapEditor.vue';
function makeEditorStub({ markdownOut, htmlOut }) {
@@ -87,3 +91,42 @@ describe('TipTapEditor — getContent() logic', () => {
});
});
});
+
+describe('TipTapEditor — minimizing from the toolbar', () => {
+ const renderEditor = async listeners => {
+ let editor;
+ render(
+ {
+ components: { TipTapEditor },
+ template:
+ '',
+ mounted() {
+ editor = this.$children[0];
+ },
+ },
+ { listeners, routes: new VueRouter() },
+ );
+ // The toolbar treats its buttons as unavailable until the editor reports ready,
+ // which it does a tick after it is constructed.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ await nextTick();
+ return editor;
+ };
+
+ const minimize = user => user.click(screen.getByRole('button', { name: 'Minimize Toolbar' }));
+
+ it('emits the content written since the last blur, before it emits minimize', async () => {
+ const user = userEvent.setup();
+ const update = jest.fn();
+ const onMinimize = jest.fn();
+ const editor = await renderEditor({ update, minimize: onMinimize });
+ editor.editor.commands.setContent('after
');
+
+ await minimize(user);
+
+ expect(update).toHaveBeenCalledWith('after
');
+ expect(update.mock.invocationCallOrder.at(-1)).toBeLessThan(
+ onMinimize.mock.invocationCallOrder[0],
+ );
+ });
+});
From bdc2f62449d308ef93a7e6e0c25e0a29a80db8e8 Mon Sep 17 00:00:00 2001
From: rtibblesbot
Date: Mon, 31 Aug 2026 09:06:27 -0700
Subject: [PATCH 4/7] Add associate interaction editor and register the plugin
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Registering the descriptor does not populate QUESTION_TYPE_LABELS, so
QTIItemEditor gets an explicit ASSOCIATE entry — without it every associate
item's view-mode header reads "Unknown type".
Co-Authored-By: Claude Opus 5 (1M context)
---
.../__tests__/QTIItemEditor.spec.js | 27 +
.../components/QTIItemEditor/index.vue | 1 +
.../interactions/associate/Editor.vue | 1121 +++++++++++++++++
.../associate/__tests__/Editor.spec.js | 603 +++++++++
.../QTIEditor/interactions/descriptors.js | 2 +
.../views/QTIEditor/interactions/index.js | 2 +
.../TipTapEditor/__mocks__/TipTapEditor.vue | 18 +
7 files changed, 1774 insertions(+)
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/Editor.spec.js
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/interactions/associate/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue
new file mode 100644
index 0000000000..4d7d0e18f6
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/Editor.vue
@@ -0,0 +1,1121 @@
+
+
+
+
+
+
+ {{ 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__/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