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 2911bbf60cc6a3cdc7a483cd81e8cd67d996c1c6 Mon Sep 17 00:00:00 2001
From: Abhishek-Punhani
Date: Wed, 1 Jul 2026 02:52:17 +0530
Subject: [PATCH 03/12] 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 a6fd3d8a11eeb76d6e2592784c6558a97ee23946 Mon Sep 17 00:00:00 2001
From: Abhishek-Punhani
Date: Wed, 1 Jul 2026 02:52:29 +0530
Subject: [PATCH 04/12] 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 71474f1594387b47291f3e4200eb60880bc21590 Mon Sep 17 00:00:00 2001
From: Abhishek-Punhani
Date: Wed, 1 Jul 2026 22:09:02 +0530
Subject: [PATCH 05/12] 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 [
'',
From 6ce25e011450ebc6a8586c378fea65af5fc4150a Mon Sep 17 00:00:00 2001
From: Abhishek-Punhani
Date: Mon, 6 Jul 2026 00:10:13 +0530
Subject: [PATCH 06/12] refactor: optimize QTI item assembly with XML attribute
escaping, introduce improved state validation, and modernize component
reactive data handling
Signed-off-by: Abhishek-Punhani
---
.../components/QTIItemEditor/index.vue | 34 +-
.../components/ValidationMessage/index.vue | 2 +-
.../composables/useChoiceInteraction.js | 2 +
.../shared/views/QTIEditor/constants.js | 1 +
.../choice/ChoiceInteractionDescriptor.js | 174 +---------
.../choice/ChoiceInteractionEditor.vue | 301 ++++++++++--------
.../ChoiceInteractionDescriptor.spec.js | 17 +-
.../QTIEditor/interactions/choice/parse.js | 138 ++++++++
.../interactions/choice/validation.js | 52 +++
.../views/QTIEditor/qtiEditorStrings.js | 10 +-
.../serialization/__tests__/parseItem.spec.js | 62 +++-
.../shared/views/QTIEditor/utils/html.js | 8 +
12 files changed, 464 insertions(+), 337 deletions(-)
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js
create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/html.js
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 27f4748601..4fd62db3d1 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -33,8 +33,8 @@
:mode="mode"
:showAnswers="showAnswers"
@update:questionType="type => (currentQuestionType = type)"
- @update:bodyXml="onBodyXmlUpdate"
- @update:responseDeclarations="onResponseDeclarationsUpdate"
+ @update:bodyXml="xml => (currentBodyXml = xml)"
+ @update:responseDeclarations="decls => (currentResponseDeclarations = decls)"
/>
- import { computed, ref } from 'vue';
+ import { computed, ref, watchEffect } from 'vue';
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { QuestionType } from '../../constants';
import useQtiItem from '../../composables/useQtiItem';
@@ -130,26 +130,20 @@
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({
+ const rawData = computed(() =>
+ reassembleItemXml({
identifier: identifier.value,
title: title.value,
language: language.value,
bodyXml: currentBodyXml.value,
responseDeclarations: currentResponseDeclarations.value,
- });
- emit('update:rawData', newRawData);
- }
+ }),
+ );
+
+ // Emit only when the final assembled string actually changes
+ watchEffect(() => {
+ emit('update:rawData', rawData.value);
+ });
return {
currentQuestionType,
@@ -158,8 +152,8 @@
questionNumberAndTypeLabel,
closeBtnLabel$,
questionContentPlaceholder$,
- onBodyXmlUpdate,
- onResponseDeclarationsUpdate,
+ currentBodyXml,
+ currentResponseDeclarations,
};
},
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue
index dc98110055..dd8176c707 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/ValidationMessage/index.vue
@@ -4,7 +4,7 @@
v-if="show"
class="validation-message"
role="alert"
- :style="{ color: $themePalette.red.v_700 }"
+ :style="{ color: $themeTokens.error }"
>
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
index 8f365dbb88..f3d956e6ef 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
@@ -1,3 +1,4 @@
+import { readonly } from 'vue';
import { QuestionType } from '../constants';
import { generateRandomSlug } from '../utils/generateRandomSlug';
import { choiceInteractionDescriptor } from '../interactions/choice/ChoiceInteractionDescriptor';
@@ -104,6 +105,7 @@ export function useChoiceInteraction(interactionBlock, questionType) {
return {
...base,
+ state: readonly(state),
addChoice,
removeChoice,
moveChoiceUp,
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js
index 4169f4f783..4922a38ad7 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js
@@ -84,5 +84,6 @@ export const ValidationError = Object.freeze({
NO_CORRECT_ANSWER: 'NO_CORRECT_ANSWER',
TOO_MANY_CORRECT_ANSWERS: 'TOO_MANY_CORRECT_ANSWERS',
EMPTY_CHOICE_CONTENT: 'EMPTY_CHOICE_CONTENT',
+ DUPLICATE_CHOICE_CONTENT: 'DUPLICATE_CHOICE_CONTENT',
TOO_FEW_CHOICES: 'TOO_FEW_CHOICES',
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js
index 573b70d458..711f08c705 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js
@@ -1,15 +1,6 @@
-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';
+import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants';
+import { parseChoiceInteraction, buildChoiceInteractionXML } from './parse';
+import { validateChoiceInteraction } from './validation';
/**
* Owns all choice-specific interaction logic: schema, parse, buildXML, and validate.
@@ -50,17 +41,6 @@ export class ChoiceInteractionDescriptor {
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.
*
@@ -69,31 +49,7 @@ export class ChoiceInteractionDescriptor {
* @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 };
+ return parseChoiceInteraction(bodyXml, responseDeclarations);
}
/**
@@ -104,49 +60,7 @@ export class ChoiceInteractionDescriptor {
* @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] };
+ return buildChoiceInteractionXML(state, questionType, this.getDeclarationSchema(questionType));
}
/**
@@ -157,85 +71,9 @@ export class ChoiceInteractionDescriptor {
* @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;
+ return validateChoiceInteraction(state, questionType);
}
}
/** 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/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
index 13aff61928..7be092b53e 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
@@ -3,88 +3,66 @@
+
+ {{ errorPromptRequired$() }}
+
{{ questionLabel$() }}
-
+
-
-
- {{ errorPromptRequired$() }}
-
-
-
-
-
-
-
-
+
+
-
- {{ errorPromptRequired$() }}
-
-
-
- {{ errorNoCorrectAnswer$() }}
-
-
- {{ errorTooManyCorrectAnswers$() }}
-
-
- {{ errorTooFewChoices$() }}
-
-
+
+ {{ errorNoCorrectAnswer$() }}
+
+
+ {{ errorTooManyCorrectAnswers$() }}
+
+
+ {{ errorTooFewChoices$() }}
+
+
-
-
+
+
-
-
+
+
+
+
setChoiceContent(answer.id, html)"
@minimize="closeChoice"
/>
-
+
{{ errorEmptyChoiceContent$() }}
+
+ {{ errorDuplicateChoiceContent$() }}
+
@@ -182,17 +154,6 @@
v-if="mode === 'edit' && !answer.fixed"
class="answer-actions toolbar"
>
-
-
-
-
+
+
- import { computed, ref, watch } from 'vue';
+ import { computed, ref, watch, getCurrentInstance } from 'vue';
import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow';
import { themePalette } from 'kolibri-design-system/lib/styles/theme';
import { qtiEditorStrings } from '../../qtiEditorStrings';
@@ -296,6 +257,7 @@
errorNoCorrectAnswer$,
errorTooManyCorrectAnswers$,
errorEmptyChoiceContent$,
+ errorDuplicateChoiceContent$,
errorTooFewChoices$,
questionLabel$,
answersLabelSingleChoice$,
@@ -398,6 +360,15 @@
),
);
+ const duplicateChoiceIds = computed(
+ () =>
+ new Set(
+ errors.value
+ .filter(e => e.code === ValidationError.DUPLICATE_CHOICE_CONTENT)
+ .map(e => e.id),
+ ),
+ );
+
const questionHasError = computed(() =>
errorCodes.value.includes(ValidationError.PROMPT_REQUIRED),
);
@@ -414,10 +385,6 @@
() => errors.value.length > 0 && errorCodes.value.includes(ValidationError.TOO_FEW_CHOICES),
);
- function choiceHasError(id) {
- return emptyChoiceIds.value.has(id);
- }
-
function onToggleCorrect(id) {
toggleCorrectChoice(id);
runValidation();
@@ -481,6 +448,47 @@
];
}
+ const instance = getCurrentInstance();
+
+ function isChoiceClosed(id) {
+ return props.mode !== 'edit' || openChoiceId.value !== id;
+ }
+
+ function isChoiceOpen(id) {
+ return props.mode === 'edit' && openChoiceId.value === id;
+ }
+
+ function getAnswerClasses(answer) {
+ const closed = isChoiceClosed(answer.id);
+ const clickable = props.mode === 'edit' && closed;
+ return [
+ { 'is-clickable': clickable },
+ clickable
+ ? instance.proxy.$computedClass({
+ ':hover': { backgroundColor: instance.proxy.$themeTokens.fineLine },
+ })
+ : '',
+ ];
+ }
+
+ function getAnswerStyle(answer) {
+ const isCorrectView = answer.correct && props.mode === 'view' && props.showAnswers;
+ const hasError =
+ emptyChoiceIds.value.has(answer.id) || duplicateChoiceIds.value.has(answer.id);
+
+ let borderColor = instance.proxy.$themeTokens.fineLine;
+ if (hasError) {
+ borderColor = instance.proxy.$themeTokens.error;
+ } else if (isCorrectView) {
+ borderColor = instance.proxy.$themeTokens.correct;
+ }
+
+ return {
+ borderColor,
+ backgroundColor: isCorrectView ? instance.proxy.$themePalette.green.v_50 : null,
+ };
+ }
+
return {
state,
isSingleSelect,
@@ -489,8 +497,6 @@
isQuestionOpen,
openQuestion,
closeQuestion,
- openChoiceId,
- openChoice,
closeChoice,
onRowClick,
correctAnswerId,
@@ -498,7 +504,8 @@
noCorrectAnswerError,
tooManyCorrectError,
tooFewChoicesError,
- choiceHasError,
+ emptyChoiceIds,
+ duplicateChoiceIds,
setPrompt,
setChoiceContent,
onToggleCorrect,
@@ -507,6 +514,10 @@
moveChoiceUp,
moveChoiceDown,
getChoiceRowActions,
+ isChoiceClosed,
+ isChoiceOpen,
+ getAnswerClasses,
+ getAnswerStyle,
addChoiceBtn$,
deleteChoiceBtn$,
moveChoiceUpBtn$,
@@ -516,6 +527,7 @@
errorNoCorrectAnswer$,
errorTooManyCorrectAnswers$,
errorEmptyChoiceContent$,
+ errorDuplicateChoiceContent$,
errorTooFewChoices$,
questionLabel$,
toolbarLabelEdit$,
@@ -580,9 +592,13 @@
display: flex;
flex-direction: column;
gap: 4px;
+ padding: 0;
+ margin: 0;
+ list-style: none;
}
.answer-border {
+ position: relative;
border: 1px solid;
border-radius: 4px;
transition: background-color 0.3s;
@@ -662,13 +678,12 @@
margin-left: 16px;
}
- /* Fill parent — same as AnswersEditor .editor */
.editor {
width: 100%;
}
- /* Prompt wrapper */
.choice-editor__prompt-wrap {
+ position: relative;
padding: 4px 0;
}
@@ -676,7 +691,23 @@
cursor: pointer;
}
- /* Add choice button */
+ .content-overlay-trigger {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ cursor: pointer;
+ background: none;
+ border: 0;
+ outline: none;
+
+ &:focus-visible {
+ outline: 2px solid currentcolor;
+ outline-offset: -2px;
+ }
+ }
+
.answer-editor-button {
justify-content: center;
width: 100%;
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
index 18aeffc152..25c1b6532f 100644
--- 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
@@ -20,18 +20,17 @@ describe('ChoiceInteractionDescriptor', () => {
expect(descriptor.getQuestionType(el)).toBe(QuestionType.SINGLE_SELECT);
});
- it('creates single-cardinality declarations for singleSelect', () => {
+ it('returns single-cardinality schema 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);
+ const schema = descriptor.getDeclarationSchema(QuestionType.SINGLE_SELECT);
+ expect(schema.baseType).toBe(BaseType.IDENTIFIER);
+ expect(schema.cardinality).toBe(Cardinality.SINGLE);
});
- it('creates multiple-cardinality declarations for multiSelect', () => {
+ it('returns multiple-cardinality schema 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);
+ const schema = descriptor.getDeclarationSchema(QuestionType.MULTI_SELECT);
+ expect(schema.baseType).toBe(BaseType.IDENTIFIER);
+ expect(schema.cardinality).toBe(Cardinality.MULTIPLE);
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
new file mode 100644
index 0000000000..a8cc9d6dfe
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
@@ -0,0 +1,138 @@
+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';
+
+const serializer = new XMLSerializer();
+const RESPONSE_IDENTIFIER = 'RESPONSE';
+
+export function _defaultState() {
+ return {
+ prompt: '',
+ answers: [],
+ maxChoices: 1,
+ minChoices: 0,
+ shuffle: false,
+ orientation: 'vertical',
+ };
+}
+
+export 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;
+}
+
+export function _parseHtmlFragment(html) {
+ if (!html) return [];
+ if (!html.includes('<')) return [html];
+ try {
+ const fragment = parseXML(`${html}`);
+ return [...fragment.documentElement.childNodes];
+ } catch {
+ return [html];
+ }
+}
+
+/**
+ * Parse body XML + response declarations → ChoiceState.
+ *
+ * @param {string} bodyXml
+ * @param {string[]} responseDeclarations
+ * @returns {object} ChoiceState
+ */
+export function parseChoiceInteraction(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
+ * @param {object} declarationSchema - { baseType: string, cardinality: string }
+ * @returns {{ bodyXml: string, declarations: string[] }}
+ */
+export function buildChoiceInteractionXML(state, questionType, declarationSchema) {
+ 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, baseType } = declarationSchema;
+ const declaration = new QTIDeclaration({
+ identifier: RESPONSE_IDENTIFIER,
+ baseType,
+ 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] };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js
new file mode 100644
index 0000000000..5733ac26df
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js
@@ -0,0 +1,52 @@
+import { QuestionType, ValidationError } from '../../constants';
+import { stripTags } from '../../utils/html';
+
+/**
+ * Validate ChoiceState → ValidationError[].
+ *
+ * @param {object} state - ChoiceState
+ * @param {string} questionType
+ * @returns {Array<{ code: string, id?: string }>}
+ */
+export function validateChoiceInteraction(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 });
+ }
+
+ // Map each normalised text → the id of the first answer that had that text.
+ // When a later answer matches, both ids are flagged as duplicates in O(1).
+ const firstSeenId = new Map();
+ const duplicateIds = new Set();
+
+ for (const answer of answers) {
+ const textContent = stripTags(answer.content).trim();
+ if (!textContent) {
+ errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id: answer.id });
+ } else if (firstSeenId.has(textContent)) {
+ duplicateIds.add(firstSeenId.get(textContent));
+ duplicateIds.add(answer.id);
+ } else {
+ firstSeenId.set(textContent, answer.id);
+ }
+ }
+
+ for (const duplicateId of duplicateIds) {
+ errors.push({ code: ValidationError.DUPLICATE_CHOICE_CONTENT, id: duplicateId });
+ }
+
+ 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;
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
index dffd3ae3f1..7b9615115a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
@@ -122,11 +122,11 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
context: 'Accessible label for radio / checkbox that marks a choice as correct',
},
errorPromptRequired: {
- message: 'A question prompt is required.',
+ message: 'Question is required',
context: 'Validation error shown when the prompt is empty',
},
errorNoCorrectAnswer: {
- message: 'At least one correct answer must be selected.',
+ message: 'Choose a correct answer',
context: 'Validation error when no choice is marked correct',
},
errorTooManyCorrectAnswers: {
@@ -134,9 +134,13 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
context: 'Validation error when multiple choices are marked correct for single-select',
},
errorEmptyChoiceContent: {
- message: 'Each answer option must have content.',
+ message: 'Answer cannot be blank',
context: 'Validation error when an answer option is empty',
},
+ errorDuplicateChoiceContent: {
+ message: 'Duplicate answer options are not allowed',
+ context: 'Validation error when two or more answer options have identical content',
+ },
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/serialization/__tests__/parseItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js
index cf8e63f6b9..6dab82abf9 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js
@@ -1,4 +1,4 @@
-import { parseXML, parseItem } from '../parseItem';
+import { parseXML, parseItem, reassembleItemXml } from '../parseItem';
import { VALID_CHOICE_ITEM_DOCUMENT, TWO_INTERACTIONS_DOCUMENT } from '../../utils/testingFixtures';
// ---------------------------------------------------------------------------
@@ -110,3 +110,63 @@ describe('parseItem — response declaration matching', () => {
expect(model.interactions[0].responseDeclarations).toHaveLength(0);
});
});
+
+// ---------------------------------------------------------------------------
+// reassembleItemXml — XML attribute escaping (regression)
+// ---------------------------------------------------------------------------
+
+describe('reassembleItemXml — attribute escaping', () => {
+ const BASE = {
+ bodyXml: '',
+ responseDeclarations: [],
+ };
+
+ it('escapes & in the title so the output is well-formed XML', () => {
+ const xml = reassembleItemXml({
+ ...BASE,
+ identifier: 'item-1',
+ title: 'Math & Science',
+ language: 'en',
+ });
+ expect(xml).toContain('title="Math & Science"');
+ expect(() =>
+ new DOMParser().parseFromString(xml, 'text/xml').querySelector('parsererror'),
+ ).not.toThrow();
+ expect(
+ new DOMParser().parseFromString(xml, 'text/xml').querySelector('parsererror'),
+ ).toBeNull();
+ });
+
+ it('escapes " in the title so the attribute value is not terminated early', () => {
+ const xml = reassembleItemXml({
+ ...BASE,
+ identifier: 'item-1',
+ title: 'The "Quiz"',
+ language: 'en',
+ });
+ expect(xml).toContain('title="The "Quiz""');
+ expect(
+ new DOMParser().parseFromString(xml, 'text/xml').querySelector('parsererror'),
+ ).toBeNull();
+ });
+
+ it('escapes < in the identifier so the tag is not corrupted', () => {
+ const xml = reassembleItemXml({ ...BASE, identifier: 'a {
+ const xml = reassembleItemXml({
+ ...BASE,
+ identifier: 'item-clean',
+ title: 'Plain Title',
+ language: 'en',
+ });
+ const doc = new DOMParser().parseFromString(xml, 'text/xml');
+ expect(doc.querySelector('parsererror')).toBeNull();
+ expect(doc.querySelector('qti-assessment-item')).toHaveAttribute('title', 'Plain Title');
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/html.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/html.js
new file mode 100644
index 0000000000..2d78143323
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/html.js
@@ -0,0 +1,8 @@
+/**
+ * Strips HTML tags from a string.
+ * @param {string} html
+ * @returns {string}
+ */
+export function stripTags(html) {
+ return (html ?? '').replace(/<[^>]*>/g, '');
+}
From 4ddc0cdd0dd3961d5c93336be39671642f491d11 Mon Sep 17 00:00:00 2001
From: Abhishek-Punhani
Date: Mon, 6 Jul 2026 21:51:33 +0530
Subject: [PATCH 07/12] refactor: rename answers property to choices in QTI
choice interaction state and logic
Signed-off-by: Abhishek-Punhani
---
.../__tests__/useChoiceInteraction.spec.js | 64 ++++----
.../composables/useChoiceInteraction.js | 32 ++--
.../choice/ChoiceInteractionEditor.vue | 152 +++++++++---------
.../__tests__/ChoiceInteractionEditor.spec.js | 14 +-
.../choice/__tests__/parse.spec.js | 58 +++----
.../choice/__tests__/validate.spec.js | 34 ++--
.../QTIEditor/interactions/choice/parse.js | 18 +--
.../interactions/choice/validation.js | 20 +--
8 files changed, 196 insertions(+), 196 deletions(-)
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
index 0b48c4d064..d8a3969283 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js
@@ -10,12 +10,12 @@ function makeAnswer(overrides = {}) {
return { id: 'choice_a', content: 'A', correct: false, fixed: false, ...overrides };
}
-function makeBlock(answers, questionType = QuestionType.SINGLE_SELECT) {
+function makeBlock(choices, questionType = QuestionType.SINGLE_SELECT) {
const maxChoices = questionType === QuestionType.SINGLE_SELECT ? 1 : 2;
- const correctIds = answers.filter(a => a.correct).map(a => a.id);
+ const correctIds = choices.filter(a => a.correct).map(a => a.id);
const bodyXml = `
- ${answers.map(a => `${a.content}`).join('\n ')}
+ ${choices.map(a => `${a.content}`).join('\n ')}
`;
const declaration = ` {
describe('addChoice()', () => {
- it('appends a new answer to the list', () => {
+ it('appends a new choice to the list', () => {
const { state, addChoice } = setup([
makeAnswer({ id: 'a', content: 'A' }),
makeAnswer({ id: 'b', content: 'B' }),
]);
addChoice();
- expect(state.value.answers).toHaveLength(3);
+ expect(state.value.choices).toHaveLength(3);
});
- it('new answer has a generated "choice_" identifier', () => {
+ it('new choice has a generated "choice_" identifier', () => {
const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
addChoice();
- const newAnswer = state.value.answers[2];
+ const newAnswer = state.value.choices[2];
expect(newAnswer.id).toMatch(/^choice_[a-z0-9]{8}$/);
});
- it('new answer has empty content and correct: false', () => {
+ it('new choice has empty content and correct: false', () => {
const { state, addChoice } = setup([makeAnswer({ id: 'a' }), makeAnswer({ id: 'b' })]);
addChoice();
- const newAnswer = state.value.answers[2];
+ const newAnswer = state.value.choices[2];
expect(newAnswer.content).toBe('');
expect(newAnswer.correct).toBe(false);
});
});
describe('removeChoice()', () => {
- it('removes the answer with the given id', () => {
+ it('removes the choice 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();
+ expect(state.value.choices.find(a => a.id === 'a')).toBeUndefined();
});
- it('is a no-op when only one answer remains', () => {
+ it('is a no-op when only one choice remains', () => {
const { state, removeChoice } = setup([makeAnswer({ id: 'a' })]);
removeChoice('a');
- expect(state.value.answers).toHaveLength(1);
+ expect(state.value.choices).toHaveLength(1);
});
});
describe('moveChoiceUp()', () => {
- it('swaps answer with the previous one', () => {
+ it('swaps choice 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']);
+ expect(state.value.choices.map(a => a.id)).toEqual(['b', 'a', 'c']);
});
- it('is a no-op when the answer is first', () => {
+ it('is a no-op when the choice 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']);
+ expect(state.value.choices.map(a => a.id)).toEqual(['a', 'b']);
});
});
describe('moveChoiceDown()', () => {
- it('swaps answer with the next one', () => {
+ it('swaps choice 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']);
+ expect(state.value.choices.map(a => a.id)).toEqual(['a', 'c', 'b']);
});
- it('is a no-op when the answer is last', () => {
+ it('is a no-op when the choice 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']);
+ expect(state.value.choices.map(a => a.id)).toEqual(['a', 'b']);
});
});
@@ -124,8 +124,8 @@ describe('useChoiceInteraction', () => {
]);
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);
+ expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
+ expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
});
it('multiSelect: toggles only the target, leaves others unchanged', () => {
@@ -135,8 +135,8 @@ describe('useChoiceInteraction', () => {
);
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);
+ expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
+ expect(state.value.choices.find(a => a.id === 'a').correct).toBe(true);
});
it('multiSelect: toggles correct off when already correct', () => {
@@ -146,8 +146,8 @@ describe('useChoiceInteraction', () => {
);
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);
+ expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false);
+ expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true);
});
});
@@ -160,14 +160,14 @@ describe('useChoiceInteraction', () => {
});
describe('setChoiceContent()', () => {
- it('updates content for the target answer only', () => {
+ it('updates content for the target choice 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');
+ expect(state.value.choices.find(a => a.id === 'a').content).toBe('New content');
+ expect(state.value.choices.find(a => a.id === 'b').content).toBe('Unchanged');
});
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
index f3d956e6ef..e0268059c6 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useChoiceInteraction.js
@@ -27,47 +27,47 @@ export function useChoiceInteraction(interactionBlock, questionType) {
function addChoice() {
state.value = {
...state.value,
- answers: [
- ...state.value.answers,
+ choices: [
+ ...state.value.choices,
{ id: generateRandomSlug('choice'), content: '', correct: false, fixed: false },
],
};
}
function removeChoice(id) {
- if (state.value.answers.length <= 1) return;
+ if (state.value.choices.length <= 1) return;
state.value = {
...state.value,
- answers: state.value.answers.filter(a => a.id !== id),
+ choices: state.value.choices.filter(a => a.id !== id),
};
}
function moveChoiceUp(id) {
- const answers = [...state.value.answers];
- const idx = answers.findIndex(a => a.id === id);
+ const choices = [...state.value.choices];
+ const idx = choices.findIndex(a => a.id === id);
if (idx <= 0) return;
- [answers[idx - 1], answers[idx]] = [answers[idx], answers[idx - 1]];
- state.value = { ...state.value, answers };
+ [choices[idx - 1], choices[idx]] = [choices[idx], choices[idx - 1]];
+ state.value = { ...state.value, choices };
}
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 };
+ const choices = [...state.value.choices];
+ const idx = choices.findIndex(a => a.id === id);
+ if (idx === -1 || idx >= choices.length - 1) return;
+ [choices[idx], choices[idx + 1]] = [choices[idx + 1], choices[idx]];
+ state.value = { ...state.value, choices };
}
/**
* 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.
+ * multiSelect: toggles only the target choice's correct field.
*/
function toggleCorrectChoice(id) {
state.value = {
...state.value,
- answers: state.value.answers.map(a => {
+ choices: state.value.choices.map(a => {
if (questionType.value === QuestionType.SINGLE_SELECT) {
return { ...a, correct: a.id === id };
}
@@ -87,7 +87,7 @@ export function useChoiceInteraction(interactionBlock, questionType) {
function setChoiceContent(id, html) {
state.value = {
...state.value,
- answers: state.value.answers.map(a => (a.id === id ? { ...a, content: html } : a)),
+ choices: state.value.choices.map(a => (a.id === id ? { ...a, content: html } : a)),
};
}
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 7be092b53e..a26e89ad0d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
@@ -16,7 +16,7 @@
-
-
-
+
+
+
{{ answersLabel }}
-
+
-
-
+
-
+
setChoiceContent(answer.id, html)"
+ @update="html => setChoiceContent(choice.id, html)"
@minimize="closeChoice"
/>
-
+
{{ errorEmptyChoiceContent$() }}
-
+
{{ errorDuplicateChoiceContent$() }}
@@ -163,40 +163,40 @@
:disabled="index === 0"
:color="index === 0 ? $themeTokens.textDisabled : $themePalette.grey.v_800"
size="small"
- @click.stop="moveChoiceUp(answer.id)"
+ @click.stop="moveChoiceUp(choice.id)"
/>
@@ -209,11 +209,11 @@
v-if="mode === 'edit'"
appearance="flat-button"
:appearanceOverrides="buttonAppearanceOverrides"
- class="answer-editor-button"
+ class="choice-editor-button"
:aria-label="addChoiceBtn$()"
@click="onAddChoice"
>
-
+
0) {
- openChoice(state.value.answers[0].id);
+ } else if (state.value.choices.length > 0) {
+ openChoice(state.value.choices[0].id);
}
} else {
isQuestionOpen.value = false;
@@ -348,7 +348,7 @@
isSingleSelect.value ? answersLabelSingleChoice$() : answersLabelMultipleChoice$(),
);
- const correctAnswerId = computed(() => state.value.answers.find(a => a.correct)?.id ?? null);
+ const correctChoiceId = computed(() => state.value.choices.find(a => a.correct)?.id ?? null);
const errorCodes = computed(() => errors.value.map(e => e.code));
const emptyChoiceIds = computed(
@@ -393,17 +393,17 @@
function onAddChoice() {
addChoice();
runValidation();
- openChoiceId.value = state.value.answers[state.value.answers.length - 1]?.id ?? null;
+ openChoiceId.value = state.value.choices[state.value.choices.length - 1]?.id ?? null;
}
function onRemoveChoice(id) {
- const index = state.value.answers.findIndex(a => a.id === id);
+ const index = state.value.choices.findIndex(a => a.id === id);
removeChoice(id);
runValidation();
if (openChoiceId.value === id) {
openChoiceId.value = null;
- const nextIdx = Math.min(index, state.value.answers.length - 1);
- if (nextIdx >= 0) openChoiceId.value = state.value.answers[nextIdx]?.id ?? null;
+ const nextIdx = Math.min(index, state.value.choices.length - 1);
+ if (nextIdx >= 0) openChoiceId.value = state.value.choices[nextIdx]?.id ?? null;
}
}
@@ -413,7 +413,7 @@
if (
event.target.closest('.toolbar') ||
event.target.closest('input') ||
- event.target.closest('.answer-selection')
+ event.target.closest('.choice-selection')
)
return;
openChoiceId.value = id;
@@ -433,7 +433,7 @@
id: 'down',
icon: 'chevronDown',
label: moveChoiceDownBtn$(),
- disabled: index === state.value.answers.length - 1,
+ disabled: index === state.value.choices.length - 1,
handler: () => moveChoiceDown(answerId),
collapsed: false,
},
@@ -441,7 +441,7 @@
id: 'delete',
icon: 'trash',
label: deleteChoiceBtn$(),
- disabled: state.value.answers.length <= 1,
+ disabled: state.value.choices.length <= 1,
handler: () => onRemoveChoice(answerId),
collapsed: false,
},
@@ -458,8 +458,8 @@
return props.mode === 'edit' && openChoiceId.value === id;
}
- function getAnswerClasses(answer) {
- const closed = isChoiceClosed(answer.id);
+ function getChoiceClasses(choice) {
+ const closed = isChoiceClosed(choice.id);
const clickable = props.mode === 'edit' && closed;
return [
{ 'is-clickable': clickable },
@@ -471,10 +471,10 @@
];
}
- function getAnswerStyle(answer) {
- const isCorrectView = answer.correct && props.mode === 'view' && props.showAnswers;
+ function getChoiceStyle(choice) {
+ const isCorrectView = choice.correct && props.mode === 'view' && props.showAnswers;
const hasError =
- emptyChoiceIds.value.has(answer.id) || duplicateChoiceIds.value.has(answer.id);
+ emptyChoiceIds.value.has(choice.id) || duplicateChoiceIds.value.has(choice.id);
let borderColor = instance.proxy.$themeTokens.fineLine;
if (hasError) {
@@ -499,7 +499,7 @@
closeQuestion,
closeChoice,
onRowClick,
- correctAnswerId,
+ correctChoiceId,
questionHasError,
noCorrectAnswerError,
tooManyCorrectError,
@@ -516,8 +516,8 @@
getChoiceRowActions,
isChoiceClosed,
isChoiceOpen,
- getAnswerClasses,
- getAnswerStyle,
+ getChoiceClasses,
+ getChoiceStyle,
addChoiceBtn$,
deleteChoiceBtn$,
moveChoiceUpBtn$,
@@ -577,7 +577,7 @@
font-weight: 600;
}
- .answers-label {
+ .choices-label {
margin-bottom: 5px;
font-size: 12px;
font-weight: 600;
@@ -588,7 +588,7 @@
flex-direction: column;
}
- .answers-list {
+ .choices-list {
display: flex;
flex-direction: column;
gap: 4px;
@@ -597,14 +597,14 @@
list-style: none;
}
- .answer-border {
+ .choice-border {
position: relative;
border: 1px solid;
border-radius: 4px;
transition: background-color 0.3s;
}
- .answer-card-text {
+ .choice-card-text {
padding: 7.5px;
&.is-closed {
@@ -619,7 +619,7 @@
}
/* Flex row: [selection] [content] [actions] */
- .answer-layout {
+ .choice-layout {
display: flex;
align-items: center;
justify-content: space-between;
@@ -633,19 +633,19 @@
flex-wrap: wrap;
align-items: center;
- .answer-selection {
+ .choice-selection {
flex: 0 0 auto;
order: 0;
margin-bottom: 4px;
}
- .answer-actions {
+ .choice-actions {
flex: 0 0 auto;
order: 1;
margin-bottom: 4px;
}
- .answer-content {
+ .choice-content {
flex: 0 0 100%;
order: 2;
min-width: 0;
@@ -654,7 +654,7 @@
}
}
- .answer-selection {
+ .choice-selection {
flex-shrink: 0;
margin-right: 16px;
@@ -663,14 +663,14 @@
}
}
- .answer-content {
+ .choice-content {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
}
- .answer-actions {
+ .choice-actions {
display: flex;
flex-shrink: 0;
gap: 2px;
@@ -687,7 +687,7 @@
padding: 4px 0;
}
- .answer-border.is-clickable {
+ .choice-border.is-clickable {
cursor: pointer;
}
@@ -708,7 +708,7 @@
}
}
- .answer-editor-button {
+ .choice-editor-button {
justify-content: center;
width: 100%;
padding: 11px 16px !important;
@@ -717,7 +717,7 @@
border-radius: 4px !important;
}
- .add-answer-btn-content {
+ .add-choice-btn-content {
display: flex;
gap: 10px;
align-items: center;
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 4db1e81dd7..df69f46ade 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
@@ -81,7 +81,7 @@ describe('ChoiceInteractionEditor', () => {
expect(screen.getByText('Earth')).toBeInTheDocument();
});
- it('pre-checks the correct answer radio', () => {
+ it('pre-checks the correct choice radio', () => {
renderEditor({
interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL),
questionType: QuestionType.SINGLE_SELECT,
@@ -122,7 +122,7 @@ describe('ChoiceInteractionEditor', () => {
expect(screen.getByText('Option C')).toBeInTheDocument();
});
- it('pre-checks multiple correct answers', () => {
+ it('pre-checks multiple correct choices', () => {
renderEditor({
interaction: blockWithDecl(CHOICE_MULTI_SELECT_XML, MULTI_DECL),
questionType: QuestionType.MULTI_SELECT,
@@ -247,7 +247,7 @@ describe('ChoiceInteractionEditor', () => {
expect(screen.getAllByRole('radio')).toHaveLength(3);
});
- it('disables the correct-answer control in view mode', () => {
+ it('disables the correct-choice control in view mode', () => {
renderEditor({
interaction: block(CHOICE_SINGLE_SELECT_XML),
questionType: QuestionType.SINGLE_SELECT,
@@ -278,8 +278,8 @@ describe('ChoiceInteractionEditor', () => {
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.
+ it('shows global errors (no correct choice) after a structural mutation', async () => {
+ // Add a choice so we have 2+ choices — then the only error is no correct choice.
renderEditor({
interaction: block(CHOICE_SINGLE_SELECT_XML),
questionType: QuestionType.SINGLE_SELECT,
@@ -290,12 +290,12 @@ describe('ChoiceInteractionEditor', () => {
expect(screen.getAllByRole('alert').length).toBeGreaterThan(0);
});
- it('shows no-correct-answer error after toggling and running validation', async () => {
+ it('shows no-correct-choice error after toggling and running validation', async () => {
renderEditor({
interaction: blockWithDecl(CHOICE_SINGLE_SELECT_XML, SINGLE_DECL),
questionType: QuestionType.SINGLE_SELECT,
});
- // Uncheck the correct answer
+ // Uncheck the correct choice
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
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
index 8537c681c4..59b4ca66a0 100644
--- 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
@@ -75,12 +75,12 @@ describe('parse()', () => {
});
});
- describe('answers array', () => {
- it('maps each to an answer with id and content', () => {
+ describe('choices array', () => {
+ it('maps each to an choice 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');
+ expect(state.choices).toHaveLength(3);
+ expect(state.choices[0].id).toBe('mercury');
+ expect(state.choices[0].content).toContain('Mercury');
});
it('generates a slug identifier when element is missing its identifier attribute', () => {
@@ -88,7 +88,7 @@ describe('parse()', () => {
No id here
`;
const state = parse(xml, []);
- expect(state.answers[0].id).toMatch(/^choice_[a-z0-9]{8}$/);
+ expect(state.choices[0].id).toMatch(/^choice_[a-z0-9]{8}$/);
});
it('sets fixed: true when the fixed attribute is present', () => {
@@ -96,47 +96,47 @@ describe('parse()', () => {
A
`;
const state = parse(xml, []);
- expect(state.answers[0].fixed).toBe(true);
+ expect(state.choices[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);
+ expect(state.choices[0].fixed).toBe(false);
});
});
describe('correct response detection', () => {
- it('marks the correct answer for single-select', () => {
+ it('marks the correct choice 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');
+ const mercury = state.choices.find(a => a.id === 'mercury');
+ const venus = state.choices.find(a => a.id === 'venus');
expect(mercury.correct).toBe(true);
expect(venus.correct).toBe(false);
});
- it('marks multiple correct answers for multi-select', () => {
+ it('marks multiple correct choices 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);
+ expect(state.choices.find(a => a.id === 'a').correct).toBe(true);
+ expect(state.choices.find(a => a.id === 'b').correct).toBe(false);
+ expect(state.choices.find(a => a.id === 'c').correct).toBe(true);
});
- it('marks all answers as not correct when no declaration provided', () => {
+ it('marks all choices as not correct when no declaration provided', () => {
const state = parse(CHOICE_SINGLE_SELECT_XML, []);
- expect(state.answers.every(a => !a.correct)).toBe(true);
+ expect(state.choices.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.choices).toEqual([]);
expect(state.prompt).toBe('');
});
it('returns default state for malformed XML', () => {
const state = parse(' {
describe('buildXML()', () => {
const baseState = {
prompt: 'Pick one.',
- answers: [
+ choices: [
{ id: 'choice_a', content: 'Option A', correct: true, fixed: false },
{ id: 'choice_b', content: 'Option B', correct: false, fixed: false },
],
@@ -162,7 +162,7 @@ describe('buildXML()', () => {
it('sets cardinality="multiple" for multiSelect', () => {
const multiState = {
...baseState,
- answers: [
+ choices: [
{ id: 'a', content: 'A', correct: true, fixed: false },
{ id: 'b', content: 'B', correct: true, fixed: false },
],
@@ -181,7 +181,7 @@ describe('buildXML()', () => {
it('includes all correct identifiers for multi-select', () => {
const multiState = {
...baseState,
- answers: [
+ choices: [
{ id: 'x', content: 'X', correct: true, fixed: false },
{ id: 'y', content: 'Y', correct: true, fixed: false },
{ id: 'z', content: 'Z', correct: false, fixed: false },
@@ -204,10 +204,10 @@ describe('buildXML()', () => {
expect(bodyXml).toContain('min-choices');
});
- it('includes one per answer', () => {
+ it('includes one per choice', () => {
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
+ // Each tag appears as open + close = 2 × 2 choices = 4
expect(matches.length).toBe(4);
});
@@ -216,7 +216,7 @@ describe('buildXML()', () => {
{
...baseState,
prompt: 'Pick one.
',
- answers: [
+ choices: [
{
id: 'choice_a',
content: 'Option A
',
@@ -245,8 +245,8 @@ describe('parse → buildXML → parse round-trip', () => {
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));
+ expect(reparsed.choices.map(a => a.id)).toEqual(original.choices.map(a => a.id));
+ expect(reparsed.choices.map(a => a.correct)).toEqual(original.choices.map(a => a.correct));
});
it('multi-select: re-parsed state matches original', () => {
@@ -254,8 +254,8 @@ describe('parse → buildXML → parse round-trip', () => {
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),
+ expect(reparsed.choices.filter(a => a.correct).map(a => a.id)).toEqual(
+ original.choices.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
index 9364eeace0..8ff7d16911 100644
--- 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
@@ -13,7 +13,7 @@ function makeAnswer(overrides = {}) {
function makeState(overrides = {}) {
return {
prompt: 'What is 2 + 2?',
- answers: [
+ choices: [
makeAnswer({ id: 'choice_a', content: 'Four', correct: true }),
makeAnswer({ id: 'choice_b', content: 'Five', correct: false }),
],
@@ -34,7 +34,7 @@ describe('validate()', () => {
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 })],
+ choices: [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })],
});
expect(validate(state, QuestionType.MULTI_SELECT)).toEqual([]);
});
@@ -66,14 +66,14 @@ describe('validate()', () => {
});
describe('TOO_FEW_CHOICES', () => {
- it('returns error when there is only one answer', () => {
- const state = makeState({ answers: [makeAnswer({ correct: true })] });
+ it('returns error when there is only one choice', () => {
+ const state = makeState({ choices: [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', () => {
+ it('does not return error when there are two or more choices', () => {
expect(errorCodes(validate(makeState(), QuestionType.SINGLE_SELECT))).not.toContain(
ValidationError.TOO_FEW_CHOICES,
);
@@ -81,9 +81,9 @@ describe('validate()', () => {
});
describe('EMPTY_CHOICE_CONTENT', () => {
- it('returns an error for each answer with empty content', () => {
+ it('returns an error for each choice with empty content', () => {
const state = makeState({
- answers: [
+ choices: [
makeAnswer({ id: 'a', content: '', correct: true }),
makeAnswer({ id: 'b', content: ' ', correct: false }),
],
@@ -94,9 +94,9 @@ describe('validate()', () => {
expect(contentErrors.map(e => e.id)).toEqual(['a', 'b']);
});
- it('does not flag answers with content wrapped in HTML tags', () => {
+ it('does not flag choices with content wrapped in HTML tags', () => {
const state = makeState({
- answers: [
+ choices: [
makeAnswer({ id: 'a', content: 'Yes', correct: true }),
makeAnswer({ id: 'b', content: 'No', correct: false }),
],
@@ -107,18 +107,18 @@ describe('validate()', () => {
});
describe('NO_CORRECT_ANSWER', () => {
- it('returns error for singleSelect when no answer is correct', () => {
+ it('returns error for singleSelect when no choice is correct', () => {
const state = makeState({
- answers: [makeAnswer({ id: 'a', correct: false }), makeAnswer({ id: 'b', correct: false })],
+ choices: [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', () => {
+ it('returns error for multiSelect when no choice is correct', () => {
const state = makeState({
- answers: [makeAnswer({ id: 'a', correct: false }), makeAnswer({ id: 'b', correct: false })],
+ choices: [makeAnswer({ id: 'a', correct: false }), makeAnswer({ id: 'b', correct: false })],
});
expect(errorCodes(validate(state, QuestionType.MULTI_SELECT))).toContain(
ValidationError.NO_CORRECT_ANSWER,
@@ -127,18 +127,18 @@ describe('validate()', () => {
});
describe('TOO_MANY_CORRECT_ANSWERS', () => {
- it('returns error for singleSelect when more than one answer is correct', () => {
+ it('returns error for singleSelect when more than one choice is correct', () => {
const state = makeState({
- answers: [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })],
+ choices: [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', () => {
+ it('does not return error for multiSelect when more than one choice is correct', () => {
const state = makeState({
- answers: [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })],
+ choices: [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/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
index a8cc9d6dfe..3a3c119f2d 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js
@@ -10,7 +10,7 @@ const RESPONSE_IDENTIFIER = 'RESPONSE';
export function _defaultState() {
return {
prompt: '',
- answers: [],
+ choices: [],
maxChoices: 1,
minChoices: 0,
shuffle: false,
@@ -73,14 +73,14 @@ export function parseChoiceInteraction(bodyXml, responseDeclarations) {
const correctIds = _extractCorrectIds(responseDeclarations);
- const answers = [...root.querySelectorAll('qti-simple-choice')].map(el => ({
+ const choices = [...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 };
+ return { prompt, choices, maxChoices, minChoices, shuffle, orientation };
}
/**
@@ -92,7 +92,7 @@ export function parseChoiceInteraction(bodyXml, responseDeclarations) {
* @returns {{ bodyXml: string, declarations: string[] }}
*/
export function buildChoiceInteractionXML(state, questionType, declarationSchema) {
- const { prompt, answers, maxChoices, minChoices, shuffle, orientation } = state;
+ const { prompt, choices, maxChoices, minChoices, shuffle, orientation } = state;
const attrs = {
'response-identifier': RESPONSE_IDENTIFIER,
@@ -108,14 +108,14 @@ export function buildChoiceInteractionXML(state, questionType, declarationSchema
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';
+ for (const choice of choices) {
+ const choiceAttrs = { identifier: choice.id };
+ if (choice.fixed) choiceAttrs.fixed = 'true';
children.push(
buildXmlNode({
tag: 'qti-simple-choice',
attrs: choiceAttrs,
- children: _parseHtmlFragment(answer.content),
+ children: _parseHtmlFragment(choice.content),
}),
);
}
@@ -130,7 +130,7 @@ export function buildChoiceInteractionXML(state, questionType, declarationSchema
cardinality,
tag: 'qti-response-declaration',
});
- const correctIds = answers.filter(a => a.correct).map(a => a.id);
+ const correctIds = choices.filter(a => a.correct).map(a => a.id);
new CorrectResponse(correctIds, declaration);
const declarationXml = serializer.serializeToString(declaration.getXML());
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js
index 5733ac26df..b8aad8289f 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/validation.js
@@ -10,30 +10,30 @@ import { stripTags } from '../../utils/html';
*/
export function validateChoiceInteraction(state, questionType) {
const errors = [];
- const { prompt, answers } = state;
+ const { prompt, choices } = state;
if (!stripTags(prompt).trim()) {
errors.push({ code: ValidationError.PROMPT_REQUIRED });
}
- if (answers.length < 2) {
+ if (choices.length < 2) {
errors.push({ code: ValidationError.TOO_FEW_CHOICES });
}
- // Map each normalised text → the id of the first answer that had that text.
- // When a later answer matches, both ids are flagged as duplicates in O(1).
+ // Map each normalised text → the id of the first choice that had that text.
+ // When a later choice matches, both ids are flagged as duplicates in O(1).
const firstSeenId = new Map();
const duplicateIds = new Set();
- for (const answer of answers) {
- const textContent = stripTags(answer.content).trim();
+ for (const choice of choices) {
+ const textContent = stripTags(choice.content).trim();
if (!textContent) {
- errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id: answer.id });
+ errors.push({ code: ValidationError.EMPTY_CHOICE_CONTENT, id: choice.id });
} else if (firstSeenId.has(textContent)) {
duplicateIds.add(firstSeenId.get(textContent));
- duplicateIds.add(answer.id);
+ duplicateIds.add(choice.id);
} else {
- firstSeenId.set(textContent, answer.id);
+ firstSeenId.set(textContent, choice.id);
}
}
@@ -41,7 +41,7 @@ export function validateChoiceInteraction(state, questionType) {
errors.push({ code: ValidationError.DUPLICATE_CHOICE_CONTENT, id: duplicateId });
}
- const correctCount = answers.filter(a => a.correct).length;
+ const correctCount = choices.filter(a => a.correct).length;
if (correctCount === 0) {
errors.push({ code: ValidationError.NO_CORRECT_ANSWER });
} else if (questionType === QuestionType.SINGLE_SELECT && correctCount > 1) {
From c2e7bd576780a111b722de4bac50fe2f140914c5 Mon Sep 17 00:00:00 2001
From: Abhishek-Punhani
Date: Tue, 7 Jul 2026 00:13:10 +0530
Subject: [PATCH 08/12] refactor: unify bodyXml and responseDeclarations into a
single update:interaction event and update related tests
Signed-off-by: Abhishek-Punhani
---
.../components/InteractionSection/index.vue | 5 +-
.../components/QTIItemEditor/index.vue | 21 ++--
.../__tests__/useInteraction.spec.js | 30 ++++-
.../QTIEditor/composables/useInteraction.js | 20 ++-
.../choice/ChoiceInteractionEditor.vue | 118 +++++++++---------
.../__tests__/ChoiceInteractionEditor.spec.js | 60 ++++-----
.../choice/__tests__/validate.spec.js | 5 +-
7 files changed, 152 insertions(+), 107 deletions(-)
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 4bf3efb30f..4f9edf856a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue
@@ -15,8 +15,7 @@
:interaction="interaction"
:mode="mode"
:showAnswers="showAnswers"
- @update:bodyXml="xml => $emit('update:bodyXml', xml)"
- @update:responseDeclarations="decls => $emit('update:responseDeclarations', decls)"
+ @update:interaction="interaction => $emit('update:interaction', interaction)"
/>
@@ -69,7 +68,7 @@
},
},
- emits: ['update:questionType', 'update:bodyXml', 'update:responseDeclarations'],
+ emits: ['update:questionType', 'update:interaction'],
};
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 4fd62db3d1..30e7139ac0 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -33,8 +33,12 @@
:mode="mode"
:showAnswers="showAnswers"
@update:questionType="type => (currentQuestionType = type)"
- @update:bodyXml="xml => (currentBodyXml = xml)"
- @update:responseDeclarations="decls => (currentResponseDeclarations = decls)"
+ @update:interaction="
+ ({ bodyXml, responseDeclarations }) => {
+ currentBodyXml.value = bodyXml;
+ currentResponseDeclarations.value = responseDeclarations;
+ }
+ "
/>
- import { computed, ref, watchEffect } from 'vue';
+ import { computed, ref, watch } from 'vue';
import { qtiEditorStrings } from '../../qtiEditorStrings';
import { QuestionType } from '../../constants';
import useQtiItem from '../../composables/useQtiItem';
@@ -121,7 +125,8 @@
/**
* Track the current bodyXml and responseDeclarations for the interaction.
- * Initialized from the parsed item; updated when the editor emits changes.
+ * Initialised from the parsed item; updated atomically when the editor
+ * emits update:interaction.
*/
const currentBodyXml = ref(
interactions.value.length > 0 ? interactions.value[0].bodyXml : '',
@@ -140,9 +145,9 @@
}),
);
- // Emit only when the final assembled string actually changes
- watchEffect(() => {
- emit('update:rawData', rawData.value);
+ // Emit only when the assembled XML actually changes after initial mount
+ watch(rawData, newVal => {
+ emit('update:rawData', newVal);
});
return {
@@ -152,8 +157,6 @@
questionNumberAndTypeLabel,
closeBtnLabel$,
questionContentPlaceholder$,
- currentBodyXml,
- currentResponseDeclarations,
};
},
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
index 0196a9c215..81d4649932 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js
@@ -1,4 +1,4 @@
-import { ref } from 'vue';
+import { ref, nextTick } from 'vue';
import { useInteraction } from '../useInteraction';
// ---------------------------------------------------------------------------
@@ -136,4 +136,32 @@ describe('useInteraction', () => {
expect(descriptor.buildXML).toHaveBeenCalledTimes(2);
expect(descriptor.buildXML).toHaveBeenLastCalledWith(expect.anything(), 'multiSelect');
});
+
+ it('automatically runs validation (debounced) when state changes', async () => {
+ jest.useFakeTimers();
+ const validateReturn = [{ code: 'SOME_ERROR' }];
+ const descriptor = makeDescriptor({ validateReturn });
+ const questionType = ref('singleSelect');
+
+ const { state, errors } = useInteraction(
+ descriptor,
+ { bodyXml: '', responseDeclarations: [] },
+ questionType,
+ );
+
+ expect(errors.value).toEqual([]);
+ state.value = { prompt: 'updated' };
+ await nextTick(); // flush Vue watcher queue
+
+ // Before the debounce fires, errors should still be empty.
+ expect(errors.value).toEqual([]);
+
+ // Fast-forward past the 400 ms debounce window.
+ jest.advanceTimersByTime(400);
+
+ expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'singleSelect');
+ expect(errors.value).toEqual(validateReturn);
+
+ jest.useRealTimers();
+ });
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js
index 6259490dc6..83e0c67249 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js
@@ -1,4 +1,5 @@
-import { ref, computed } from 'vue';
+import { ref, computed, watch, onUnmounted } from 'vue';
+import debounce from 'lodash/debounce';
/**
* Base composable for all interaction editors.
@@ -7,6 +8,11 @@ import { ref, computed } from 'vue';
* interaction plugin must go through. Individual interaction composables
* (e.g. useChoiceInteraction) call this and add mutation methods on top.
*
+ * Validation runs immediately when called explicitly (e.g. when closing a
+ * panel), but is debounced when triggered by state changes so that errors
+ * only appear after the user pauses typing (400 ms), avoiding noisy
+ * inline error flicker on every keystroke.
+ *
* @param {import('../interactions/defineInteraction').InteractionDescriptor} descriptor
* @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock
* @param {import('vue').Ref} questionType
@@ -38,9 +44,21 @@ export function useInteraction(descriptor, interactionBlock, questionType) {
// Errors start empty — never shown automatically so authors aren't startled on first load.
const errors = ref([]);
+ /** Immediately validates and updates errors. Use this for explicit triggers (e.g. close). */
function runValidation() {
errors.value = descriptor.validate(state.value, questionType.value);
}
+ /**
+ * Debounced version used by the state watcher — waits 400 ms after the user
+ * stops typing before showing inline errors.
+ */
+ const debouncedValidation = debounce(runValidation, 400);
+
+ // Cancel any pending debounce when the component is torn down.
+ onUnmounted(() => debouncedValidation.cancel());
+
+ watch([state, questionType], debouncedValidation, { deep: true });
+
return { state, bodyXml, declarations, errors, runValidation };
}
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 a26e89ad0d..5a1a59fbe0 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue
@@ -18,18 +18,14 @@
:class="[
mode === 'edit' && isQuestionOpen ? 'choice-editor__prompt-wrap' : 'choice-border',
]"
- :style="
+ :style="[
mode === 'edit' && isQuestionOpen
? {}
- : { borderColor: questionHasError ? $themeTokens.error : $themeTokens.fineLine }
- "
+ : { borderColor: questionHasError ? $themeTokens.error : $themeTokens.fineLine },
+ mode === 'edit' && !isQuestionOpen ? { cursor: 'pointer' } : {},
+ ]"
+ @click.stop="handlePromptClick"
>
-
@@ -77,29 +73,27 @@
class="choice-border"
:class="getChoiceClasses(choice)"
:style="getChoiceStyle(choice)"
+ @click.stop="handleChoiceClick($event, choice.id)"
>
-