diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue b/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue index bb11ec769f..30073a6634 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue @@ -3,13 +3,13 @@
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 @@
-
-

+

+ +

{{ 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: [], +});