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 @@ + + + + + + + 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