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)"
>
+ item.assessment_id === assessmentId ? { ...item, raw_data: newRawData } : item,
+ );
+ emit('update', list);
+ }
+
const { getToolbarActions } = useQTIEditorActions({
items,
activeId,
@@ -162,6 +174,7 @@
showAnswers,
closeItem,
addItem,
+ updateItemRawData,
getToolbarActions,
noQuestionsPlaceholder$,
newQuestionBtnLabel$,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
index d8d545cc39..4441049e6d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
@@ -1,39 +1,265 @@
-
+
-
- {{ prompt }}
-
-
-
-
-
-
-
-
-
-
-
- toggleValue(choice.identifier, checked)"
+
+
+ {{ questionLabel$() }}
+
+
+
+
+
-
-
+
+ {{ errorPromptRequired$() }}
+
+
+
+
+
+
+
+ {{ errorPromptRequired$() }}
+
+
+
+
+
+
+ {{ errorNoCorrectAnswer$() }}
+
+
+ {{ errorTooManyCorrectAnswers$() }}
+
+
+ {{ errorTooFewChoices$() }}
+
+
+
+
+
+ {{ answersLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setChoiceContent(answer.id, html)"
+ @minimize="closeChoice"
+ />
+
+ {{ errorEmptyChoiceContent$() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ addChoiceBtn$() }}
+
+
+
@@ -41,81 +267,286 @@
@@ -123,18 +554,144 @@
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
index f9169788bf..4db1e81dd7 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
@@ -6,20 +6,43 @@ 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,
mockInteractionBlock as block,
+ mockInteractionBlockWithDecl as blockWithDecl,
} from '../../../utils/testingFixtures';
import { QuestionType } from '../../../constants';
+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) }),
+ };
+});
+jest.mock('shared/views/QTIEditor/components/CollapsibleToolbar/index.vue', () => ({
+ name: 'CollapsibleToolbar',
+ props: ['actions'],
+ template: `
+
+
+
+ `,
+}));
+
const renderEditor = (props = {}) =>
render(ChoiceInteractionEditor, {
props: { mode: 'edit', ...props },
routes: new VueRouter(),
});
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
describe('ChoiceInteractionEditor', () => {
describe('prompt rendering', () => {
it('renders the prompt text from the XML', () => {
@@ -30,7 +53,7 @@ describe('ChoiceInteractionEditor', () => {
expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument();
});
- it('renders no prompt element when the XML has no
', () => {
+ it('renders no prompt when the XML has no ', () => {
renderEditor({
interaction: block(CHOICE_NO_PROMPT_XML),
questionType: QuestionType.SINGLE_SELECT,
@@ -45,8 +68,7 @@ describe('ChoiceInteractionEditor', () => {
interaction: block(CHOICE_SINGLE_SELECT_XML),
questionType: QuestionType.SINGLE_SELECT,
});
- const radios = screen.getAllByRole('radio');
- expect(radios).toHaveLength(3);
+ expect(screen.getAllByRole('radio')).toHaveLength(3);
});
it('renders the correct choice labels', () => {
@@ -59,15 +81,25 @@ describe('ChoiceInteractionEditor', () => {
expect(screen.getByText('Earth')).toBeInTheDocument();
});
- it('allows selecting a radio button', async () => {
+ it('pre-checks the correct answer radio', () => {
+ renderEditor({
+ interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ const radios = screen.getAllByRole('radio');
+ expect(radios[0]).toBeChecked(); // mercury
+ expect(radios[1]).not.toBeChecked(); // venus
+ });
+
+ it('allows toggling a different radio', async () => {
renderEditor({
interaction: block(CHOICE_SINGLE_SELECT_XML),
questionType: QuestionType.SINGLE_SELECT,
});
- const mercury = screen.getByRole('radio', { name: 'Mercury' });
- expect(mercury).not.toBeChecked();
- await fireEvent.click(mercury);
- expect(mercury).toBeChecked();
+ const radios = screen.getAllByRole('radio');
+ await fireEvent.click(radios[1]);
+ expect(radios[0]).not.toBeChecked();
+ expect(radios[1]).toBeChecked();
});
});
@@ -77,8 +109,7 @@ describe('ChoiceInteractionEditor', () => {
interaction: block(CHOICE_MULTI_SELECT_XML),
questionType: QuestionType.MULTI_SELECT,
});
- const checkboxes = screen.getAllByRole('checkbox');
- expect(checkboxes).toHaveLength(3);
+ expect(screen.getAllByRole('checkbox')).toHaveLength(3);
});
it('renders the correct choice labels', () => {
@@ -91,6 +122,17 @@ describe('ChoiceInteractionEditor', () => {
expect(screen.getByText('Option C')).toBeInTheDocument();
});
+ it('pre-checks multiple correct answers', () => {
+ renderEditor({
+ interaction: blockWithDecl(CHOICE_MULTI_SELECT_XML, MULTI_DECL),
+ questionType: QuestionType.MULTI_SELECT,
+ });
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes[0]).toBeChecked(); // a
+ expect(checkboxes[1]).not.toBeChecked(); // b
+ expect(checkboxes[2]).toBeChecked(); // c
+ });
+
it('allows checking multiple checkboxes independently', async () => {
renderEditor({
interaction: block(CHOICE_MULTI_SELECT_XML),
@@ -104,29 +146,228 @@ describe('ChoiceInteractionEditor', () => {
});
it('allows unchecking a checked checkbox', async () => {
- renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: 'multiSelect' });
+ renderEditor({
+ interaction: blockWithDecl(CHOICE_MULTI_SELECT_XML, MULTI_DECL),
+ questionType: QuestionType.MULTI_SELECT,
+ });
const [checkA] = screen.getAllByRole('checkbox');
await fireEvent.click(checkA);
- expect(checkA).toBeChecked();
- await fireEvent.click(checkA);
expect(checkA).not.toBeChecked();
});
});
- describe('graceful fallback', () => {
- it('renders nothing interactive when block is null', () => {
- renderEditor({ block: null, questionType: 'singleSelect' });
+ describe('edit mode controls', () => {
+ it('renders an Add choice button', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(screen.getByRole('button', { name: /add choice/i })).toBeInTheDocument();
+ });
+
+ it('adds a new choice row when Add choice is clicked', async () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ await fireEvent.click(screen.getByRole('button', { name: /add choice/i }));
+ expect(screen.getAllByRole('radio')).toHaveLength(4);
+ });
+
+ it('renders move-up, move-down, and delete buttons for each non-fixed choice', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(screen.getAllByRole('button', { name: /move choice up/i })).toHaveLength(3);
+ expect(screen.getAllByRole('button', { name: /move choice down/i })).toHaveLength(3);
+ expect(screen.getAllByRole('button', { name: /delete choice/i })).toHaveLength(3);
+ });
+
+ it('disables move-up on the first choice', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ const moveUpBtns = screen.getAllByRole('button', { name: /move choice up/i });
+ expect(moveUpBtns[0]).toBeDisabled();
+ expect(moveUpBtns[1]).toBeEnabled();
+ });
+
+ it('disables move-down on the last choice', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ const moveDownBtns = screen.getAllByRole('button', { name: /move choice down/i });
+ expect(moveDownBtns[2]).toBeDisabled();
+ expect(moveDownBtns[1]).toBeEnabled();
+ });
+
+ it('disables delete when only one choice remains', async () => {
+ const xml = `
+ Only
+ `;
+ renderEditor({
+ interaction: block(xml),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(screen.getByRole('button', { name: /delete choice/i })).toBeDisabled();
+ });
+
+ it('removes a choice row when delete is clicked', async () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ const deleteBtns = screen.getAllByRole('button', { name: /delete choice/i });
+ await fireEvent.click(deleteBtns[0]);
+ expect(screen.getAllByRole('radio')).toHaveLength(2);
+ });
+ });
+
+ describe('view mode', () => {
+ it('hides choices when mode=view and showAnswers=false', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ mode: 'view',
+ showAnswers: false,
+ });
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
- expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});
- it('renders nothing interactive when block.bodyXml is an empty string', () => {
- renderEditor({ interaction: block(''), questionType: 'singleSelect' });
+ it('shows choices when mode=view and showAnswers=true', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ mode: 'view',
+ showAnswers: true,
+ });
+ expect(screen.getAllByRole('radio')).toHaveLength(3);
+ });
+
+ it('disables the correct-answer control in view mode', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ mode: 'view',
+ showAnswers: true,
+ });
+ screen.getAllByRole('radio').forEach(r => expect(r).toBeDisabled());
+ });
+
+ it('hides add/move/delete buttons in view mode', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ mode: 'view',
+ showAnswers: true,
+ });
+ expect(screen.queryByRole('button', { name: /add choice/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /delete choice/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('validation', () => {
+ it('does not show errors before any field is touched', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('shows global errors (no correct answer) after a structural mutation', async () => {
+ // Add a choice so we have 2+ choices — then the only error is no correct answer.
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ // Clicking Add choice calls onAddChoice → runValidation.
+ await fireEvent.click(screen.getByRole('button', { name: /add choice/i }));
+ // NO_CORRECT_ANSWER (and potentially others) should be shown after validation runs.
+ expect(screen.getAllByRole('alert').length).toBeGreaterThan(0);
+ });
+
+ it('shows no-correct-answer error after toggling and running validation', async () => {
+ renderEditor({
+ interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ // Uncheck the correct answer
+ const radios = screen.getAllByRole('radio');
+ await fireEvent.click(radios[1]); // picks venus, but that's fine — triggers runValidation
+ // Now uncheck all by toggling to none... instead trigger via add-choice which runs validate
+ await fireEvent.click(screen.getByRole('button', { name: /add choice/i }));
+ // Validate fires; if no correct → error appears
+ });
+ });
+
+ describe('emits', () => {
+ it('emits update:bodyXml on mount with the rebuilt XML', () => {
+ const { emitted } = renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(emitted()['update:bodyXml']).toBeTruthy();
+ });
+
+ it('emits update:responseDeclarations on mount', () => {
+ const { emitted } = renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(emitted()['update:responseDeclarations']).toBeTruthy();
+ });
+
+ it('emits update:bodyXml after adding a choice', async () => {
+ const { emitted } = renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ const before = emitted()['update:bodyXml'].length;
+ await fireEvent.click(screen.getByRole('button', { name: /add choice/i }));
+ expect(emitted()['update:bodyXml'].length).toBeGreaterThan(before);
+ });
+ });
+
+ describe('accessibility', () => {
+ it('all radios have an accessible label', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ screen.getAllByRole('radio').forEach(r => expect(r).toHaveAccessibleName());
+ });
+
+ it('all checkboxes have an accessible label', () => {
+ renderEditor({
+ interaction: block(CHOICE_MULTI_SELECT_XML),
+ questionType: QuestionType.MULTI_SELECT,
+ });
+ screen.getAllByRole('checkbox').forEach(c => expect(c).toHaveAccessibleName());
+ });
+
+ it('icon buttons have accessible labels', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ screen
+ .getAllByRole('button', { name: /move choice up/i })
+ .forEach(b => expect(b).toHaveAccessibleName());
+ });
+ });
+
+ describe('graceful fallback', () => {
+ it('renders nothing interactive when bodyXml is empty', () => {
+ renderEditor({ interaction: block(''), questionType: QuestionType.SINGLE_SELECT });
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
});
it('renders nothing interactive when XML is malformed', () => {
- renderEditor({ interaction: block(' openItem(item.id),
+ handler: () => openItem(item.assessment_id),
collapsed: false,
disabled: isEditMode,
});
From 3f9409b6dbcaf5e532b9ad69b987a4cefd5c2dfa Mon Sep 17 00:00:00 2001
From: Abhishek-Punhani
Date: Wed, 1 Jul 2026 22:09:02 +0530
Subject: [PATCH 5/5] refactor: migrate ChoiceInteractionEditor to Composition
API and implement XML attribute escaping in reassembleItemXml
Signed-off-by: Abhishek-Punhani
---
.../choice/ChoiceInteractionEditor.vue | 53 +++++++++----------
.../QTIEditor/serialization/parseItem.js | 15 ++++--
2 files changed, 38 insertions(+), 30 deletions(-)
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
index 4441049e6d..13aff61928 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
@@ -101,22 +101,23 @@
:key="answer.id"
class="answer-border"
role="listitem"
- :class="{ 'is-clickable': mode === 'edit' && openChoiceId !== answer.id }"
+ :class="[
+ { 'is-clickable': mode === 'edit' && openChoiceId !== answer.id },
+ mode === 'edit' &&
+ openChoiceId !== answer.id &&
+ !(answer.correct && mode === 'view' && showAnswers)
+ ? $computedClass({ ':hover': { backgroundColor: $themeTokens.fineLine } })
+ : '',
+ ]"
:style="{
borderColor:
answer.correct && mode === 'view' && showAnswers
? $themePalette.green.v_500
: $themeTokens.fineLine,
backgroundColor:
- answer.correct && mode === 'view' && showAnswers
- ? $themePalette.green.v_50
- : hoveredId === answer.id
- ? $themeTokens.fineLine
- : null,
+ answer.correct && mode === 'view' && showAnswers ? $themePalette.green.v_50 : null,
}"
@click="onRowClick($event, answer.id)"
- @mouseenter="hoveredId = answer.id"
- @mouseleave="hoveredId = null"
>
({
+ backgroundColor: palette.blue.v_50,
+ border: `1px dashed ${palette.blue.v_200}`,
+ color: `${palette.blue.v_500} !important`,
+ fontSize: '14px',
+ fontWeight: '600',
+ textTransform: 'none',
+ ':hover': {
+ backgroundColor: palette.blue.v_100,
+ },
+ }));
+
// questionType prop is not a Ref — wrap it so useChoiceInteraction can react to changes.
const questionTypeRef = computed(() => props.questionType);
@@ -329,6 +344,7 @@
function closeQuestion() {
isQuestionOpen.value = false;
+ runValidation();
}
function openChoice(id) {
@@ -338,6 +354,7 @@
function closeChoice() {
openChoiceId.value = null;
+ runValidation();
}
watch(
@@ -464,8 +481,6 @@
];
}
- const hoveredId = ref(null);
-
return {
state,
isSingleSelect,
@@ -491,7 +506,6 @@
onRemoveChoice,
moveChoiceUp,
moveChoiceDown,
- hoveredId,
getChoiceRowActions,
addChoiceBtn$,
deleteChoiceBtn$,
@@ -505,6 +519,7 @@
errorTooFewChoices$,
questionLabel$,
toolbarLabelEdit$,
+ buttonAppearanceOverrides,
};
},
@@ -530,22 +545,6 @@
},
},
- computed: {
- buttonAppearanceOverrides() {
- return {
- backgroundColor: this.$themePalette.blue.v_50,
- border: `1px dashed ${this.$themePalette.blue.v_200}`,
- color: `${this.$themePalette.blue.v_500} !important`,
- fontSize: '14px',
- fontWeight: '600',
- textTransform: 'none',
- ':hover': {
- backgroundColor: this.$themePalette.blue.v_100,
- },
- };
- },
- },
-
emits: ['update:bodyXml', 'update:responseDeclarations'],
};
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js
index 96c3151ed9..b30a38d84a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js
@@ -102,11 +102,20 @@ export function parseItem(rawData) {
* @param {string[]} params.responseDeclarations - Array of serialized declaration XML strings
* @returns {string} Full QTI XML string
*/
+function escapeXmlAttr(unsafe) {
+ return String(unsafe)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
export function reassembleItemXml({ identifier, title, language, bodyXml, responseDeclarations }) {
const declarations = (responseDeclarations || []).join('\n ');
- const lang = language || 'en';
- const id = identifier || 'item';
- const t = title || '';
+ const lang = escapeXmlAttr(language || 'en');
+ const id = escapeXmlAttr(identifier || 'item');
+ const t = escapeXmlAttr(title || '');
return [
'',