QTI Editor — Dev Demo
Hardcoded items. Changes are local only and not persisted.
@@ -28,28 +28,34 @@
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
index 03122034a9..be4fa55cae 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js
@@ -2,20 +2,19 @@ import { render, screen, fireEvent } from '@testing-library/vue';
import VueRouter from 'vue-router';
import QTIItemEditor from '../index.vue';
import { qtiEditorStrings } from '../../../qtiEditorStrings';
-import { QtiInteraction } from '../../../constants';
+import { AssessmentItemTypes } from '../../../constants';
const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;
const defaultProps = {
item: {
- id: 'test-item-id',
- type: QtiInteraction.CHOICE,
- title: 'Test Choice Interaction',
+ assessment_id: 'test-item-id',
+ type: AssessmentItemTypes.QTI,
},
index: 0,
total: 5,
mode: 'view',
- displayAnswersPreview: false,
+ showAnswers: false,
};
const renderComponent = (props = {}, slots = {}) => {
@@ -28,9 +27,9 @@ const renderComponent = (props = {}, slots = {}) => {
describe('QTIItemEditor', () => {
describe('view mode', () => {
- test('does not show the card body', () => {
+ test('shows the card body (placeholder) even in view mode', () => {
renderComponent({ mode: 'view' });
- expect(screen.queryByText(questionContentPlaceholder$())).not.toBeInTheDocument();
+ expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
});
test('does not show the close button', () => {
@@ -57,14 +56,14 @@ describe('QTIItemEditor', () => {
});
});
- describe('displayAnswersPreview', () => {
- test('shows the card body in view mode when displayAnswersPreview is true', () => {
- renderComponent({ mode: 'view', displayAnswersPreview: true });
+ describe('showAnswers', () => {
+ test('shows the card body in view mode when showAnswers is true', () => {
+ renderComponent({ mode: 'view', showAnswers: true });
expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
});
- test('does not show the close button even when displayAnswersPreview is true', () => {
- renderComponent({ mode: 'view', displayAnswersPreview: true });
+ test('does not show the close button even when showAnswers is true', () => {
+ renderComponent({ mode: 'view', showAnswers: true });
expect(screen.queryByRole('button', { name: closeBtnLabel$() })).not.toBeInTheDocument();
});
});
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 c4754315ec..e21b5139f1 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue
@@ -26,11 +26,18 @@
-
+
+
(currentQuestionType = type)"
+ />
+
{{ questionContentPlaceholder$() }}
@@ -52,31 +59,28 @@
+
+
+
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
new file mode 100644
index 0000000000..f9169788bf
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js
@@ -0,0 +1,133 @@
+import { render, screen, fireEvent } from '@testing-library/vue';
+import VueRouter from 'vue-router';
+import ChoiceInteractionEditor from '../ChoiceInteractionEditor.vue';
+
+import {
+ CHOICE_SINGLE_SELECT_XML,
+ CHOICE_MULTI_SELECT_XML,
+ CHOICE_NO_PROMPT_XML,
+ mockInteractionBlock as block,
+} from '../../../utils/testingFixtures';
+import { QuestionType } from '../../../constants';
+
+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', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument();
+ });
+
+ it('renders no prompt element when the XML has no
', () => {
+ renderEditor({
+ interaction: block(CHOICE_NO_PROMPT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(screen.queryByText('Which planet is closest to the Sun?')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('singleSelect (KRadioButton)', () => {
+ it('renders a radio button for each choice', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ const radios = screen.getAllByRole('radio');
+ expect(radios).toHaveLength(3);
+ });
+
+ it('renders the correct choice labels', () => {
+ renderEditor({
+ interaction: block(CHOICE_SINGLE_SELECT_XML),
+ questionType: QuestionType.SINGLE_SELECT,
+ });
+ expect(screen.getByText('Mercury')).toBeInTheDocument();
+ expect(screen.getByText('Venus')).toBeInTheDocument();
+ expect(screen.getByText('Earth')).toBeInTheDocument();
+ });
+
+ it('allows selecting a radio button', 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();
+ });
+ });
+
+ describe('multiSelect (KCheckbox)', () => {
+ it('renders a checkbox for each choice', () => {
+ renderEditor({
+ interaction: block(CHOICE_MULTI_SELECT_XML),
+ questionType: QuestionType.MULTI_SELECT,
+ });
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes).toHaveLength(3);
+ });
+
+ it('renders the correct choice labels', () => {
+ renderEditor({
+ interaction: block(CHOICE_MULTI_SELECT_XML),
+ questionType: QuestionType.MULTI_SELECT,
+ });
+ expect(screen.getByText('Option A')).toBeInTheDocument();
+ expect(screen.getByText('Option B')).toBeInTheDocument();
+ expect(screen.getByText('Option C')).toBeInTheDocument();
+ });
+
+ it('allows checking multiple checkboxes independently', async () => {
+ renderEditor({
+ interaction: block(CHOICE_MULTI_SELECT_XML),
+ questionType: QuestionType.MULTI_SELECT,
+ });
+ const [checkA, checkB] = screen.getAllByRole('checkbox');
+ await fireEvent.click(checkA);
+ await fireEvent.click(checkB);
+ expect(checkA).toBeChecked();
+ expect(checkB).toBeChecked();
+ });
+
+ it('allows unchecking a checked checkbox', async () => {
+ renderEditor({ interaction: block(CHOICE_MULTI_SELECT_XML), questionType: 'multiSelect' });
+ 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' });
+ 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' });
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument();
+ });
+
+ it('renders nothing interactive when XML is malformed', () => {
+ renderEditor({ interaction: block(' 1) via the same element.
+ */
+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;
+ },
+
+ /**
+ * Extracts a structured state object from the interaction element.
+ * Stub — full parsing is a future task.
+ * @returns {object}
+ */
+ parse() {
+ return {};
+ },
+
+ /**
+ * Validates the interaction state and returns an array of error strings.
+ * Stub — full validation is a future task.
+ * @returns {string[]}
+ */
+ validate() {
+ return [];
+ },
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js
new file mode 100644
index 0000000000..2c76726529
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js
@@ -0,0 +1,34 @@
+/**
+ * Required keys every interaction descriptor must provide.
+ * Validated at import time so missing fields surface immediately during development.
+ */
+const REQUIRED_KEYS = [
+ 'type',
+ 'placement',
+ 'questionTypes',
+ 'editorComponent',
+ 'convertsFrom',
+ 'matches',
+ 'getQuestionType',
+ 'parse',
+ 'validate',
+];
+
+/**
+ * Validates that a descriptor has every required key and returns it unchanged.
+ * Throws at call-time (i.e. module import time) if any key is absent.
+ *
+ * @template {object} T
+ * @param {T} descriptor - The interaction descriptor to validate
+ * @returns {T} The same descriptor, unmodified
+ * @throws {Error} If any required key is missing from the descriptor
+ */
+export default function defineInteraction(descriptor) {
+ for (const key of REQUIRED_KEYS) {
+ if (!(key in descriptor)) {
+ const name = descriptor.type ?? '(unknown)';
+ throw new Error(`defineInteraction: missing required key "${key}" on descriptor "${name}"`);
+ }
+ }
+ return descriptor;
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
new file mode 100644
index 0000000000..d43baf93e0
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js
@@ -0,0 +1,23 @@
+import { QtiInteraction } from '../constants';
+import choiceDescriptor from './choice/index';
+
+/**
+ * The default interaction type used as fallback when no descriptor matches
+ * the interaction element found in the XML body.
+ */
+export const DEFAULT_INTERACTION = QtiInteraction.CHOICE;
+
+/**
+ * Ordered array of all registered interaction descriptors.
+ * InteractionSection iterates this to find the first descriptor whose
+ * matches(el) returns true.
+ */
+export const descriptors = [choiceDescriptor];
+
+/**
+ * Registry map keyed by descriptor.type for O(1) direct lookup.
+ * Built from the descriptors array — do not populate manually.
+ *
+ * @type {Object.}
+ */
+export const registry = Object.fromEntries(descriptors.map(d => [d.type, d]));
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
index b4a852f9f7..52ca0014d6 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js
@@ -29,29 +29,33 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', {
message: 'Show answers',
context: 'Checkbox label to toggle displaying answers/previews',
},
- interactionTypeChoice: {
- message: 'Choice',
- context: 'Display name for choiceInteraction',
+ singleChoiceLabel: {
+ message: 'Single Choice',
+ context: 'Display name for a single-select question type',
},
- interactionTypeOrder: {
+ multipleChoiceLabel: {
+ message: 'Multiple Choice',
+ context: 'Display name for a multiple-select question type',
+ },
+ orderLabel: {
message: 'Order',
- context: 'Display name for orderInteraction',
+ context: 'Display name for an order question type',
},
- interactionTypeMatch: {
+ matchLabel: {
message: 'Match',
- context: 'Display name for matchInteraction',
+ context: 'Display name for a match question type',
},
- interactionTypeTextEntry: {
+ textEntryLabel: {
message: 'Text entry',
- context: 'Display name for textEntryInteraction',
+ context: 'Display name for a text entry question type',
},
- interactionTypeExtendedText: {
+ extendedTextLabel: {
message: 'Extended text',
- context: 'Display name for extendedTextInteraction',
+ context: 'Display name for an extended text question type',
},
- interactionTypeUnknown: {
+ unknownTypeLabel: {
message: 'Unknown type',
- context: 'Fallback when an item has an unrecognised interaction type',
+ context: 'Fallback when an item has an unrecognised question type',
},
toolbarLabelEdit: {
message: 'Edit',
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
new file mode 100644
index 0000000000..cf8e63f6b9
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js
@@ -0,0 +1,112 @@
+import { parseXML, parseItem } from '../parseItem';
+import { VALID_CHOICE_ITEM_DOCUMENT, TWO_INTERACTIONS_DOCUMENT } from '../../utils/testingFixtures';
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const ITEM_NO_INTERACTIONS = `
+
+
+`;
+
+// ---------------------------------------------------------------------------
+// parseXML
+// ---------------------------------------------------------------------------
+
+describe('parseXML', () => {
+ it('parses valid XML into a Document', () => {
+ const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT);
+ expect(doc).toBeInstanceOf(Document);
+ expect(doc.querySelector('qti-assessment-item')).not.toBeNull();
+ });
+
+ it('throws for malformed XML', () => {
+ expect(() => parseXML(' {
+ // An extra closing tag causes a parsererror in jsdom
+ expect(() => parseXML('')).toThrow(/QTI XML parse error/i);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseItem — meta extraction
+// ---------------------------------------------------------------------------
+
+describe('parseItem — meta', () => {
+ it('returns an object with the top-level item attributes', () => {
+ const model = parseItem(VALID_CHOICE_ITEM_DOCUMENT);
+ expect(model.identifier).toBe('item-test-1');
+ expect(model.title).toBe('Test Question');
+ expect(model.language).toBe('en');
+ });
+
+ it('returns empty strings for missing meta attributes', () => {
+ const xml =
+ '';
+ const model = parseItem(xml);
+ expect(model.identifier).toBe('');
+ expect(model.title).toBe('');
+ expect(model.language).toBe('');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseItem — interaction blocks
+// ---------------------------------------------------------------------------
+
+describe('parseItem — interaction blocks', () => {
+ it('extracts interactions and their corresponding response declarations', () => {
+ const model = parseItem(VALID_CHOICE_ITEM_DOCUMENT);
+ expect(model.interactions).toHaveLength(1);
+
+ const block = model.interactions[0];
+ expect(block.bodyXml).toContain(' {
+ const model = parseItem(TWO_INTERACTIONS_DOCUMENT);
+ expect(model.interactions).toHaveLength(2);
+ });
+
+ it('returns empty interactions array for an item with no interactions', () => {
+ const model = parseItem(ITEM_NO_INTERACTIONS);
+ expect(model.interactions).toHaveLength(0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseItem — response declaration matching
+// ---------------------------------------------------------------------------
+
+describe('parseItem — response declaration matching', () => {
+ it('matches each interaction to its own response declaration', () => {
+ const model = parseItem(TWO_INTERACTIONS_DOCUMENT);
+ expect(model.interactions[0].responseDeclarations[0]).toContain('identifier="RESP1"');
+ expect(model.interactions[1].responseDeclarations[0]).toContain('identifier="RESP2"');
+ });
+
+ it('returns empty responseDeclarations when no declaration matches', () => {
+ // interaction references RESP-MISSING which has no declaration
+ const xml = `
+
+
+ A
+
+
+ `;
+ const model = parseItem(xml);
+ expect(model.interactions[0].responseDeclarations).toHaveLength(0);
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js
new file mode 100644
index 0000000000..9a44a6eef7
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js
@@ -0,0 +1,74 @@
+import { QTI_INTERACTION_TAGS } from '../constants';
+
+const serializer = new XMLSerializer();
+const parser = new DOMParser();
+
+/**
+ * Parses a QTI XML string into a validated XML Document.
+ *
+ * @param {string} xmlString - Raw QTI XML string
+ * @returns {Document} Parsed XML Document
+ * @throws {Error} If the XML is malformed or contains a parsererror
+ */
+export function parseXML(xmlString) {
+ const doc = parser.parseFromString(xmlString, 'text/xml');
+
+ // DOMParser never throws — it signals failure via a node.
+ const error = doc.querySelector('parsererror');
+ if (error) {
+ throw new Error(`QTI XML parse error: ${error.textContent.trim()}`);
+ }
+
+ return doc;
+}
+
+/**
+ * Parses a raw QTI XML string into the structured ItemModel.
+ *
+ * Each interaction block in the item body becomes one entry in `interactions`.
+ * A response declaration belongs to an interaction when the declaration's
+ * `identifier` matches the interaction's `response-identifier` attribute.
+ *
+ * @param {string} rawData - Raw QTI XML string (the full assessment item XML)
+ * @returns {{
+ * identifier: string,
+ * title: string,
+ * language: string,
+ * interactions: Array<{ bodyXml: string, responseDeclarations: string[] }>
+ * }}
+ */
+export function parseItem(rawData) {
+ const doc = parseXML(rawData);
+
+ const root = doc.querySelector('qti-assessment-item');
+ const identifier = root?.getAttribute('identifier') ?? '';
+ const title = root?.getAttribute('title') ?? '';
+ const language = root?.getAttribute('xml:lang') ?? '';
+
+ const body = doc.querySelector('qti-item-body');
+
+ // Collect all response declarations from the document.
+ const allDeclarations = [...doc.querySelectorAll('qti-response-declaration')];
+
+ const interactions = [];
+
+ if (body) {
+ const selector = QTI_INTERACTION_TAGS.join(', ');
+ const interactionEls = [...body.querySelectorAll(selector)];
+
+ for (const el of interactionEls) {
+ const responseId = el.getAttribute('response-identifier');
+
+ const responseDeclarations = allDeclarations
+ .filter(d => d.getAttribute('identifier') === responseId)
+ .map(d => serializer.serializeToString(d));
+
+ interactions.push({
+ bodyXml: serializer.serializeToString(el),
+ responseDeclarations,
+ });
+ }
+ }
+
+ return { identifier, title, language, interactions };
+}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
new file mode 100644
index 0000000000..7323d4366b
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js
@@ -0,0 +1,90 @@
+// ---------------------------------------------------------------------------
+// Centralized QTI Mock XML Fixtures for Unit Tests
+// ---------------------------------------------------------------------------
+
+export const CHOICE_SINGLE_SELECT_XML = `
+ Which planet is closest to the Sun?
+ Mercury
+ Venus
+ Earth
+`;
+
+export const CHOICE_MULTI_SELECT_XML = `
+ Select all that apply.
+ Option A
+ Option B
+ Option C
+`;
+
+export const CHOICE_NO_PROMPT_XML = `
+ A
+`;
+
+export const UNKNOWN_INTERACTION_XML = `
+ Unknown.
+`;
+
+// ---------------------------------------------------------------------------
+// Full QTI Assessment Item XML Documents
+// ---------------------------------------------------------------------------
+
+export const VALID_CHOICE_ITEM_DOCUMENT = `
+
+
+
+ choice-a
+
+
+
+
+
+ Pick one.
+ A
+ B
+
+
+`;
+
+export const TWO_INTERACTIONS_DOCUMENT = `
+
+
+
+
+
+ Intro text
+
+ Question 1
+
+ Middle text
+
+
+`;
+
+/**
+ * Wraps a snippet of interaction XML into a mock 'block' object
+ * simulating the output of useQtiItem()
+ * @param {string} bodyXml
+ * @returns {object}
+ */
+export const mockInteractionBlock = bodyXml => ({
+ bodyXml,
+ responseDeclarations: [],
+});