From b35b802e4fd17d706ce5686157a412f396259047 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Mon, 31 Aug 2026 09:06:17 -0700 Subject: [PATCH 1/5] 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 | 4 + .../associate/__tests__/parse.spec.js | 367 ++++++++++++++++++ .../associate/__tests__/validate.spec.js | 153 ++++++++ .../QTIEditor/interactions/associate/parse.js | 204 ++++++++++ .../interactions/associate/validate.js | 47 +++ .../views/QTIEditor/qtiEditorStrings.js | 69 ++++ .../views/QTIEditor/utils/testingFixtures.js | 33 ++ 7 files changed, 877 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__/validate.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/validate.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 3fa2bcd6a4..c668382b56 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -53,6 +53,7 @@ export const QtiInteraction = Object.freeze({ CHOICE: 'qti-choice-interaction', ORDER: 'qti-order-interaction', MATCH: 'qti-match-interaction', + ASSOCIATE: 'qti-associate-interaction', TEXT_ENTRY: 'qti-text-entry-interaction', EXTENDED_TEXT: 'qti-extended-text-interaction', }); @@ -81,6 +82,7 @@ export const QuestionType = Object.freeze({ TEXT_ENTRY: 'textEntry', FREE_RESPONSE: 'freeResponse', ORDERING: 'ordering', + ASSOCIATE: 'associate', }); /** @@ -98,6 +100,8 @@ export const ValidationError = Object.freeze({ EMPTY_ANSWER_CONTENT: 'EMPTY_ANSWER_CONTENT', DUPLICATE_ANSWER_CONTENT: 'DUPLICATE_ANSWER_CONTENT', TOO_FEW_CHOICES: 'TOO_FEW_CHOICES', + TOO_FEW_PAIRS: 'TOO_FEW_PAIRS', + DUPLICATE_PAIR_CONTENT: 'DUPLICATE_PAIR_CONTENT', }); export const RESPONSE_IDENTIFIER = 'RESPONSE'; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js new file mode 100644 index 0000000000..d76471b258 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/parse.spec.js @@ -0,0 +1,367 @@ +/* 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 { + _defaultState, + buildAssociateInteractionXML as buildXML, + parseAssociateInteraction as parse, +} from '../parse'; +import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../../utils/testingFixtures'; +import { BaseType, Cardinality, QuestionType } from '../../../constants'; + +const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content)); + +const SCHEMA = { baseType: BaseType.PAIR, cardinality: Cardinality.MULTIPLE }; + +function parseXmlString(xml) { + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, 'text/xml'); + const err = doc.querySelector('parseerror, parsererror'); + if (err) throw new Error(`Invalid XML: ${err.textContent}`); + return doc.documentElement; +} + +describe('_defaultState()', () => { + it('seeds a single pair of two blank choices', () => { + const state = _defaultState(); + expect(contentsOf(state.pairs)).toEqual([['', '']]); + }); + + it('gives the two seeded choices distinct generated ids', () => { + const [[first, second]] = _defaultState().pairs; + expect(first.id).toMatch(/^choice_/); + expect(second.id).toMatch(/^choice_/); + expect(first.id).not.toBe(second.id); + }); +}); + +describe('parse()', () => { + describe('fallbacks', () => { + it('returns the default state when bodyXml is empty', () => { + const state = parse('', [ASSOCIATE_DECL_XML]); + expect(state.pairs).toHaveLength(1); + expect(state.distractors).toEqual([]); + }); + + 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('drops an empty , keeping the well-formed pairs', () => { + const decl = ` + + + choice_ccc33333 choice_ddd44444 + + `; + expect(contentsOf(parse(ASSOCIATE_XML, [decl]).pairs)).toEqual([['Capulet', 'Montague']]); + }); + + it('drops a pair naming identifiers that only exist on Object.prototype', () => { + const decl = ` + + constructor toString + + `; + expect(parse(ASSOCIATE_XML, [decl]).pairs).toEqual([]); + }); + }); + + 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('subtracts pair appearances for a choice named after an Object.prototype member', () => { + const xml = ` + Antonio + Prospero + `; + const decl = ` + + constructor choice_bbb22222 + + `; + expect(parse(xml, [decl]).distractors.map(choice => choice.id)).toEqual(['constructor']); + }); + + 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_/); + }); + }); +}); + +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('sets max-associations to the number of pairs', () => { + const root = parseXmlString(build(baseState).bodyXml); + expect(root.getAttribute('max-associations')).toBe('2'); + }); + + it('always emits shuffle="true"', () => { + const root = parseXmlString(build(baseState).bodyXml); + expect(root.getAttribute('shuffle')).toBe('true'); + }); + + it('emits the response identifier from state', () => { + const root = parseXmlString(build(baseState).bodyXml); + expect(root.getAttribute('response-identifier')).toBe('RESPONSE'); + }); + + 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 distinct 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', + ]); + }); + + it('gives every singly-used choice match-max="1"', () => { + const root = parseXmlString(build(baseState).bodyXml); + 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('reassigns the id of a later choice that reuses an id with different content', () => { + const pairs = [ + baseState.pairs[0], + [ + { id: 'choice_aaa11111', content: 'Capulet' }, + { id: 'choice_ddd44444', content: 'Montague' }, + ], + ]; + const { bodyXml, responseDeclarations } = build({ + ...baseState, + pairs, + distractors: [], + }); + const [capulet] = choicesOf(parseXmlString(bodyXml)).filter( + el => el.textContent === 'Capulet', + ); + 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`, + ); + }); + + 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('sets cardinality="multiple"', () => { + const decl = parseXmlString(build(baseState).responseDeclarations[0]); + expect(decl.getAttribute('cardinality')).toBe('multiple'); + }); + + it('sets base-type="pair"', () => { + const decl = parseXmlString(build(baseState).responseDeclarations[0]); + 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 the default state as one pair of two blank choices', () => { + const reparsed = roundTrip(_defaultState()); + expect(contentsOf(reparsed.pairs)).toEqual([['', '']]); + expect(reparsed.distractors).toEqual([]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validate.spec.js new file mode 100644 index 0000000000..3f655f5797 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/validate.spec.js @@ -0,0 +1,153 @@ +import { validateAssociateInteraction } from '../validate'; +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('TOO_FEW_PAIRS', () => { + it('returns error when there are no pairs at all', () => { + expect(errorCodes(validateAssociateInteraction(makeState({ pairs: [] })))).toContain( + ValidationError.TOO_FEW_PAIRS, + ); + }); + + it('does not return error when a later pair is complete and distinct', () => { + const state = makeState({ + pairs: [ + [ + { id: 'choice_aaa11111', content: '' }, + { id: 'choice_bbb22222', content: 'Prospero' }, + ], + [ + { id: 'choice_ccc33333', content: 'Capulet' }, + { id: 'choice_ddd44444', 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..79a94eba1b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js @@ -0,0 +1,204 @@ +import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; +import { getPromptHTML, parseXML } from '../../serialization/parseItem'; +import { buildXmlNode } from '../../serialization/assembleItem'; +import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; +import { generateRandomSlug } from '../../utils/generateRandomSlug'; +import { stripTags } from '../../utils/stripTags'; +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 + */ + +/** + * @returns {AssociateState} + */ +export function _defaultState() { + return { + responseIdentifier: RESPONSE_IDENTIFIER, + prompt: '', + distractors: [], + pairs: [ + [ + { id: generateRandomSlug('choice'), content: '' }, + { id: generateRandomSlug('choice'), content: '' }, + ], + ], + }; +} + +/** + * Extract the correct pairs from a response declaration string as id couples. + * + * @param {string[]} declarations + * @returns {string[][]} + */ +export function _extractCorrectPairIds(declarations) { + const [declXml] = declarations || []; + if (!declXml) return []; + + try { + const declEl = parseXML(declXml).documentElement; + const declaration = QTIDeclaration.fromXML(declEl); + // An empty coerces to null rather than to an id couple. + return (declaration.correctResponse ?? []).filter(Array.isArray); + } 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.map(id => poolById.get(id))) + .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 pairs.flat()) { + pairedCount.set(id, (pairedCount.get(id) || 0) + 1); + } + + const distractors = pool.flatMap(({ 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 = stripTags(content).trim(); + + // 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 [...resolvedPairs.flat(), ...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/validate.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validate.js new file mode 100644 index 0000000000..88e903c893 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/validate.js @@ -0,0 +1,47 @@ +import { ValidationError } from '../../constants'; +import { stripTags } from '../../utils/stripTags'; + +const text = content => stripTags(content).trim(); + +/** + * 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. + * + * @param {object} state - AssociateState + * @returns {Array<{ code: string, id?: string, index?: number }>} + */ +export function validateAssociateInteraction(state) { + const errors = []; + const { prompt, pairs = [], distractors = [] } = state; + + if (!text(prompt)) { + errors.push({ code: ValidationError.PROMPT_REQUIRED }); + } + + for (const { id, content } of [...pairs.flat(), ...distractors]) { + if (!text(content)) { + errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id }); + } + } + + let validPairs = 0; + pairs.forEach(([first, second], index) => { + const [firstText, secondText] = [text(first.content), text(second.content)]; + if (!firstText || !secondText) { + return; + } + if (firstText === secondText) { + errors.push({ code: ValidationError.DUPLICATE_PAIR_CONTENT, index }); + } else { + validPairs += 1; + } + }); + + if (validPairs < 1) { + errors.push({ code: ValidationError.TOO_FEW_PAIRS }); + } + + return errors; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index f597368a2e..20ff876991 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -99,6 +99,75 @@ 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: 'Connect pairs', + context: 'Display name for an associate question type shown in the question type selector', + }, + associateDescription: { + message: 'Learners must draw connections between pairs of items.', + context: 'Description for the associate question type in the info modal', + }, + correctPairsLabel: { + message: 'Correct pairs', + context: 'Section header above the list of correctly associated pairs', + }, + correctPairsDescription: { + message: 'Learners will see all options shuffled together', + context: 'Subtitle under the correct pairs header', + }, + distractorsLabel: { + message: 'Additional options', + context: 'Section header above the options that belong to no correct pair', + }, + distractorsDescription: { + message: 'Options that are not part of any correct pair', + context: 'Subtitle under the additional options header', + }, + responsePoolLabel: { + message: 'Options (shuffled)', + context: 'Header above the shuffled pool of options 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 option', + context: 'Button that appends a new additional option', + }, + deleteDistractorBtn: { + message: 'Delete option {number}', + context: 'Accessible label for the delete icon button on an additional option', + }, + editPairItemLabel: { + message: 'Edit pair {number}, item {position}', + context: 'Accessible label for the clickable region to edit one item of a pair', + }, + editDistractorLabel: { + message: 'Edit option {number}', + context: 'Accessible label for the clickable region to edit an additional option', + }, + 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', + }, 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 24c12a77c2..64bf49506d 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 = ` @@ -91,6 +108,22 @@ export const VALID_CHOICE_ITEM_DOCUMENT = ` `; +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/5] Add associate interaction descriptor and composable Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/useAssociateInteraction.spec.js | 115 ++++++++++++++++++ .../composables/useAssociateInteraction.js | 71 +++++++++++ .../AssociateInteractionDescriptor.js | 85 +++++++++++++ .../AssociateInteractionDescriptor.spec.js | 49 ++++++++ 4 files changed, 320 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/AssociateInteractionDescriptor.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.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..dc6ef2a073 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useAssociateInteraction.spec.js @@ -0,0 +1,115 @@ +import { ref } from 'vue'; +import { useAssociateInteraction } from '../useAssociateInteraction'; +import { ASSOCIATE_XML, ASSOCIATE_DECL_XML } from '../../utils/testingFixtures'; +import { QuestionType, ValidationError } from '../../constants'; + +const GENERATED_ID = /^choice_[a-zA-Z0-9]{8}$/; + +const contentsOf = pairs => pairs.map(pair => pair.map(choice => choice.content)); + +describe('useAssociateInteraction', () => { + function setup(bodyXml = ASSOCIATE_XML, declarationXml = ASSOCIATE_DECL_XML) { + const questionType = ref(QuestionType.ASSOCIATE); + return useAssociateInteraction( + { bodyXml, responseDeclarations: [declarationXml] }, + questionType, + ); + } + + describe('initial state', () => { + it('parses pairs and distractors from the fixture XML', () => { + const { state } = setup(); + expect(contentsOf(state.value.pairs)).toEqual([ + ['Antonio', 'Prospero'], + ['Capulet', 'Montague'], + ]); + expect(state.value.distractors.map(d => d.content)).toEqual(['Lysander']); + }); + }); + + describe('addPair()', () => { + it('appends a pair of two blank choices with distinct generated ids', () => { + const { state, addPair } = setup(); + addPair(); + expect(state.value.pairs).toHaveLength(3); + const [first, second] = state.value.pairs[2]; + expect(first.content).toBe(''); + expect(second.content).toBe(''); + expect(first.id).toMatch(GENERATED_ID); + expect(second.id).toMatch(GENERATED_ID); + expect(first.id).not.toBe(second.id); + }); + + it('leaves the existing pairs untouched', () => { + const { state, addPair } = setup(); + addPair(); + expect(contentsOf(state.value.pairs.slice(0, 2))).toEqual([ + ['Antonio', 'Prospero'], + ['Capulet', 'Montague'], + ]); + }); + + it('rebuilds bodyXml so the mutation reaches the emitted interaction', () => { + const { bodyXml, addPair } = setup(); + const before = bodyXml.value; + addPair(); + expect(bodyXml.value).not.toBe(before); + }); + }); + + describe('removePair()', () => { + it('drops the pair at the given index and keeps the rest in order', () => { + const { state, removePair } = setup(); + removePair(0); + expect(contentsOf(state.value.pairs)).toEqual([['Capulet', 'Montague']]); + }); + }); + + describe('setPair()', () => { + it('replaces only the pair at the given index', () => { + const { state, setPair } = setup(); + const [first, second] = state.value.pairs[0]; + setPair(0, [{ ...first, content: '

Updated

' }, second]); + expect(contentsOf(state.value.pairs)).toEqual([ + ['

Updated

', 'Prospero'], + ['Capulet', 'Montague'], + ]); + }); + }); + + describe('addDistractor()', () => { + it('appends one blank choice with a generated id', () => { + const { state, addDistractor } = setup(); + addDistractor(); + expect(state.value.distractors).toHaveLength(2); + expect(state.value.distractors[1].content).toBe(''); + expect(state.value.distractors[1].id).toMatch(GENERATED_ID); + }); + }); + + describe('removeDistractor()', () => { + it('drops the distractor at the given index', () => { + const { state, removeDistractor } = setup(); + removeDistractor(0); + expect(state.value.distractors).toEqual([]); + }); + }); + + describe('setDistractorContent()', () => { + it('updates only the targeted distractor', () => { + const { state, addDistractor, setDistractorContent } = setup(); + addDistractor(); + setDistractorContent(1, '

Updated

'); + expect(state.value.distractors.map(d => d.content)).toEqual(['Lysander', '

Updated

']); + }); + }); + + describe('runValidation()', () => { + it('populates errors for an invalid state', () => { + const { setPrompt, runValidation, errors } = setup(); + setPrompt(''); + runValidation(); + expect(errors.value.map(e => e.code)).toContain(ValidationError.PROMPT_REQUIRED); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js new file mode 100644 index 0000000000..1e4903a92f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useAssociateInteraction.js @@ -0,0 +1,71 @@ +import { readonly } from 'vue'; +import { generateRandomSlug } from '../utils/generateRandomSlug'; +import { associateInteractionDescriptor } from '../interactions/associate/AssociateInteractionDescriptor'; +import { useInteraction } from './useInteraction'; + +const blankChoice = () => ({ id: generateRandomSlug('choice'), content: '' }); + +/** + * Composable for the associate interaction editor. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + */ +export function useAssociateInteraction(interactionBlock, questionType) { + const base = useInteraction(associateInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + function addPair() { + state.value = { ...state.value, pairs: [...state.value.pairs, [blankChoice(), blankChoice()]] }; + } + + function removePair(index) { + state.value = { + ...state.value, + pairs: state.value.pairs.filter((_, i) => i !== index), + }; + } + + function setPair(index, newPair) { + state.value = { + ...state.value, + pairs: state.value.pairs.map((pair, i) => (i === index ? newPair : pair)), + }; + } + + function addDistractor() { + state.value = { ...state.value, distractors: [...state.value.distractors, blankChoice()] }; + } + + function removeDistractor(index) { + state.value = { + ...state.value, + distractors: state.value.distractors.filter((_, i) => i !== index), + }; + } + + function setDistractorContent(index, html) { + state.value = { + ...state.value, + distractors: state.value.distractors.map((choice, i) => + i === index ? { ...choice, content: html } : choice, + ), + }; + } + + function setPrompt(html) { + state.value = { ...state.value, prompt: html }; + } + + return { + ...base, + state: readonly(state), + addPair, + removePair, + setPair, + addDistractor, + removeDistractor, + setDistractorContent, + setPrompt, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionDescriptor.js new file mode 100644 index 0000000000..bac3347bd3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionDescriptor.js @@ -0,0 +1,85 @@ +import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { parseAssociateInteraction, buildAssociateInteractionXML } from './parse'; +import { validateAssociateInteraction } from './validate'; + +/** + * Owns all associate-specific interaction logic: schema, parse, buildXML, and validate. + */ +export class AssociateInteractionDescriptor { + constructor({ editorComponent = null } = {}) { + this.type = QtiInteraction.ASSOCIATE; + this.placement = 'block'; + this.questionTypes = [QuestionType.ASSOCIATE]; + this.editorComponent = editorComponent; + this.convertsFrom = []; + } + + getTypeOptions(tr) { + return [ + { + value: QuestionType.ASSOCIATE, + label: tr.associateLabel$(), + description: tr.associateDescription$(), + }, + ]; + } + + /** @param {Element} el */ + matches(el) { + return el.tagName.toLowerCase() === QtiInteraction.ASSOCIATE; + } + + /** + * Associate always has exactly one question type. + * + * @returns {string} + */ + getQuestionType() { + return QuestionType.ASSOCIATE; + } + + /** + * @returns {{ baseType: string, cardinality: string }} + */ + getResponseDeclarationSchema() { + return { + baseType: BaseType.PAIR, + cardinality: Cardinality.MULTIPLE, + }; + } + + /** + * Parse body XML + response declarations → AssociateState. + * + * @param {string} bodyXml + * @param {string[]} responseDeclarations + * @returns {object} AssociateState + */ + parse(bodyXml, responseDeclarations) { + return parseAssociateInteraction(bodyXml, responseDeclarations); + } + + /** + * Serialize AssociateState → { bodyXml, responseDeclarations }. + * + * @param {object} state - AssociateState + * @param {string} questionType + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ + buildXML(state, questionType) { + return buildAssociateInteractionXML(state, questionType, this.getResponseDeclarationSchema()); + } + + /** + * Validate AssociateState → ValidationError[]. + * + * @param {object} state - AssociateState + * @returns {Array<{ code: string, id?: string, index?: number }>} + */ + validate(state) { + return validateAssociateInteraction(state); + } +} + +/** Singleton — safe to import from any file in the associate module tree. */ +export const associateInteractionDescriptor = new AssociateInteractionDescriptor(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.spec.js new file mode 100644 index 0000000000..292a81fdf4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionDescriptor.spec.js @@ -0,0 +1,49 @@ +import { associateInteractionDescriptor as descriptor } from '../AssociateInteractionDescriptor'; +import { parseXML } from '../../../serialization/parseItem'; +import { qtiEditorStrings } from '../../../qtiEditorStrings'; +import { QtiInteraction, QuestionType } from '../../../constants'; +import { + ASSOCIATE_XML, + ASSOCIATE_DECL_XML, + ORDERING_XML, + CHOICE_SINGLE_SELECT_XML, +} from '../../../utils/testingFixtures'; + +const elementOf = xml => parseXML(xml).documentElement; + +describe('AssociateInteractionDescriptor', () => { + it('declares the associate interaction tag, block placement, and question type', () => { + expect(descriptor.type).toBe(QtiInteraction.ASSOCIATE); + expect(descriptor.placement).toBe('block'); + expect(descriptor.questionTypes).toEqual([QuestionType.ASSOCIATE]); + }); + + describe('matches()', () => { + it('matches a element', () => { + expect(descriptor.matches(elementOf(ASSOCIATE_XML))).toBe(true); + }); + + it('does not match other interaction elements', () => { + expect(descriptor.matches(elementOf(ORDERING_XML))).toBe(false); + expect(descriptor.matches(elementOf(CHOICE_SINGLE_SELECT_XML))).toBe(false); + }); + }); + + it('getQuestionType() returns the associate question type', () => { + expect(descriptor.getQuestionType()).toBe(QuestionType.ASSOCIATE); + }); + + it('getTypeOptions() offers the associate question type to the type selector', () => { + const options = descriptor.getTypeOptions(qtiEditorStrings); + expect(options).toHaveLength(1); + expect(options[0].value).toBe(QuestionType.ASSOCIATE); + expect(options[0].label).toBe(qtiEditorStrings.$tr('associateLabel')); + }); + + it('buildXML() forwards its own declaration schema', () => { + const state = descriptor.parse(ASSOCIATE_XML, [ASSOCIATE_DECL_XML]); + const [declXml] = descriptor.buildXML(state, QuestionType.ASSOCIATE).responseDeclarations; + expect(declXml).toContain('base-type="pair"'); + expect(declXml).toContain('cardinality="multiple"'); + }); +}); From f4fcdfec7b71deb9ec4c695eb55e441a92a32759 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Mon, 31 Aug 2026 09:06:27 -0700 Subject: [PATCH 3/5] 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 | 32 +- .../components/QTIItemEditor/index.vue | 1 + .../associate/AssociateInteractionEditor.vue | 815 ++++++++++++++++++ .../AssociateInteractionEditor.spec.js | 225 +++++ .../QTIEditor/interactions/associate/index.js | 5 + .../views/QTIEditor/interactions/index.js | 8 +- 6 files changed, 1084 insertions(+), 2 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionEditor.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionEditor.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/index.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 8a9d19fe02..9708a3397c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -3,6 +3,7 @@ import VueRouter from 'vue-router'; import QTIItemEditor from '../index.vue'; import { qtiEditorStrings } from '../../../qtiEditorStrings'; import { AssessmentItemTypes } from '../../../constants'; +import { VALID_ASSOCIATE_ITEM_DOCUMENT } from '../../../utils/testingFixtures'; jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { @@ -13,7 +14,13 @@ jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { }; }); -const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings; +const { + closeBtnLabel$, + questionContentPlaceholder$, + associateLabel$, + unknownTypeLabel$, + responsePoolLabel$, +} = qtiEditorStrings; const defaultProps = { item: { @@ -77,6 +84,29 @@ describe('QTIItemEditor', () => { }); }); + describe('associate interaction', () => { + const renderAssociateItem = () => + renderComponent({ + item: { + assessment_id: 'test-item-id', + type: AssessmentItemTypes.QTI, + raw_data: VALID_ASSOCIATE_ITEM_DOCUMENT, + }, + }); + + test('names the associate question type rather than falling back to unknown', async () => { + renderAssociateItem(); + expect(await screen.findByText(new RegExp(associateLabel$()))).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(unknownTypeLabel$()))).not.toBeInTheDocument(); + }); + + test('renders the associate editor for the parsed interaction', async () => { + renderAssociateItem(); + expect(await screen.findByText(responsePoolLabel$())).toBeInTheDocument(); + expect(screen.getByText('Antonio')).toBeInTheDocument(); + }); + }); + describe('toolbarActions slot', () => { test('renders content injected into the toolbarActions slot', () => { renderComponent({}, { toolbarActions: '' }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index 40711ad6f9..ee2f245975 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -132,6 +132,7 @@ [QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$, [QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$, [QuestionType.ORDERING]: qtiEditorStrings.orderingLabel$, + [QuestionType.ASSOCIATE]: qtiEditorStrings.associateLabel$, }; return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)(); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionEditor.vue new file mode 100644 index 0000000000..fecfe00edc --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/AssociateInteractionEditor.vue @@ -0,0 +1,815 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionEditor.spec.js new file mode 100644 index 0000000000..aa3631bab9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/__tests__/AssociateInteractionEditor.spec.js @@ -0,0 +1,225 @@ +import { render, screen, within, fireEvent } from '@testing-library/vue'; +import { nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import AssociateInteractionEditor from '../AssociateInteractionEditor.vue'; + +import { + ASSOCIATE_XML, + ASSOCIATE_DECL_XML, + mockInteractionBlock as block, + mockInteractionBlockWithDecl as blockWithDecl, +} from '../../../utils/testingFixtures'; +import { QuestionType } from '../../../constants'; +import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; + +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); +jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { + const { ref } = require('vue'); + return { + __esModule: true, + default: () => ({ windowIsSmall: ref(false) }), + }; +}); + +const POOL_CONTENTS = ['Antonio', 'Prospero', 'Capulet', 'Montague', 'Lysander']; + +// The mock TipTapEditor renders a