From abd4ae541deed07b61d647942fcd9edef4587c76 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Wed, 1 Jul 2026 02:51:47 +0530 Subject: [PATCH 1/5] feat(qti): add base utilities, constants, and ValidationMessage --- .../components/ValidationMessage/index.vue | 40 +++++++++++++ .../shared/views/QTIEditor/constants.js | 13 ++++ .../views/QTIEditor/qtiEditorStrings.js | 60 +++++++++++++++++++ .../__tests__/generateRandomSlug.spec.js | 20 +++++++ .../QTIEditor/utils/generateRandomSlug.js | 10 ++++ 5 files changed, 143 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/generateRandomSlug.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/generateRandomSlug.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue new file mode 100644 index 0000000000..dc98110055 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue @@ -0,0 +1,40 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 2dca90936c..4169f4f783 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -73,3 +73,16 @@ export const QuestionType = Object.freeze({ SINGLE_SELECT: 'singleSelect', MULTI_SELECT: 'multiSelect', }); + +/** + * Error codes returned by each interaction's validate() function. + * Interaction-agnostic codes live here; interaction-specific codes may extend + * this set in their own validate.js module. + */ +export const ValidationError = Object.freeze({ + PROMPT_REQUIRED: 'PROMPT_REQUIRED', + NO_CORRECT_ANSWER: 'NO_CORRECT_ANSWER', + TOO_MANY_CORRECT_ANSWERS: 'TOO_MANY_CORRECT_ANSWERS', + EMPTY_CHOICE_CONTENT: 'EMPTY_CHOICE_CONTENT', + TOO_FEW_CHOICES: 'TOO_FEW_CHOICES', +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 52ca0014d6..dffd3ae3f1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -37,6 +37,18 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Multiple Choice', context: 'Display name for a multiple-select question type', }, + questionLabel: { + message: 'Question', + context: 'Section header for the question prompt', + }, + answersLabelSingleChoice: { + message: 'Answer options \u2014 select one correct answer', + context: 'Section header above single-choice options', + }, + answersLabelMultipleChoice: { + message: 'Answer options \u2014 select all correct answers', + context: 'Section header above multiple-choice options', + }, orderLabel: { message: 'Order', context: 'Display name for an order question type', @@ -81,4 +93,52 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Add question below', context: 'Action to add a new question below the current one', }, + promptPlaceholder: { + message: 'Enter question prompt…', + context: 'Placeholder text inside the prompt rich-text editor', + }, + choiceContentPlaceholder: { + message: 'Enter answer option…', + context: 'Placeholder text inside a choice rich-text editor', + }, + addChoiceBtn: { + message: 'Add choice', + context: 'Button that appends a new answer choice', + }, + deleteChoiceBtn: { + message: 'Delete choice', + context: 'Accessible label for the delete-choice icon button', + }, + moveChoiceUpBtn: { + message: 'Move choice up', + context: 'Accessible label for the move-up icon button', + }, + moveChoiceDownBtn: { + message: 'Move choice down', + context: 'Accessible label for the move-down icon button', + }, + markCorrectLabel: { + message: 'Mark as correct answer', + context: 'Accessible label for radio / checkbox that marks a choice as correct', + }, + errorPromptRequired: { + message: 'A question prompt is required.', + context: 'Validation error shown when the prompt is empty', + }, + errorNoCorrectAnswer: { + message: 'At least one correct answer must be selected.', + context: 'Validation error when no choice is marked correct', + }, + errorTooManyCorrectAnswers: { + message: 'Only one correct answer is allowed for single-choice questions.', + context: 'Validation error when multiple choices are marked correct for single-select', + }, + errorEmptyChoiceContent: { + message: 'Each answer option must have content.', + context: 'Validation error when an answer option is empty', + }, + errorTooFewChoices: { + message: 'At least two answer options are required.', + context: 'Validation error when fewer than two choices exist', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/generateRandomSlug.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/generateRandomSlug.spec.js new file mode 100644 index 0000000000..f26363dadc --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/generateRandomSlug.spec.js @@ -0,0 +1,20 @@ +import { generateRandomSlug } from '../generateRandomSlug'; + +describe('generateRandomSlug', () => { + it('returns a string starting with the given prefix', () => { + expect(generateRandomSlug('choice')).toMatch(/^choice_/); + expect(generateRandomSlug('response')).toMatch(/^response_/); + }); + + it('appends exactly 8 alphanumeric characters after the prefix', () => { + const result = generateRandomSlug('choice'); + const suffix = result.replace('choice_', ''); + expect(suffix).toHaveLength(8); + expect(suffix).toMatch(/^[a-z0-9]{8}$/); + }); + + it('generates unique values on successive calls', () => { + const results = new Set(Array.from({ length: 50 }, () => generateRandomSlug('x'))); + expect(results.size).toBe(50); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/generateRandomSlug.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/generateRandomSlug.js new file mode 100644 index 0000000000..56170a5703 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/generateRandomSlug.js @@ -0,0 +1,10 @@ +/** + * Generate a QTI-safe identifier with the given prefix. + * + * @param {string} prefix + * @returns {string} + */ +export function generateRandomSlug(prefix) { + const slug = Math.random().toString(36).slice(2, 10); + return `${prefix}_${slug}`; +} From 5d496f66c72fb8caf0f75adb401762216058bfab Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Wed, 1 Jul 2026 02:51:58 +0530 Subject: [PATCH 2/5] feat(qti): implement base useInteraction and XML serialization --- .../__tests__/useInteraction.spec.js | 139 ++++++++++++++++++ .../QTIEditor/composables/useInteraction.js | 46 ++++++ .../__tests__/defineInteraction.spec.js | 2 + .../interactions/defineInteraction.js | 1 + .../__tests__/assembleItem.spec.js | 9 ++ .../QTIEditor/serialization/assembleItem.js | 3 +- .../QTIEditor/serialization/parseItem.js | 55 +++++++ .../serialization/qti/QTIDeclaration.js | 24 ++- .../qti/__tests__/QTIDeclaration.spec.js | 19 +++ 9 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js new file mode 100644 index 0000000000..0196a9c215 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js @@ -0,0 +1,139 @@ +import { ref } from 'vue'; +import { useInteraction } from '../useInteraction'; + +// --------------------------------------------------------------------------- +// Minimal descriptor stub +// --------------------------------------------------------------------------- + +function makeDescriptor({ parseReturn = {}, buildReturn = null, validateReturn = [] } = {}) { + return { + parse: jest.fn(() => parseReturn), + buildXML: jest.fn(() => buildReturn ?? { bodyXml: '', declarations: [''] }), + validate: jest.fn(() => validateReturn), + }; +} + +describe('useInteraction', () => { + it('calls descriptor.parse once with the interaction block on creation', () => { + const descriptor = makeDescriptor(); + const block = { bodyXml: '', responseDeclarations: [''] }; + const questionType = ref('singleSelect'); + + useInteraction(descriptor, block, questionType); + + expect(descriptor.parse).toHaveBeenCalledTimes(1); + expect(descriptor.parse).toHaveBeenCalledWith(block.bodyXml, block.responseDeclarations); + }); + + it('exposes initial parsed state as a reactive ref', () => { + const parseReturn = { prompt: 'Hello', answers: [] }; + const descriptor = makeDescriptor({ parseReturn }); + const questionType = ref('singleSelect'); + + const { state } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(state.value).toEqual(parseReturn); + }); + + it('bodyXml and declarations are computed from buildXML', () => { + const descriptor = makeDescriptor({ + buildReturn: { bodyXml: '', declarations: ['', ''] }, + }); + const questionType = ref('singleSelect'); + + const { bodyXml, declarations } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(bodyXml.value).toBe(''); + expect(declarations.value).toEqual(['', '']); + }); + + it('errors starts as an empty array', () => { + const descriptor = makeDescriptor(); + const questionType = ref('singleSelect'); + + const { errors } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(errors.value).toEqual([]); + }); + + it('runValidation populates errors from descriptor.validate', () => { + const validateReturn = [{ code: 'PROMPT_REQUIRED' }]; + const descriptor = makeDescriptor({ validateReturn }); + const questionType = ref('singleSelect'); + + const { errors, runValidation } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + expect(errors.value).toEqual([]); + runValidation(); + expect(errors.value).toEqual(validateReturn); + }); + + it('runValidation passes current state and questionType to validate', () => { + const descriptor = makeDescriptor(); + const questionType = ref('singleSelect'); + const block = { bodyXml: '', responseDeclarations: [] }; + + const { state, runValidation } = useInteraction(descriptor, block, questionType); + state.value = { prompt: 'updated' }; + questionType.value = 'multiSelect'; + runValidation(); + + expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'multiSelect'); + }); + + it('bodyXml recomputes when state changes', () => { + let callCount = 0; + const descriptor = { + parse: jest.fn(() => ({ prompt: '' })), + buildXML: jest.fn(() => ({ bodyXml: `call-${++callCount}`, declarations: [] })), + validate: jest.fn(() => []), + }; + const questionType = ref('singleSelect'); + + const { state, bodyXml } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + const first = bodyXml.value; + state.value = { prompt: 'changed' }; + const second = bodyXml.value; + + expect(first).not.toBe(second); + }); + + it('bodyXml recomputes when questionType changes', () => { + const descriptor = makeDescriptor(); + const questionType = ref('singleSelect'); + + const { bodyXml } = useInteraction( + descriptor, + { bodyXml: '', responseDeclarations: [] }, + questionType, + ); + + bodyXml.value; // trigger initial compute + questionType.value = 'multiSelect'; + bodyXml.value; // trigger recompute + + expect(descriptor.buildXML).toHaveBeenCalledTimes(2); + expect(descriptor.buildXML).toHaveBeenLastCalledWith(expect.anything(), 'multiSelect'); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js new file mode 100644 index 0000000000..6259490dc6 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js @@ -0,0 +1,46 @@ +import { ref, computed } from 'vue'; + +/** + * Base composable for all interaction editors. + * + * Handles the parse → state → buildXML → validate lifecycle that every + * interaction plugin must go through. Individual interaction composables + * (e.g. useChoiceInteraction) call this and add mutation methods on top. + * + * @param {import('../interactions/defineInteraction').InteractionDescriptor} descriptor + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + * @returns {{ + * state: import('vue').Ref, + * bodyXml: import('vue').ComputedRef, + * declarations: import('vue').ComputedRef, + * errors: import('vue').Ref>, + * runValidation: () => void, + * }} + */ +export function useInteraction(descriptor, interactionBlock, questionType) { + const initialState = descriptor.parse( + interactionBlock.bodyXml, + interactionBlock.responseDeclarations, + ); + + const state = ref(initialState); + + // Rebuild XML whenever state or questionType changes. + const built = computed(() => { + if (!questionType.value) return { bodyXml: '', declarations: [] }; + return descriptor.buildXML(state.value, questionType.value); + }); + + const bodyXml = computed(() => built.value.bodyXml); + const declarations = computed(() => built.value.declarations); + + // Errors start empty — never shown automatically so authors aren't startled on first load. + const errors = ref([]); + + function runValidation() { + errors.value = descriptor.validate(state.value, questionType.value); + } + + return { state, bodyXml, declarations, errors, runValidation }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js index 2b3a42d659..5829df482d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js @@ -11,6 +11,7 @@ const makeValidDescriptor = (overrides = {}) => ({ getQuestionType: () => null, getDeclarationSchema: () => ({ baseType: 'string', cardinality: 'single' }), parse: () => ({}), + buildXML: () => ({ bodyXml: '', declarations: [] }), validate: () => [], ...overrides, }); @@ -31,6 +32,7 @@ describe('defineInteraction', () => { 'getQuestionType', 'getDeclarationSchema', 'parse', + 'buildXML', 'validate', ]; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js index ae910c7c6c..60498178ec 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js @@ -12,6 +12,7 @@ const REQUIRED_KEYS = [ 'getQuestionType', 'getDeclarationSchema', 'parse', + 'buildXML', 'validate', ]; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js index 24357fe5c7..8a53ccfa75 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js @@ -74,6 +74,15 @@ describe('assembleItem', () => { const values = [...parent.getElementsByTagName('qti-value')].map(n => n.textContent); expect(values).toEqual(['A', 'B']); }); + + it('imports element children from another XML document', () => { + const child = new DOMParser().parseFromString( + 'ChoiceA', + 'text/xml', + ).documentElement; + const parent = buildXmlNode({ tag: 'qti-correct-response', children: [child] }); + expect(parent.getElementsByTagName('qti-value')[0].textContent).toBe('ChoiceA'); + }); }); describe('mixed children', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 693c84f2b3..c419269add 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -33,7 +33,8 @@ export function buildXmlNode({ tag, attrs = {}, children = [] }) { if (typeof child === 'string') { el.appendChild(xmlDoc.createTextNode(child)); } else { - el.appendChild(child); + const childNode = child.ownerDocument === xmlDoc ? child : xmlDoc.importNode(child, true); + el.appendChild(childNode); } } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js index 9a44a6eef7..96c3151ed9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -22,6 +22,20 @@ export function parseXML(xmlString) { return doc; } +/** + * Extract the inner HTML of the first child of an interaction element. + * Returns an empty string when no prompt element is present. + * Using innerHTML (not textContent) preserves rich inline markup (

, , etc.) + * for round-trip fidelity. + * + * @param {Element} interactionEl - The root element + * @returns {string} + */ +export function getPromptHTML(interactionEl) { + const promptEl = interactionEl.querySelector('qti-prompt'); + return promptEl ? promptEl.innerHTML : ''; +} + /** * Parses a raw QTI XML string into the structured ItemModel. * @@ -72,3 +86,44 @@ export function parseItem(rawData) { return { identifier, title, language, interactions }; } + +/** + * Reassembles a full QTI assessment-item XML string from its parsed parts. + * + * This is the write-path inverse of parseItem. Call it whenever an interaction + * editor emits updated bodyXml / responseDeclarations to produce the new raw_data + * that should be stored on the assessment item. + * + * @param {object} params + * @param {string} params.identifier - Item identifier attribute + * @param {string} params.title - Item title attribute + * @param {string} params.language - xml:lang attribute value + * @param {string} params.bodyXml - Serialized interaction element XML string + * @param {string[]} params.responseDeclarations - Array of serialized declaration XML strings + * @returns {string} Full QTI XML string + */ +export function reassembleItemXml({ identifier, title, language, bodyXml, responseDeclarations }) { + const declarations = (responseDeclarations || []).join('\n '); + const lang = language || 'en'; + const id = identifier || 'item'; + const t = title || ''; + + return [ + '', + ``, + declarations ? ` ${declarations}` : '', + ` `, + ` ${bodyXml}`, + ` `, + ``, + ] + .filter(line => line !== '') + .join('\n'); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js index 3b028d22a5..a149954130 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/QTIDeclaration.js @@ -8,7 +8,6 @@ * */ import { buildXmlNode } from '../assembleItem.js'; -import { getDescriptorForQuestionType } from '../../interactions/index.js'; import { BaseType, Cardinality } from '../../constants.js'; import { declarationParsers, CAPABILITY } from './declarations/index.js'; @@ -341,17 +340,26 @@ export class QTIDeclaration { // --------------------------------------------------------------------------- /** - * Create a blank QTIDeclaration shaped for the given question type. - * Delegates to the factory registered by each interaction module. + * Create a blank QTIDeclaration from an interaction descriptor's schema. * - * @param {string} questionType - One of QuestionType.* (must have a registered factory) + * QTIDeclaration deliberately does not import the interaction registry. Callers + * that already know the relevant descriptor can pass it in, keeping schema + * ownership with the interaction while preserving a one-way dependency graph. + * + * @param {{ + * getDeclarationSchema: function(string, *): { baseType: string, cardinality: string }, + * }} descriptor + * @param {string} questionType - One of QuestionType.* * @param {string} [identifier] - Response identifier, defaults to 'RESPONSE' - * @param {*} [itemData] - Optional item data forwarded to the factory + * @param {*} [itemData] - Optional item data forwarded to the descriptor * @returns {QTIDeclaration} */ - static forType(questionType, identifier = 'RESPONSE', itemData = null) { - const descriptor = getDescriptorForQuestionType(questionType); - if (!descriptor) throw new Error(`Unknown question type: ${questionType}`); + static fromInteractionDescriptor( + descriptor, + questionType, + identifier = 'RESPONSE', + itemData = null, + ) { const schema = descriptor.getDeclarationSchema(questionType, itemData); return new QTIDeclaration({ identifier, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js index 81d257bf4d..b2ae2a6a50 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/QTIDeclaration.spec.js @@ -59,6 +59,25 @@ describe('QTIDeclaration.registerCapability', () => { }); }); +describe('QTIDeclaration.fromInteractionDescriptor', () => { + it('creates a declaration from descriptor-owned schema', () => { + const descriptor = { + getDeclarationSchema: jest.fn(() => ({ + baseType: 'identifier', + cardinality: 'multiple', + })), + }; + + const d = QTIDeclaration.fromInteractionDescriptor(descriptor, 'multiSelect', 'RESPONSE'); + + expect(descriptor.getDeclarationSchema).toHaveBeenCalledWith('multiSelect', null); + expect(d.identifier).toBe('RESPONSE'); + expect(d.baseType).toBe('identifier'); + expect(d.cardinality).toBe('multiple'); + expect(d.tag).toBe('qti-response-declaration'); + }); +}); + describe('QTIDeclaration.fromXML', () => { it('reads identifier from XML', () => { const d = QTIDeclaration.fromXML(parseXML(SINGLE_SELECT_DECLARATION)); From 283ab8d979a4ae03aff3a6294c78d982d6eaba04 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Wed, 1 Jul 2026 02:52:17 +0530 Subject: [PATCH 3/5] feat(qti): implement ChoiceInteractionDescriptor and useChoiceInteraction --- .../__tests__/useChoiceInteraction.spec.js | 197 +++++++++++++ .../composables/useChoiceInteraction.js | 118 ++++++++ .../choice/ChoiceInteractionDescriptor.js | 241 ++++++++++++++++ .../ChoiceInteractionDescriptor.spec.js | 37 +++ .../choice/__tests__/parse.spec.js | 261 ++++++++++++++++++ .../choice/__tests__/validate.spec.js | 148 ++++++++++ .../QTIEditor/interactions/choice/index.js | 92 ++---- .../views/QTIEditor/utils/testingFixtures.js | 26 +- 8 files changed, 1041 insertions(+), 79 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js new file mode 100644 index 0000000000..0b48c4d064 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js @@ -0,0 +1,197 @@ +import { ref } from 'vue'; +import { useChoiceInteraction } from '../useChoiceInteraction'; +import { QuestionType } from '../../constants'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAnswer(overrides = {}) { + return { id: 'choice_a', content: 'A', correct: false, fixed: false, ...overrides }; +} + +function makeBlock(answers, questionType = QuestionType.SINGLE_SELECT) { + const maxChoices = questionType === QuestionType.SINGLE_SELECT ? 1 : 2; + const correctIds = answers.filter(a => a.correct).map(a => a.id); + + const bodyXml = ` + ${answers.map(a => `${a.content}`).join('\n ')} + `; + + const declaration = ` + + ${correctIds.map(id => `${id}`).join('')} + + `; + + return { bodyXml, responseDeclarations: [declaration] }; +} + +function setup(answers, questionType = QuestionType.SINGLE_SELECT) { + const qt = ref(questionType); + const block = makeBlock(answers, questionType); + return { qt, ...useChoiceInteraction(block, qt) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useChoiceInteraction', () => { + describe('addChoice()', () => { + it('appends a new answer to the list', () => { + const { state, addChoice } = setup([ + makeAnswer({ id: 'a', content: 'A' }), + makeAnswer({ id: 'b', content: 'B' }), + ]); + addChoice(); + expect(state.value.answers).toHaveLength(3); + }); + + it('new answer has a generated "choice_" identifier', () => { + const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + addChoice(); + const newAnswer = state.value.answers[2]; + expect(newAnswer.id).toMatch(/^choice_[a-z0-9]{8}$/); + }); + + it('new answer has empty content and correct: false', () => { + const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + addChoice(); + const newAnswer = state.value.answers[2]; + expect(newAnswer.content).toBe(''); + expect(newAnswer.correct).toBe(false); + }); + }); + + describe('removeChoice()', () => { + it('removes the answer with the given id', () => { + const { state, removeChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + removeChoice('a'); + expect(state.value.answers.find(a => a.id === 'a')).toBeUndefined(); + }); + + it('is a no-op when only one answer remains', () => { + const { state, removeChoice } = setup([makeAnswer({ id: 'a' })]); + removeChoice('a'); + expect(state.value.answers).toHaveLength(1); + }); + }); + + describe('moveChoiceUp()', () => { + it('swaps answer with the previous one', () => { + const { state, moveChoiceUp } = setup([ + makeAnswer({ id: 'a' }), + makeAnswer({ id: 'b' }), + makeAnswer({ id: 'c' }), + ]); + moveChoiceUp('b'); + expect(state.value.answers.map(a => a.id)).toEqual(['b', 'a', 'c']); + }); + + it('is a no-op when the answer is first', () => { + const { state, moveChoiceUp } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + moveChoiceUp('a'); + expect(state.value.answers.map(a => a.id)).toEqual(['a', 'b']); + }); + }); + + describe('moveChoiceDown()', () => { + it('swaps answer with the next one', () => { + const { state, moveChoiceDown } = setup([ + makeAnswer({ id: 'a' }), + makeAnswer({ id: 'b' }), + makeAnswer({ id: 'c' }), + ]); + moveChoiceDown('b'); + expect(state.value.answers.map(a => a.id)).toEqual(['a', 'c', 'b']); + }); + + it('is a no-op when the answer is last', () => { + const { state, moveChoiceDown } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + moveChoiceDown('b'); + expect(state.value.answers.map(a => a.id)).toEqual(['a', 'b']); + }); + }); + + describe('toggleCorrectChoice()', () => { + it('singleSelect: sets only the target as correct and clears others', () => { + const { state, toggleCorrectChoice, qt } = setup([ + makeAnswer({ id: 'a', correct: true }), + makeAnswer({ id: 'b', correct: false }), + ]); + qt.value = QuestionType.SINGLE_SELECT; + toggleCorrectChoice('b'); + expect(state.value.answers.find(a => a.id === 'b').correct).toBe(true); + expect(state.value.answers.find(a => a.id === 'a').correct).toBe(false); + }); + + it('multiSelect: toggles only the target, leaves others unchanged', () => { + const { state, toggleCorrectChoice, qt } = setup( + [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })], + QuestionType.MULTI_SELECT, + ); + qt.value = QuestionType.MULTI_SELECT; + toggleCorrectChoice('b'); + expect(state.value.answers.find(a => a.id === 'b').correct).toBe(true); + expect(state.value.answers.find(a => a.id === 'a').correct).toBe(true); + }); + + it('multiSelect: toggles correct off when already correct', () => { + const { state, toggleCorrectChoice, qt } = setup( + [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })], + QuestionType.MULTI_SELECT, + ); + qt.value = QuestionType.MULTI_SELECT; + toggleCorrectChoice('a'); + expect(state.value.answers.find(a => a.id === 'a').correct).toBe(false); + expect(state.value.answers.find(a => a.id === 'b').correct).toBe(true); + }); + }); + + describe('setPrompt()', () => { + it('updates the prompt field', () => { + const { state, setPrompt } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + setPrompt('

New prompt

'); + expect(state.value.prompt).toBe('

New prompt

'); + }); + }); + + describe('setChoiceContent()', () => { + it('updates content for the target answer only', () => { + const { state, setChoiceContent } = setup([ + makeAnswer({ id: 'a', content: 'Old' }), + makeAnswer({ id: 'b', content: 'Unchanged' }), + ]); + setChoiceContent('a', 'New content'); + expect(state.value.answers.find(a => a.id === 'a').content).toBe('New content'); + expect(state.value.answers.find(a => a.id === 'b').content).toBe('Unchanged'); + }); + }); + + describe('setShuffle()', () => { + it('updates the shuffle flag', () => { + const { state, setShuffle } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + setShuffle(true); + expect(state.value.shuffle).toBe(true); + }); + }); + + describe('setOrientation()', () => { + it('updates the orientation field', () => { + const { state, setOrientation } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + setOrientation('horizontal'); + expect(state.value.orientation).toBe('horizontal'); + }); + }); + + describe('setMaxChoices()', () => { + it('updates the maxChoices field', () => { + const { state, setMaxChoices } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]); + setMaxChoices(3); + expect(state.value.maxChoices).toBe(3); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js new file mode 100644 index 0000000000..8f365dbb88 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js @@ -0,0 +1,118 @@ +import { QuestionType } from '../constants'; +import { generateRandomSlug } from '../utils/generateRandomSlug'; +import { choiceInteractionDescriptor } from '../interactions/choice/ChoiceInteractionDescriptor'; +import { useInteraction } from './useInteraction'; + +/** + * Composable for the choice interaction editor. + * + * Extends useInteraction with all mutation methods needed by ChoiceEditor.vue. + * State mutations always produce a new array reference so Vue's computed + * dependencies invalidate correctly. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + */ +export function useChoiceInteraction(interactionBlock, questionType) { + // Use the import-safe descriptor core — it owns parse/buildXML/validate schema + // without depending on choice/index.js or ChoiceInteractionEditor.vue. + const base = useInteraction(choiceInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + // --------------------------------------------------------------------------- + // Structural mutations + // --------------------------------------------------------------------------- + + function addChoice() { + state.value = { + ...state.value, + answers: [ + ...state.value.answers, + { id: generateRandomSlug('choice'), content: '', correct: false, fixed: false }, + ], + }; + } + + function removeChoice(id) { + if (state.value.answers.length <= 1) return; + state.value = { + ...state.value, + answers: state.value.answers.filter(a => a.id !== id), + }; + } + + function moveChoiceUp(id) { + const answers = [...state.value.answers]; + const idx = answers.findIndex(a => a.id === id); + if (idx <= 0) return; + [answers[idx - 1], answers[idx]] = [answers[idx], answers[idx - 1]]; + state.value = { ...state.value, answers }; + } + + function moveChoiceDown(id) { + const answers = [...state.value.answers]; + const idx = answers.findIndex(a => a.id === id); + if (idx === -1 || idx >= answers.length - 1) return; + [answers[idx], answers[idx + 1]] = [answers[idx + 1], answers[idx]]; + state.value = { ...state.value, answers }; + } + + /** + * Toggle the correct flag for a single choice. + * + * singleSelect: clears all others and sets only the target to correct. + * multiSelect: toggles only the target answer's correct field. + */ + function toggleCorrectChoice(id) { + state.value = { + ...state.value, + answers: state.value.answers.map(a => { + if (questionType.value === QuestionType.SINGLE_SELECT) { + return { ...a, correct: a.id === id }; + } + return a.id === id ? { ...a, correct: !a.correct } : a; + }), + }; + } + + // --------------------------------------------------------------------------- + // Field mutations + // --------------------------------------------------------------------------- + + function setPrompt(html) { + state.value = { ...state.value, prompt: html }; + } + + function setChoiceContent(id, html) { + state.value = { + ...state.value, + answers: state.value.answers.map(a => (a.id === id ? { ...a, content: html } : a)), + }; + } + + function setShuffle(val) { + state.value = { ...state.value, shuffle: val }; + } + + function setOrientation(val) { + state.value = { ...state.value, orientation: val }; + } + + function setMaxChoices(n) { + state.value = { ...state.value, maxChoices: n }; + } + + return { + ...base, + addChoice, + removeChoice, + moveChoiceUp, + moveChoiceDown, + toggleCorrectChoice, + setPrompt, + setChoiceContent, + setShuffle, + setOrientation, + setMaxChoices, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js new file mode 100644 index 0000000000..573b70d458 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js @@ -0,0 +1,241 @@ +import { + QtiInteraction, + QuestionType, + BaseType, + Cardinality, + ValidationError, +} from '../../constants'; +import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; +import { parseXML, getPromptHTML } from '../../serialization/parseItem'; +import { buildXmlNode } from '../../serialization/assembleItem'; +import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; +import { generateRandomSlug } from '../../utils/generateRandomSlug'; + +/** + * Owns all choice-specific interaction logic: schema, parse, buildXML, and validate. + */ +export class ChoiceInteractionDescriptor { + constructor({ editorComponent = null } = {}) { + this.type = QtiInteraction.CHOICE; + this.placement = 'block'; + this.questionTypes = [QuestionType.SINGLE_SELECT, QuestionType.MULTI_SELECT]; + this.editorComponent = editorComponent; + this.convertsFrom = []; + } + + /** @param {Element} el */ + matches(el) { + return el.tagName.toLowerCase() === QtiInteraction.CHOICE; + } + + /** + * Reads max-choices: '1' → singleSelect, anything else → multiSelect. + * @param {Element} el + * @returns {string} + */ + getQuestionType(el) { + return el.getAttribute('max-choices') === '1' + ? QuestionType.SINGLE_SELECT + : QuestionType.MULTI_SELECT; + } + + /** + * @param {string} questionType + * @returns {{ baseType: string, cardinality: string }} + */ + getDeclarationSchema(questionType) { + return { + baseType: BaseType.IDENTIFIER, + cardinality: + questionType === QuestionType.SINGLE_SELECT ? Cardinality.SINGLE : Cardinality.MULTIPLE, + }; + } + + /** + * Convenience: create a blank response declaration using this descriptor's schema. + * @param {string} questionType + * @param {string} [identifier] + * @returns {QTIDeclaration} + */ + createDeclaration(questionType, identifier = 'RESPONSE') { + return QTIDeclaration.fromInteractionDescriptor(this, questionType, identifier); + } + + /** + * Parse body XML + response declarations → ChoiceState. + * + * @param {string} bodyXml + * @param {string[]} responseDeclarations + * @returns {object} ChoiceState + */ + parse(bodyXml, responseDeclarations) { + if (!bodyXml) return _defaultState(); + + let root; + try { + root = parseXML(bodyXml).documentElement; + } catch { + return _defaultState(); + } + + const maxChoices = parseInt(root.getAttribute('max-choices') ?? '0', 10); + const minChoices = parseInt(root.getAttribute('min-choices') ?? '0', 10); + const shuffle = root.getAttribute('shuffle') === 'true'; + const orientation = root.getAttribute('orientation') ?? 'vertical'; + const prompt = getPromptHTML(root); + + const correctIds = _extractCorrectIds(responseDeclarations); + + const answers = [...root.querySelectorAll('qti-simple-choice')].map(el => ({ + id: el.getAttribute('identifier') || generateRandomSlug('choice'), + content: el.innerHTML, + correct: correctIds.has(el.getAttribute('identifier') ?? ''), + fixed: el.getAttribute('fixed') === 'true', + })); + + return { prompt, answers, maxChoices, minChoices, shuffle, orientation }; + } + + /** + * Serialize ChoiceState → { bodyXml, declarations }. + * + * @param {object} state - ChoiceState + * @param {string} questionType + * @returns {{ bodyXml: string, declarations: string[] }} + */ + buildXML(state, questionType) { + const { prompt, answers, maxChoices, minChoices, shuffle, orientation } = state; + + const attrs = { + 'response-identifier': RESPONSE_IDENTIFIER, + 'max-choices': maxChoices, + shuffle: String(shuffle), + orientation, + }; + if (minChoices > 0) attrs['min-choices'] = minChoices; + + const children = []; + + if (prompt) { + children.push(buildXmlNode({ tag: 'qti-prompt', children: _parseHtmlFragment(prompt) })); + } + + for (const answer of answers) { + const choiceAttrs = { identifier: answer.id }; + if (answer.fixed) choiceAttrs.fixed = 'true'; + children.push( + buildXmlNode({ + tag: 'qti-simple-choice', + attrs: choiceAttrs, + children: _parseHtmlFragment(answer.content), + }), + ); + } + + const interactionEl = buildXmlNode({ tag: 'qti-choice-interaction', attrs, children }); + const bodyXml = serializer.serializeToString(interactionEl); + + const { cardinality } = this.getDeclarationSchema(questionType); + const declaration = new QTIDeclaration({ + identifier: RESPONSE_IDENTIFIER, + baseType: BaseType.IDENTIFIER, + cardinality, + tag: 'qti-response-declaration', + }); + const correctIds = answers.filter(a => a.correct).map(a => a.id); + new CorrectResponse(correctIds, declaration); + + const declarationXml = serializer.serializeToString(declaration.getXML()); + return { bodyXml, declarations: [declarationXml] }; + } + + /** + * Validate ChoiceState → ValidationError[]. + * + * @param {object} state - ChoiceState + * @param {string} questionType + * @returns {Array<{ code: string, id?: string }>} + */ + validate(state, questionType) { + const errors = []; + const { prompt, answers } = state; + + if (!_stripTags(prompt).trim()) { + errors.push({ code: ValidationError.PROMPT_REQUIRED }); + } + + if (answers.length < 2) { + errors.push({ code: ValidationError.TOO_FEW_CHOICES }); + } + + for (const answer of answers) { + if (!_stripTags(answer.content).trim()) { + errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id: answer.id }); + } + } + + const correctCount = answers.filter(a => a.correct).length; + if (correctCount === 0) { + errors.push({ code: ValidationError.NO_CORRECT_ANSWER }); + } else if (questionType === QuestionType.SINGLE_SELECT && correctCount > 1) { + errors.push({ code: ValidationError.TOO_MANY_CORRECT_ANSWERS }); + } + + return errors; + } +} + +/** Singleton — safe to import from any file in the choice module tree. */ +export const choiceInteractionDescriptor = new ChoiceInteractionDescriptor(); + +// --------------------------------------------------------------------------- +// Module-level constants and private helpers +// --------------------------------------------------------------------------- + +const serializer = new XMLSerializer(); +const RESPONSE_IDENTIFIER = 'RESPONSE'; + +function _defaultState() { + return { + prompt: '', + answers: [], + maxChoices: 1, + minChoices: 0, + shuffle: false, + orientation: 'vertical', + }; +} + +function _extractCorrectIds(declarations) { + const ids = new Set(); + for (const declXml of declarations) { + let declEl; + try { + declEl = parseXML(declXml).documentElement; + } catch { + continue; + } + const declaration = QTIDeclaration.fromXML(declEl); + const correct = declaration.correctResponse; + if (correct) { + for (const id of correct) ids.add(id); + break; + } + } + return ids; +} + +function _parseHtmlFragment(html) { + if (!html) return []; + if (!html.includes('<')) return [html]; + try { + const fragment = parseXML(`${html}`); + return [...fragment.documentElement.childNodes]; + } catch { + return [html]; + } +} + +function _stripTags(html) { + return (html ?? '').replace(/<[^>]*>/g, ''); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js new file mode 100644 index 0000000000..18aeffc152 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionDescriptor.spec.js @@ -0,0 +1,37 @@ +import { ChoiceInteractionDescriptor } from '../ChoiceInteractionDescriptor'; +import { BaseType, Cardinality, QtiInteraction, QuestionType } from '../../../constants'; + +describe('ChoiceInteractionDescriptor', () => { + it('owns the choice interaction metadata without requiring the Vue editor', () => { + const descriptor = new ChoiceInteractionDescriptor(); + expect(descriptor.type).toBe(QtiInteraction.CHOICE); + expect(descriptor.questionTypes).toEqual([ + QuestionType.SINGLE_SELECT, + QuestionType.MULTI_SELECT, + ]); + }); + + it('derives singleSelect from max-choices="1"', () => { + const descriptor = new ChoiceInteractionDescriptor(); + const el = new DOMParser().parseFromString( + '', + 'text/xml', + ).documentElement; + expect(descriptor.getQuestionType(el)).toBe(QuestionType.SINGLE_SELECT); + }); + + it('creates single-cardinality declarations for singleSelect', () => { + const descriptor = new ChoiceInteractionDescriptor(); + const declaration = descriptor.createDeclaration(QuestionType.SINGLE_SELECT, 'RESPONSE'); + expect(declaration.identifier).toBe('RESPONSE'); + expect(declaration.baseType).toBe(BaseType.IDENTIFIER); + expect(declaration.cardinality).toBe(Cardinality.SINGLE); + }); + + it('creates multiple-cardinality declarations for multiSelect', () => { + const descriptor = new ChoiceInteractionDescriptor(); + const declaration = descriptor.createDeclaration(QuestionType.MULTI_SELECT, 'RESPONSE'); + expect(declaration.baseType).toBe(BaseType.IDENTIFIER); + expect(declaration.cardinality).toBe(Cardinality.MULTIPLE); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js new file mode 100644 index 0000000000..8537c681c4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js @@ -0,0 +1,261 @@ +/* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */ +// The eslint-dom matchers reject XML nodes produced by DOMParser(..., 'text/xml'). +// Native DOM APIs (getAttribute, textContent) work correctly on XML elements. + +import { choiceInteractionDescriptor } from '../ChoiceInteractionDescriptor'; + +import { + CHOICE_SINGLE_SELECT_XML, + CHOICE_MULTI_SELECT_XML, + CHOICE_NO_PROMPT_XML, + CHOICE_SINGLE_DECL_XML as SINGLE_DECL, + CHOICE_MULTI_DECL_XML as MULTI_DECL, +} from '../../../utils/testingFixtures'; +import { QuestionType } from '../../../constants'; + +const parse = choiceInteractionDescriptor.parse.bind(choiceInteractionDescriptor); +const buildXML = choiceInteractionDescriptor.buildXML.bind(choiceInteractionDescriptor); + +describe('parse()', () => { + describe('attribute defaults', () => { + it('defaults maxChoices to 0 when attribute is absent', () => { + const xml = ` + A + `; + const state = parse(xml, []); + expect(state.maxChoices).toBe(0); + }); + + it('defaults minChoices to 0 when attribute is absent', () => { + const state = parse(CHOICE_SINGLE_SELECT_XML, []); + expect(state.minChoices).toBe(0); + }); + + it('defaults shuffle to false when attribute is absent', () => { + const state = parse(CHOICE_SINGLE_SELECT_XML, []); + expect(state.shuffle).toBe(false); + }); + + it('defaults orientation to "vertical" when attribute is absent', () => { + const state = parse(CHOICE_SINGLE_SELECT_XML, []); + expect(state.orientation).toBe('vertical'); + }); + + it('defaults prompt to empty string when is absent', () => { + const state = parse(CHOICE_NO_PROMPT_XML, []); + expect(state.prompt).toBe(''); + }); + }); + + describe('attribute reading', () => { + it('reads max-choices attribute', () => { + const state = parse(CHOICE_SINGLE_SELECT_XML, []); + expect(state.maxChoices).toBe(1); + }); + + it('reads shuffle attribute when true', () => { + const xml = ` + A + `; + expect(parse(xml, []).shuffle).toBe(true); + }); + + it('reads orientation attribute', () => { + const xml = ` + A + `; + expect(parse(xml, []).orientation).toBe('horizontal'); + }); + }); + + describe('prompt extraction', () => { + it('extracts the prompt text content', () => { + const state = parse(CHOICE_SINGLE_SELECT_XML, []); + expect(state.prompt).toContain('Which planet is closest to the Sun?'); + }); + }); + + describe('answers array', () => { + it('maps each to an answer with id and content', () => { + const state = parse(CHOICE_SINGLE_SELECT_XML, []); + expect(state.answers).toHaveLength(3); + expect(state.answers[0].id).toBe('mercury'); + expect(state.answers[0].content).toContain('Mercury'); + }); + + it('generates a slug identifier when element is missing its identifier attribute', () => { + const xml = ` + No id here + `; + const state = parse(xml, []); + expect(state.answers[0].id).toMatch(/^choice_[a-z0-9]{8}$/); + }); + + it('sets fixed: true when the fixed attribute is present', () => { + const xml = ` + A + `; + const state = parse(xml, []); + expect(state.answers[0].fixed).toBe(true); + }); + + it('sets fixed: false when the fixed attribute is absent', () => { + const state = parse(CHOICE_SINGLE_SELECT_XML, []); + expect(state.answers[0].fixed).toBe(false); + }); + }); + + describe('correct response detection', () => { + it('marks the correct answer for single-select', () => { + const state = parse(CHOICE_SINGLE_SELECT_XML, [SINGLE_DECL]); + const mercury = state.answers.find(a => a.id === 'mercury'); + const venus = state.answers.find(a => a.id === 'venus'); + expect(mercury.correct).toBe(true); + expect(venus.correct).toBe(false); + }); + + it('marks multiple correct answers for multi-select', () => { + const state = parse(CHOICE_MULTI_SELECT_XML, [MULTI_DECL]); + expect(state.answers.find(a => a.id === 'a').correct).toBe(true); + expect(state.answers.find(a => a.id === 'b').correct).toBe(false); + expect(state.answers.find(a => a.id === 'c').correct).toBe(true); + }); + + it('marks all answers as not correct when no declaration provided', () => { + const state = parse(CHOICE_SINGLE_SELECT_XML, []); + expect(state.answers.every(a => !a.correct)).toBe(true); + }); + }); + + describe('graceful fallback', () => { + it('returns default state for empty bodyXml', () => { + const state = parse('', []); + expect(state.answers).toEqual([]); + expect(state.prompt).toBe(''); + }); + + it('returns default state for malformed XML', () => { + const state = parse(' { + const baseState = { + prompt: 'Pick one.', + answers: [ + { id: 'choice_a', content: 'Option A', correct: true, fixed: false }, + { id: 'choice_b', content: 'Option B', correct: false, fixed: false }, + ], + maxChoices: 1, + minChoices: 0, + shuffle: false, + orientation: 'vertical', + }; + + it('sets cardinality="single" for singleSelect', () => { + const { declarations } = buildXML(baseState, QuestionType.SINGLE_SELECT); + expect(declarations[0]).toContain('cardinality="single"'); + }); + + it('sets cardinality="multiple" for multiSelect', () => { + const multiState = { + ...baseState, + answers: [ + { id: 'a', content: 'A', correct: true, fixed: false }, + { id: 'b', content: 'B', correct: true, fixed: false }, + ], + maxChoices: 2, + }; + const { declarations } = buildXML(multiState, QuestionType.MULTI_SELECT); + expect(declarations[0]).toContain('cardinality="multiple"'); + }); + + it('includes the correct identifier in ', () => { + const { declarations } = buildXML(baseState, QuestionType.SINGLE_SELECT); + expect(declarations[0]).toContain('choice_a'); + expect(declarations[0]).not.toContain('choice_b'); + }); + + it('includes all correct identifiers for multi-select', () => { + const multiState = { + ...baseState, + answers: [ + { id: 'x', content: 'X', correct: true, fixed: false }, + { id: 'y', content: 'Y', correct: true, fixed: false }, + { id: 'z', content: 'Z', correct: false, fixed: false }, + ], + maxChoices: 2, + }; + const { declarations } = buildXML(multiState, QuestionType.MULTI_SELECT); + expect(declarations[0]).toContain('x'); + expect(declarations[0]).toContain('y'); + expect(declarations[0]).not.toContain('z'); + }); + + it('omits min-choices attribute when minChoices is 0', () => { + const { bodyXml } = buildXML(baseState, QuestionType.SINGLE_SELECT); + expect(bodyXml).not.toContain('min-choices'); + }); + + it('includes min-choices attribute when minChoices > 0', () => { + const { bodyXml } = buildXML({ ...baseState, minChoices: 1 }, QuestionType.SINGLE_SELECT); + expect(bodyXml).toContain('min-choices'); + }); + + it('includes one per answer', () => { + const { bodyXml } = buildXML(baseState, QuestionType.SINGLE_SELECT); + const matches = bodyXml.match(/qti-simple-choice/g) ?? []; + // Each tag appears as open + close = 2 × 2 answers = 4 + expect(matches.length).toBe(4); + }); + + it('preserves rich markup as XML nodes instead of escaped text', () => { + const { bodyXml } = buildXML( + { + ...baseState, + prompt: '

Pick one.

', + answers: [ + { + id: 'choice_a', + content: '

Option A

', + correct: true, + fixed: false, + }, + { id: 'choice_b', content: 'Option B', correct: false, fixed: false }, + ], + }, + QuestionType.SINGLE_SELECT, + ); + + expect(bodyXml).toContain('one'); + expect(bodyXml).toContain('A'); + expect(bodyXml).not.toContain('<strong'); + expect(bodyXml).not.toContain('<em'); + }); +}); + +describe('parse → buildXML → parse round-trip', () => { + it('single-select: re-parsed state matches original', () => { + const original = parse(CHOICE_SINGLE_SELECT_XML, [SINGLE_DECL]); + const { bodyXml, declarations } = buildXML(original, QuestionType.SINGLE_SELECT); + const reparsed = parse(bodyXml, declarations); + + expect(reparsed.maxChoices).toBe(original.maxChoices); + expect(reparsed.shuffle).toBe(original.shuffle); + expect(reparsed.orientation).toBe(original.orientation); + expect(reparsed.answers.map(a => a.id)).toEqual(original.answers.map(a => a.id)); + expect(reparsed.answers.map(a => a.correct)).toEqual(original.answers.map(a => a.correct)); + }); + + it('multi-select: re-parsed state matches original', () => { + const original = parse(CHOICE_MULTI_SELECT_XML, [MULTI_DECL]); + const { bodyXml, declarations } = buildXML(original, QuestionType.MULTI_SELECT); + const reparsed = parse(bodyXml, declarations); + + expect(reparsed.answers.filter(a => a.correct).map(a => a.id)).toEqual( + original.answers.filter(a => a.correct).map(a => a.id), + ); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js new file mode 100644 index 0000000000..9364eeace0 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validate.spec.js @@ -0,0 +1,148 @@ +import { choiceInteractionDescriptor } from '../ChoiceInteractionDescriptor'; + +import { ValidationError, QuestionType } from '../../../constants'; + +const validate = choiceInteractionDescriptor.validate.bind(choiceInteractionDescriptor); + +// Helpers + +function makeAnswer(overrides = {}) { + return { id: 'choice_a', content: 'Option A', correct: false, fixed: false, ...overrides }; +} + +function makeState(overrides = {}) { + return { + prompt: 'What is 2 + 2?', + answers: [ + makeAnswer({ id: 'choice_a', content: 'Four', correct: true }), + makeAnswer({ id: 'choice_b', content: 'Five', correct: false }), + ], + maxChoices: 1, + minChoices: 0, + shuffle: false, + orientation: 'vertical', + ...overrides, + }; +} + +const errorCodes = errors => errors.map(e => e.code); + +describe('validate()', () => { + it('returns an empty array for a valid single-select state', () => { + expect(validate(makeState(), QuestionType.SINGLE_SELECT)).toEqual([]); + }); + + it('returns an empty array for a valid multi-select state', () => { + const state = makeState({ + answers: [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })], + }); + expect(validate(state, QuestionType.MULTI_SELECT)).toEqual([]); + }); + + describe('PROMPT_REQUIRED', () => { + it('returns error when prompt is an empty string', () => { + expect(errorCodes(validate(makeState({ prompt: '' }), QuestionType.SINGLE_SELECT))).toContain( + ValidationError.PROMPT_REQUIRED, + ); + }); + + it('returns error when prompt is whitespace only', () => { + expect( + errorCodes(validate(makeState({ prompt: ' ' }), QuestionType.SINGLE_SELECT)), + ).toContain(ValidationError.PROMPT_REQUIRED); + }); + + it('returns error when prompt is tags-only with no visible text', () => { + expect( + errorCodes(validate(makeState({ prompt: '

' }), QuestionType.SINGLE_SELECT)), + ).toContain(ValidationError.PROMPT_REQUIRED); + }); + + it('does not return error when prompt has visible text inside tags', () => { + expect( + errorCodes(validate(makeState({ prompt: '

Hello

' }), QuestionType.SINGLE_SELECT)), + ).not.toContain(ValidationError.PROMPT_REQUIRED); + }); + }); + + describe('TOO_FEW_CHOICES', () => { + it('returns error when there is only one answer', () => { + const state = makeState({ answers: [makeAnswer({ correct: true })] }); + expect(errorCodes(validate(state, QuestionType.SINGLE_SELECT))).toContain( + ValidationError.TOO_FEW_CHOICES, + ); + }); + + it('does not return error when there are two or more answers', () => { + expect(errorCodes(validate(makeState(), QuestionType.SINGLE_SELECT))).not.toContain( + ValidationError.TOO_FEW_CHOICES, + ); + }); + }); + + describe('EMPTY_CHOICE_CONTENT', () => { + it('returns an error for each answer with empty content', () => { + const state = makeState({ + answers: [ + makeAnswer({ id: 'a', content: '', correct: true }), + makeAnswer({ id: 'b', content: ' ', correct: false }), + ], + }); + const errors = validate(state, QuestionType.SINGLE_SELECT); + const contentErrors = errors.filter(e => e.code === ValidationError.EMPTY_CHOICE_CONTENT); + expect(contentErrors).toHaveLength(2); + expect(contentErrors.map(e => e.id)).toEqual(['a', 'b']); + }); + + it('does not flag answers with content wrapped in HTML tags', () => { + const state = makeState({ + answers: [ + makeAnswer({ id: 'a', content: 'Yes', correct: true }), + makeAnswer({ id: 'b', content: 'No', correct: false }), + ], + }); + const errors = validate(state, QuestionType.SINGLE_SELECT); + expect(errorCodes(errors)).not.toContain(ValidationError.EMPTY_CHOICE_CONTENT); + }); + }); + + describe('NO_CORRECT_ANSWER', () => { + it('returns error for singleSelect when no answer is correct', () => { + const state = makeState({ + answers: [makeAnswer({ id: 'a', correct: false }), makeAnswer({ id: 'b', correct: false })], + }); + expect(errorCodes(validate(state, QuestionType.SINGLE_SELECT))).toContain( + ValidationError.NO_CORRECT_ANSWER, + ); + }); + + it('returns error for multiSelect when no answer is correct', () => { + const state = makeState({ + answers: [makeAnswer({ id: 'a', correct: false }), makeAnswer({ id: 'b', correct: false })], + }); + expect(errorCodes(validate(state, QuestionType.MULTI_SELECT))).toContain( + ValidationError.NO_CORRECT_ANSWER, + ); + }); + }); + + describe('TOO_MANY_CORRECT_ANSWERS', () => { + it('returns error for singleSelect when more than one answer is correct', () => { + const state = makeState({ + answers: [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })], + }); + expect(errorCodes(validate(state, QuestionType.SINGLE_SELECT))).toContain( + ValidationError.TOO_MANY_CORRECT_ANSWERS, + ); + }); + + it('does not return error for multiSelect when more than one answer is correct', () => { + const state = makeState({ + answers: [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })], + }); + expect(errorCodes(validate(state, QuestionType.MULTI_SELECT))).not.toContain( + ValidationError.TOO_MANY_CORRECT_ANSWERS, + ); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js index ec11abe297..af1f774d50 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js @@ -1,83 +1,25 @@ import defineInteraction from '../defineInteraction'; -import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; import ChoiceInteractionEditor from './ChoiceInteractionEditor.vue'; +import { ChoiceInteractionDescriptor } from './ChoiceInteractionDescriptor'; /** - * Choice interaction plugin — handles both single-select (max-choices="1") - * and multi-select (max-choices > 1) via the same element. + * @typedef {object} ChoiceAnswer + * @property {string} id - QTI identifier, e.g. "choice_xlqTuVoq" + * @property {string} content - HTML content of the + * @property {boolean} correct - Whether this choice is in the correct response + * @property {boolean} fixed - Whether this choice is fixed (round-trip only) */ -export default defineInteraction({ - /** Registry key — matches the QTI 3.0 interaction element tag name. */ - type: QtiInteraction.CHOICE, - /** Block-level interaction: occupies its own paragraph in the item body. */ - placement: 'block', - - /** - * All QuestionType values this descriptor can render. - * Allows the registry to resolve a descriptor from a selected question type - * without re-parsing XML. - */ - questionTypes: [QuestionType.SINGLE_SELECT, QuestionType.MULTI_SELECT], - - /** Vue component rendered inside InteractionSection when this descriptor owns the block. */ - editorComponent: ChoiceInteractionEditor, - - /** - * Types this plugin can absorb when the author switches interaction type. - */ - convertsFrom: [], - - /** - * Returns true when this descriptor owns the given interaction element. - * @param {Element} el - */ - matches(el) { - return el.tagName.toLowerCase() === QtiInteraction.CHOICE; - }, - - /** - * Derives the UI-facing question type from the interaction element. - * Reads max-choices: '1' → singleSelect, anything else → multiSelect. - * @param {Element} el - * @returns {string} One of QuestionType - */ - getQuestionType(el) { - return el.getAttribute('max-choices') === '1' - ? QuestionType.SINGLE_SELECT - : QuestionType.MULTI_SELECT; - }, - - /** - * Defines the structural schema for the response declaration of this interaction. - * Returns baseType and cardinality, which can depend on the selected questionType. - * - * @param {string} questionType - * @returns {{ baseType: string, cardinality: string }} - */ - getDeclarationSchema(questionType) { - return { - baseType: BaseType.IDENTIFIER, - cardinality: - questionType === QuestionType.SINGLE_SELECT ? Cardinality.SINGLE : Cardinality.MULTIPLE, - }; - }, +/** + * @typedef {object} ChoiceState + * @property {string} prompt - HTML content of ; default "" + * @property {ChoiceAnswer[]} answers + * @property {number} maxChoices - From max-choices attribute (0 = unlimited) + * @property {number} minChoices - From min-choices attribute; default 0 + * @property {boolean} shuffle - From shuffle attribute; default false + * @property {string} orientation - From orientation attribute; default "vertical" + */ - /** - * Extracts a structured state object from the interaction element. - * Stub — full parsing is a future task. - * @returns {object} - */ - parse() { - return {}; - }, +const descriptor = new ChoiceInteractionDescriptor({ editorComponent: ChoiceInteractionEditor }); - /** - * Validates the interaction state and returns an array of error strings. - * Stub — full validation is a future task. - * @returns {string[]} - */ - validate() { - return []; - }, -}); +export default defineInteraction(descriptor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index 7323d4366b..f32d992478 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -1,6 +1,4 @@ -// --------------------------------------------------------------------------- // Centralized QTI Mock XML Fixtures for Unit Tests -// --------------------------------------------------------------------------- export const CHOICE_SINGLE_SELECT_XML = ` Which planet is closest to the Sun? @@ -24,9 +22,18 @@ export const UNKNOWN_INTERACTION_XML = `Unknown. `; -// --------------------------------------------------------------------------- +export const CHOICE_SINGLE_DECL_XML = ` + mercury +`; + +export const CHOICE_MULTI_DECL_XML = ` + + a + c + +`; + // Full QTI Assessment Item XML Documents -// --------------------------------------------------------------------------- export const VALID_CHOICE_ITEM_DOCUMENT = ` ({ bodyXml, responseDeclarations: [], }); + +/** + * Wraps a snippet of interaction XML and a declaration into a mock 'block' object + * @param {string} bodyXml + * @param {string} declarationXml + * @returns {object} + */ +export const mockInteractionBlockWithDecl = (bodyXml, declarationXml) => ({ + bodyXml, + responseDeclarations: [declarationXml], +}); From 38815c09424bee543f8a9cc389f30460839b03e7 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Wed, 1 Jul 2026 02:52:29 +0530 Subject: [PATCH 4/5] feat(qti): implement ChoiceInteractionEditor UI and Editor updates --- .../__tests__/InteractionSection.spec.js | 2 + .../components/InteractionSection/index.vue | 4 +- .../components/QTIItemEditor/index.vue | 43 +- .../frontend/shared/views/QTIEditor/index.vue | 13 + .../choice/ChoiceInteractionEditor.vue | 729 +++++++++++++++--- .../__tests__/ChoiceInteractionEditor.spec.js | 289 ++++++- .../views/QTIEditor/useQTIEditorActions.js | 4 +- 7 files changed, 968 insertions(+), 116 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js index 9261242d32..c5f008646e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js @@ -9,6 +9,8 @@ import { mockInteractionBlock as interactionBlock, } from '../../../utils/testingFixtures'; +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); + const renderSection = (props = {}) => render(InteractionSection, { props: { mode: 'edit', ...props }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue index ee9137e245..4bf3efb30f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue @@ -15,6 +15,8 @@ :interaction="interaction" :mode="mode" :showAnswers="showAnswers" + @update:bodyXml="xml => $emit('update:bodyXml', xml)" + @update:responseDeclarations="decls => $emit('update:responseDeclarations', decls)" /> @@ -67,7 +69,7 @@ }, }, - emits: ['update:questionType'], + emits: ['update:questionType', 'update:bodyXml', 'update:responseDeclarations'], }; 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 e21b5139f1..27f4748601 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -33,6 +33,8 @@ :mode="mode" :showAnswers="showAnswers" @update:questionType="type => (currentQuestionType = type)" + @update:bodyXml="onBodyXmlUpdate" + @update:responseDeclarations="onResponseDeclarationsUpdate" />

questionNumberLabel$({ @@ -116,6 +119,38 @@ }), ); + /** + * Track the current bodyXml and responseDeclarations for the interaction. + * Initialized from the parsed item; updated when the editor emits changes. + */ + const currentBodyXml = ref( + interactions.value.length > 0 ? interactions.value[0].bodyXml : '', + ); + const currentResponseDeclarations = ref( + interactions.value.length > 0 ? interactions.value[0].responseDeclarations : [], + ); + + function onBodyXmlUpdate(xml) { + currentBodyXml.value = xml; + emitRawData(); + } + + function onResponseDeclarationsUpdate(decls) { + currentResponseDeclarations.value = decls; + emitRawData(); + } + + function emitRawData() { + const newRawData = reassembleItemXml({ + identifier: identifier.value, + title: title.value, + language: language.value, + bodyXml: currentBodyXml.value, + responseDeclarations: currentResponseDeclarations.value, + }); + emit('update:rawData', newRawData); + } + return { currentQuestionType, interactions, @@ -123,6 +158,8 @@ questionNumberAndTypeLabel, closeBtnLabel$, questionContentPlaceholder$, + onBodyXmlUpdate, + onResponseDeclarationsUpdate, }; }, @@ -158,7 +195,7 @@ }, }, - emits: ['close'], + emits: ['close', 'update:rawData'], }; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index 34f4d58f92..cc9b2af339 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -29,6 +29,7 @@ :showAnswers="showAnswers" data-testid="item" @close="closeItem" + @update:rawData="newXml => updateItemRawData(item.assessment_id, newXml)" >