From 4554bc082a15d9f88c393b3ea80c4d4396701cad Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Wed, 17 Jun 2026 23:36:47 +0530 Subject: [PATCH 1/3] feat: implement QTI interaction registry, descriptor validation, and XML parsing logic for the QTI Editor. Signed-off-by: Abhishek-Punhani --- .../channelEdit/pages/QTIDemoPage.vue | 30 ++-- .../frontend/channelEdit/pages/qtiDemoData.js | 76 ++++++++++ .../__tests__/InteractionSection.spec.js | 57 ++++++++ .../components/InteractionSection/index.vue | 52 +++++++ .../__tests__/QTIItemEditor.spec.js | 4 +- .../components/QTIItemEditor/index.vue | 70 +++++++-- .../useInteractionDescriptor.spec.js | 132 +++++++++++++++++ .../composables/useInteractionDescriptor.js | 49 +++++++ .../views/QTIEditor/composables/useQtiItem.js | 39 +++++ .../shared/views/QTIEditor/constants.js | 21 ++- .../__tests__/defineInteraction.spec.js | 55 +++++++ .../choice/ChoiceInteractionEditor.vue | 134 ++++++++++++++++++ .../__tests__/ChoiceInteractionEditor.spec.js | 108 ++++++++++++++ .../QTIEditor/interactions/choice/index.js | 66 +++++++++ .../interactions/defineInteraction.js | 34 +++++ .../views/QTIEditor/interactions/index.js | 23 +++ .../views/QTIEditor/qtiEditorStrings.js | 19 ++- .../serialization/__tests__/parseItem.spec.js | 112 +++++++++++++++ .../QTIEditor/serialization/parseItem.js | 73 ++++++++++ .../views/QTIEditor/utils/testingFixtures.js | 90 ++++++++++++ 20 files changed, 1209 insertions(+), 35 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/defineInteraction.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue b/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue index bb11ec769f..20b0828e1d 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,26 +28,36 @@ 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..fcee2e0f24 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 @@ -28,9 +28,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', () => { 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..ce71a4b5ae 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,17 @@
-
-

+

+ +

{{ questionContentPlaceholder$() }}

@@ -55,10 +61,14 @@ import { computed } from 'vue'; import { qtiEditorStrings } from '../../qtiEditorStrings'; import { QtiInteraction } from '../../constants'; + import useInteractionDescriptor from '../../composables/useInteractionDescriptor'; + import useQtiItem from '../../composables/useQtiItem'; + import InteractionSection from '../InteractionSection/index.vue'; - // QTI XML element name → i18n string key, used to build closed-card labels. + // QTI interaction tag name → i18n string key, used for closed-card labels + // on items that have no raw_data yet (blank new items). const INTERACTION_TYPE_STRING_KEY = { - [QtiInteraction.CHOICE]: 'interactionTypeChoice', + [QtiInteraction.CHOICE]: 'interactionTypeSingleChoice', // defaults to single choice if no XML yet [QtiInteraction.ORDER]: 'interactionTypeOrder', [QtiInteraction.MATCH]: 'interactionTypeMatch', [QtiInteraction.TEXT_ENTRY]: 'interactionTypeTextEntry', @@ -68,6 +78,8 @@ export default { name: 'QTIItemEditor', + components: { InteractionSection }, + setup(props) { const { questionNumberLabel$, @@ -77,6 +89,8 @@ interactionTypeUnknown$, } = qtiEditorStrings; + const { interactions } = useQtiItem(props.item.raw_data); + const questionNumberLabel = computed(() => questionNumberLabel$({ number: props.index + 1, @@ -84,17 +98,40 @@ }), ); - const questionNumberAndTypeLabel = computed(() => { + const firstBlockXml = computed(() => + interactions.value.length > 0 ? interactions.value[0].bodyXml : null, + ); + const { descriptor, questionType } = useInteractionDescriptor(firstBlockXml); + + /** + * Derives the type label for the closed-card header. + * When raw_data is present: parses the first interaction's bodyXml and uses + * the matching descriptor's label — this is the source of truth from the XML. + * When raw_data is absent (blank new items): falls back to item.type enum lookup. + */ + const interactionTypeLabel = computed(() => { + if (firstBlockXml.value) { + if (descriptor.value?.type === QtiInteraction.CHOICE) { + return questionType.value === 'singleSelect' + ? qtiEditorStrings.interactionTypeSingleChoice$() + : qtiEditorStrings.interactionTypeMultipleChoice$(); + } + return descriptor.value ? descriptor.value.label : interactionTypeUnknown$(); + } const typeKey = INTERACTION_TYPE_STRING_KEY[props.item.type]; - const typeLabel = typeKey ? qtiEditorStrings[`${typeKey}$`]() : interactionTypeUnknown$(); - return questionNumberAndTypeLabel$({ + return typeKey ? qtiEditorStrings[`${typeKey}$`]() : interactionTypeUnknown$(); + }); + + const questionNumberAndTypeLabel = computed(() => + questionNumberAndTypeLabel$({ number: props.index + 1, total: props.total, - type: typeLabel, - }); - }); + type: interactionTypeLabel.value, + }), + ); return { + interactions, questionNumberLabel, questionNumberAndTypeLabel, closeBtnLabel$, @@ -103,7 +140,10 @@ }, props: { - /** Assessment item: { id, type (QtiInteraction value), title } */ + /** + * Assessment item: { id, type (QtiInteraction value), title, raw_data? } + * raw_data is the full QTI XML string; absent on blank newly-created items. + */ item: { type: Object, required: true, @@ -124,7 +164,7 @@ default: 'view', validator: val => ['view', 'edit'].includes(val), }, - /** Whether to show answers previews for closed items */ + /** Whether to show answer previews for closed items */ displayAnswersPreview: { type: Boolean, default: false, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js new file mode 100644 index 0000000000..c8037c5863 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js @@ -0,0 +1,132 @@ +import { render } from '@testing-library/vue'; +import { defineComponent, ref, nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import useInteractionDescriptor from '../useInteractionDescriptor'; +import { QtiInteraction } from '../../constants'; + +import { + CHOICE_SINGLE_SELECT_XML, + CHOICE_MULTI_SELECT_XML, + UNKNOWN_INTERACTION_XML, +} from '../../utils/testingFixtures'; + +// --------------------------------------------------------------------------- +// Helper: renders a wrapper component that runs the composable inside setup() +// Returns { result, bodyXmlRef } — result holds the reactive return value, +// bodyXmlRef can be mutated to test reactivity. +// --------------------------------------------------------------------------- + +function renderDescriptor(initialXml = null) { + const bodyXmlRef = ref(initialXml); + let result; + + const TestWrapper = defineComponent({ + setup() { + result = useInteractionDescriptor(bodyXmlRef); + return {}; + }, + template: '
', + }); + + render(TestWrapper, { routes: new VueRouter() }); + return { result, bodyXmlRef }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useInteractionDescriptor', () => { + describe('with a valid choice interaction', () => { + it('resolves the Choice descriptor by its type', () => { + const { result } = renderDescriptor(CHOICE_SINGLE_SELECT_XML); + expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE); + }); + + it('resolves questionType as singleSelect when max-choices is 1', () => { + const { result } = renderDescriptor(CHOICE_SINGLE_SELECT_XML); + expect(result.questionType.value).toBe('singleSelect'); + }); + + it('resolves questionType as multiSelect when max-choices > 1', () => { + const { result } = renderDescriptor(CHOICE_MULTI_SELECT_XML); + expect(result.questionType.value).toBe('multiSelect'); + }); + + it('returns null parseError for valid XML', () => { + const { result } = renderDescriptor(CHOICE_SINGLE_SELECT_XML); + expect(result.parseError.value).toBeNull(); + }); + }); + + describe('with an unrecognized interaction type', () => { + it('falls back to the default descriptor without a parse error', () => { + const { result } = renderDescriptor(UNKNOWN_INTERACTION_XML); + expect(result.parseError.value).toBeNull(); + }); + + it('still returns a defined fallback descriptor', () => { + const { result } = renderDescriptor(UNKNOWN_INTERACTION_XML); + expect(result.descriptor.value).toBeDefined(); + expect(typeof result.descriptor.value.matches).toBe('function'); + }); + }); + + describe('with a null or empty bodyXmlRef', () => { + it('returns a defined descriptor when bodyXmlRef is null', () => { + const { result } = renderDescriptor(null); + expect(result.descriptor.value).toBeDefined(); + expect(result.parseError.value).toBeNull(); + }); + + it('returns null questionType when bodyXmlRef is null', () => { + const { result } = renderDescriptor(null); + expect(result.questionType.value).toBeNull(); + }); + + it('returns a defined descriptor when bodyXmlRef is an empty string', () => { + const { result } = renderDescriptor(''); + expect(result.descriptor.value).toBeDefined(); + expect(result.parseError.value).toBeNull(); + }); + }); + + describe('with malformed XML', () => { + it('returns a non-null parseError', () => { + const { result } = renderDescriptor(' { + const { result } = renderDescriptor(' { + it('recomputes questionType when bodyXmlRef changes from null to single-select', async () => { + const { result, bodyXmlRef } = renderDescriptor(null); + await nextTick(); + + expect(result.questionType.value).toBeNull(); + + bodyXmlRef.value = CHOICE_SINGLE_SELECT_XML; + await nextTick(); + + expect(result.questionType.value).toBe('singleSelect'); + }); + + it('recomputes questionType when switching from single-select to multi-select', async () => { + const { result, bodyXmlRef } = renderDescriptor(CHOICE_SINGLE_SELECT_XML); + await nextTick(); + + expect(result.questionType.value).toBe('singleSelect'); + + bodyXmlRef.value = CHOICE_MULTI_SELECT_XML; + await nextTick(); + + expect(result.questionType.value).toBe('multiSelect'); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js new file mode 100644 index 0000000000..0f53efe566 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js @@ -0,0 +1,49 @@ +import { computed } from 'vue'; +import { parseXML } from '../serialization/parseItem'; +import { descriptors, registry, DEFAULT_INTERACTION } from '../interactions/index'; + +/** + * Composable that analyzes a QTI interaction block's XML and resolves the + * appropriate plugin descriptor and sub-question type. + * + * @param {import('vue').Ref} bodyXmlRef Ref to interaction's bodyXml string + */ +export default function useInteractionDescriptor(bodyXmlRef) { + const parsed = computed(() => { + if (!bodyXmlRef.value) { + return { + error: null, + descriptor: registry[DEFAULT_INTERACTION], + questionType: null, + }; + } + + try { + // bodyXml is the full element — its root IS the interaction. + const doc = parseXML(bodyXmlRef.value); + const interactionEl = doc.documentElement; + + const descriptor = + descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION]; + const questionType = descriptor.getQuestionType(interactionEl) ?? null; + + return { + error: null, + descriptor, + questionType, + }; + } catch (e) { + return { + error: e.message, + descriptor: registry[DEFAULT_INTERACTION], + questionType: null, + }; + } + }); + + return { + descriptor: computed(() => parsed.value.descriptor), + questionType: computed(() => parsed.value.questionType), + parseError: computed(() => parsed.value.error), + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js new file mode 100644 index 0000000000..2ca1580dc7 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useQtiItem.js @@ -0,0 +1,39 @@ +import { ref } from 'vue'; +import { parseItem } from '../serialization/parseItem'; + +/** + * Composable that parses a raw QTI XML string once and exposes the + * structured item model as reactive refs. + * + * Scope: read / parse only. No dirty tracking, no XML assembly, no emit-up. + * + * @param {string | null | undefined} rawData - Raw QTI XML string from item.raw_data + * @returns {{ + * identifier: import('vue').Ref, + * title: import('vue').Ref, + * language: import('vue').Ref, + * interactions: import('vue').Ref>, + * parseError: import('vue').Ref, + * }} + */ +export default function useQtiItem(rawData) { + const identifier = ref(''); + const title = ref(''); + const language = ref(''); + const interactions = ref([]); + const parseError = ref(null); + + if (rawData) { + try { + const model = parseItem(rawData); + identifier.value = model.identifier; + title.value = model.title; + language.value = model.language; + interactions.value = model.interactions; + } catch (e) { + parseError.value = e.message; + } + } + + return { identifier, title, language, interactions, parseError }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 76e313f8c9..9cd468344e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -19,10 +19,21 @@ export const BaseType = Object.freeze({ URI: 'uri', }); +/** + * QTI 3.0 interaction type identifiers. + * Values are the actual XML element tag names used in QTI 3.0 documents, + * so they serve as both type keys and CSS selectors for querySelectorAll. + */ export const QtiInteraction = Object.freeze({ - CHOICE: 'choiceInteraction', - ORDER: 'orderInteraction', - MATCH: 'matchInteraction', - TEXT_ENTRY: 'textEntryInteraction', - EXTENDED_TEXT: 'extendedTextInteraction', + CHOICE: 'qti-choice-interaction', + ORDER: 'qti-order-interaction', + MATCH: 'qti-match-interaction', + TEXT_ENTRY: 'qti-text-entry-interaction', + EXTENDED_TEXT: 'qti-extended-text-interaction', }); + +/** + * Selector-ready list of all known QTI interaction element tag names. + * Derived directly from QtiInteraction so there is a single source of truth. + */ +export const QTI_INTERACTION_TAGS = Object.freeze(Object.values(QtiInteraction)); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js new file mode 100644 index 0000000000..6b47242d2b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js @@ -0,0 +1,55 @@ +import defineInteraction from '../defineInteraction'; + +// A minimal valid descriptor with all required keys present. +const makeValidDescriptor = (overrides = {}) => ({ + type: 'test', + placement: 'block', + label: 'Test', + editorComponent: {}, + convertsFrom: [], + matches: () => false, + getQuestionType: () => null, + parse: () => ({}), + validate: () => [], + ...overrides, +}); + +describe('defineInteraction', () => { + it('returns the descriptor unchanged when all required keys are present', () => { + const descriptor = makeValidDescriptor(); + expect(defineInteraction(descriptor)).toBe(descriptor); + }); + + const REQUIRED_KEYS = [ + 'type', + 'placement', + 'label', + 'editorComponent', + 'convertsFrom', + 'matches', + 'getQuestionType', + 'parse', + 'validate', + ]; + + it.each(REQUIRED_KEYS)('throws when the required key "%s" is missing', key => { + const descriptor = makeValidDescriptor(); + delete descriptor[key]; + expect(() => defineInteraction(descriptor)).toThrow( + new RegExp(`missing required key "${key}"`, 'i'), + ); + }); + + it('includes the descriptor type in the error message when type is present', () => { + const descriptor = makeValidDescriptor({ type: 'myPlugin' }); + delete descriptor.editorComponent; + expect(() => defineInteraction(descriptor)).toThrow(/myPlugin/); + }); + + it('uses "(unknown)" in the error message when type is also missing', () => { + const descriptor = makeValidDescriptor(); + delete descriptor.type; + delete descriptor.editorComponent; + expect(() => defineInteraction(descriptor)).toThrow(/\(unknown\)/); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue new file mode 100644 index 0000000000..807c9e992e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue @@ -0,0 +1,134 @@ + + + + + + + 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..90fba8ddb9 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/ChoiceInteractionEditor.spec.js @@ -0,0 +1,108 @@ +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'; + +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({ block: block(CHOICE_SINGLE_SELECT_XML), questionType: 'singleSelect' }); + expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument(); + }); + + it('renders no prompt element when the XML has no ', () => { + renderEditor({ block: block(CHOICE_NO_PROMPT_XML), questionType: 'singleSelect' }); + expect(screen.queryByText('Which planet is closest to the Sun?')).not.toBeInTheDocument(); + }); + }); + + describe('singleSelect (KRadioButton)', () => { + it('renders a radio button for each choice', () => { + renderEditor({ block: block(CHOICE_SINGLE_SELECT_XML), questionType: 'singleSelect' }); + const radios = screen.getAllByRole('radio'); + expect(radios).toHaveLength(3); + }); + + it('renders the correct choice labels', () => { + renderEditor({ block: block(CHOICE_SINGLE_SELECT_XML), questionType: 'singleSelect' }); + expect(screen.getByText('Mercury')).toBeInTheDocument(); + expect(screen.getByText('Venus')).toBeInTheDocument(); + expect(screen.getByText('Earth')).toBeInTheDocument(); + }); + + it('allows selecting a radio button', async () => { + renderEditor({ block: block(CHOICE_SINGLE_SELECT_XML), questionType: 'singleSelect' }); + 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({ block: block(CHOICE_MULTI_SELECT_XML), questionType: 'multiSelect' }); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(3); + }); + + it('renders the correct choice labels', () => { + renderEditor({ block: block(CHOICE_MULTI_SELECT_XML), questionType: 'multiSelect' }); + 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({ block: block(CHOICE_MULTI_SELECT_XML), questionType: 'multiSelect' }); + 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({ block: 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({ block: block(''), questionType: 'singleSelect' }); + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + }); + + it('renders nothing interactive when XML is malformed', () => { + renderEditor({ block: 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', + + /** Display label used by the (future) type selector UI. */ + get label() { + return qtiEditorStrings.choiceInteractionLabel$(); + }, + + /** Vue component rendered inside InteractionSection when this descriptor owns the block. */ + editorComponent: ChoiceInteractionEditor, + + /** + * Types this plugin can absorb when the author switches interaction type. + * Populated in a future task — empty for now. + */ + 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 {'singleSelect' | 'multiSelect'} + */ + getQuestionType(el) { + return el.getAttribute('max-choices') === '1' ? 'singleSelect' : 'multiSelect'; + }, + + /** + * 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..dc60d58dbb --- /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', + 'label', + '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..ff53a4e14f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -29,9 +29,13 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Show answers', context: 'Checkbox label to toggle displaying answers/previews', }, - interactionTypeChoice: { - message: 'Choice', - context: 'Display name for choiceInteraction', + interactionTypeSingleChoice: { + message: 'Single Choice', + context: 'Display name for single-select choice interaction', + }, + interactionTypeMultipleChoice: { + message: 'Multiple Choice', + context: 'Display name for multi-select choice interaction', }, interactionTypeOrder: { message: 'Order', @@ -77,4 +81,13 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Add question below', context: 'Action to add a new question below the current one', }, + choiceInteractionLabel: { + message: 'Choice', + context: 'Label for the choice interaction plugin, shown in the type selector', + }, + choiceEditorPlaceholder: { + message: 'Choice interaction editor — {questionType} (coming soon)', + context: + 'Placeholder inside the choice interaction editor card while the real editor is being built', + }, }); 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..b6a5269f2f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -0,0 +1,73 @@ +import { QTI_INTERACTION_TAGS } from '../constants'; + +const serializer = new XMLSerializer(); + +/** + * 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 = new DOMParser().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 ? (root.getAttribute('identifier') ?? '') : ''; + const title = root ? (root.getAttribute('title') ?? '') : ''; + const language = root ? (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: [], +}); From 138d2b24490a84de2d70caffc796feb0951a8241 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Fri, 19 Jun 2026 01:15:24 +0530 Subject: [PATCH 2/3] refactor: standardize assessment item schema and rename preview toggle to showAnswers in QTIEditor Signed-off-by: Abhishek-Punhani --- .../channelEdit/pages/QTIDemoPage.vue | 22 ++++---- .../frontend/channelEdit/pages/qtiDemoData.js | 25 +++++++++ .../__tests__/InteractionSection.spec.js | 14 ++--- .../components/InteractionSection/index.vue | 26 ++++++++-- .../__tests__/QTIItemEditor.spec.js | 12 ++--- .../components/QTIItemEditor/index.vue | 51 ++++++------------- .../useInteractionDescriptor.spec.js | 12 ++--- .../shared/views/QTIEditor/constants.js | 39 ++++++++++++-- .../frontend/shared/views/QTIEditor/index.vue | 32 ++++++------ .../__tests__/defineInteraction.spec.js | 2 - .../choice/ChoiceInteractionEditor.vue | 22 +++++--- .../__tests__/ChoiceInteractionEditor.spec.js | 47 +++++++++++++---- .../QTIEditor/interactions/choice/index.js | 16 +++--- .../interactions/defineInteraction.js | 1 - .../views/QTIEditor/qtiEditorStrings.js | 9 ---- .../QTIEditor/serialization/parseItem.js | 9 ++-- 16 files changed, 201 insertions(+), 138 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue b/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue index 20b0828e1d..30073a6634 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue @@ -30,7 +30,7 @@ import { ref, defineComponent } from 'vue'; import { CHOICE_ITEM_XML, MULTI_CHOICE_ITEM_XML } from './qtiDemoData'; import QTIEditor from 'shared/views/QTIEditor/index'; - import { QtiInteraction } from 'shared/views/QTIEditor/constants'; + import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants'; /** * Hardcoded items covering different states: @@ -40,26 +40,22 @@ */ const INITIAL_ASSESSMENTS = [ { - id: 'demo-item-1', - type: QtiInteraction.CHOICE, - title: 'Which planet is closest to the Sun?', + assessment_id: 'demo-item-1', + type: AssessmentItemTypes.QTI, raw_data: CHOICE_ITEM_XML, }, { - id: 'demo-item-2', - type: QtiInteraction.CHOICE, - title: 'Select all the prime numbers.', + assessment_id: 'demo-item-2', + type: AssessmentItemTypes.QTI, raw_data: MULTI_CHOICE_ITEM_XML, }, { - id: 'demo-item-3', - type: QtiInteraction.EXTENDED_TEXT, - title: 'Describe the water cycle in your own words.', + assessment_id: 'demo-item-3', + type: AssessmentItemTypes.QTI, }, { - id: 'demo-item-4', - type: QtiInteraction.ORDER, - title: 'Arrange these events in chronological order.', + assessment_id: 'demo-item-4', + type: AssessmentItemTypes.QTI, }, ]; diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js index dbf1e1dbd3..08eefe72ca 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js @@ -1,3 +1,5 @@ +import { AssessmentItemTypes } from 'shared/views/QTIEditor/constants'; + /** * Demo item 1: a real choice interaction XML so the full load path can * be verified end-to-end (parseItem → useQtiItem → InteractionSection → @@ -74,3 +76,26 @@ export const MULTI_CHOICE_ITEM_XML = `
`; + +/** + * Hardcoded items covering different states: + * - item-1: has raw_data (real QTI XML) → exercises the full load path + * - item-2: no raw_data → shows placeholder (blank new item state) + * - item-3: no raw_data → shows placeholder + */ +export const INITIAL_ASSESSMENTS = [ + { + assessment_id: 'demo-item-1', + type: AssessmentItemTypes.QTI, + raw_data: CHOICE_ITEM_XML, + }, + { + assessment_id: 'demo-item-2', + type: AssessmentItemTypes.QTI, + raw_data: MULTI_CHOICE_ITEM_XML, + }, + { + assessment_id: 'demo-item-3', + type: AssessmentItemTypes.QTI, + }, +]; 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 97259f28bb..663921317d 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 @@ -5,7 +5,7 @@ import InteractionSection from '../index.vue'; import { CHOICE_SINGLE_SELECT_XML, UNKNOWN_INTERACTION_XML, - mockInteractionBlock as block, + mockInteractionBlock as interactionBlock, } from '../../../utils/testingFixtures'; const renderSection = (props = {}) => @@ -21,18 +21,18 @@ const renderSection = (props = {}) => describe('InteractionSection', () => { describe('choice interaction', () => { it('renders the prompt from the XML via ChoiceInteractionEditor', () => { - renderSection({ block: block(CHOICE_SINGLE_SELECT_XML) }); + renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) }); expect(screen.getByText('Which planet is closest to the Sun?')).toBeInTheDocument(); }); it('renders radio buttons for a single-select choice interaction', () => { - renderSection({ block: block(CHOICE_SINGLE_SELECT_XML) }); + renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) }); const radios = screen.getAllByRole('radio'); expect(radios).toHaveLength(3); }); it('renders the choice labels', () => { - renderSection({ block: block(CHOICE_SINGLE_SELECT_XML) }); + renderSection({ interaction: interactionBlock(CHOICE_SINGLE_SELECT_XML) }); expect(screen.getByText('Mercury')).toBeInTheDocument(); expect(screen.getByText('Venus')).toBeInTheDocument(); }); @@ -40,7 +40,7 @@ describe('InteractionSection', () => { describe('parse error handling', () => { it('shows a parse error message and no interaction when XML is malformed', () => { - renderSection({ block: block('not-xml<{{') }); + renderSection({ interaction: interactionBlock('not-xml<{{') }); expect(screen.queryByRole('radio')).not.toBeInTheDocument(); // At minimum no interactive elements render expect(screen.queryByRole('radio')).not.toBeInTheDocument(); @@ -51,7 +51,9 @@ describe('InteractionSection', () => { describe('unknown interaction type', () => { it('falls back silently when the interaction tag is unrecognized', () => { // Should not throw — just renders the fallback component - expect(() => renderSection({ block: block(UNKNOWN_INTERACTION_XML) })).not.toThrow(); + expect(() => + renderSection({ interaction: interactionBlock(UNKNOWN_INTERACTION_XML) }), + ).not.toThrow(); }); }); }); 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 e73ca5255b..4b9179296b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue @@ -12,8 +12,9 @@ v-else :key="descriptor.type" :questionType="questionType" - :block="block" + :interaction="interaction" :mode="mode" + :showAnswers="showAnswers" />
@@ -22,22 +23,30 @@ 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 fcee2e0f24..111eba79f1 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 @@ -15,7 +15,7 @@ const defaultProps = { index: 0, total: 5, mode: 'view', - displayAnswersPreview: false, + showAnswers: false, }; const renderComponent = (props = {}, slots = {}) => { @@ -57,14 +57,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 ce71a4b5ae..2c8533fecb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -29,9 +29,10 @@

- import { computed } from 'vue'; + import { computed, ref } from 'vue'; import { qtiEditorStrings } from '../../qtiEditorStrings'; - import { QtiInteraction } from '../../constants'; - import useInteractionDescriptor from '../../composables/useInteractionDescriptor'; + import { AssessmentItemTypes, QuestionType } from '../../constants'; import useQtiItem from '../../composables/useQtiItem'; import InteractionSection from '../InteractionSection/index.vue'; - // QTI interaction tag name → i18n string key, used for closed-card labels - // on items that have no raw_data yet (blank new items). - const INTERACTION_TYPE_STRING_KEY = { - [QtiInteraction.CHOICE]: 'interactionTypeSingleChoice', // defaults to single choice if no XML yet - [QtiInteraction.ORDER]: 'interactionTypeOrder', - [QtiInteraction.MATCH]: 'interactionTypeMatch', - [QtiInteraction.TEXT_ENTRY]: 'interactionTypeTextEntry', - [QtiInteraction.EXTENDED_TEXT]: 'interactionTypeExtendedText', - }; - export default { name: 'QTIItemEditor', @@ -98,28 +88,16 @@ }), ); - const firstBlockXml = computed(() => - interactions.value.length > 0 ? interactions.value[0].bodyXml : null, - ); - const { descriptor, questionType } = useInteractionDescriptor(firstBlockXml); + const currentQuestionType = ref(props.item.type || AssessmentItemTypes.QTI); - /** - * Derives the type label for the closed-card header. - * When raw_data is present: parses the first interaction's bodyXml and uses - * the matching descriptor's label — this is the source of truth from the XML. - * When raw_data is absent (blank new items): falls back to item.type enum lookup. - */ const interactionTypeLabel = computed(() => { - if (firstBlockXml.value) { - if (descriptor.value?.type === QtiInteraction.CHOICE) { - return questionType.value === 'singleSelect' - ? qtiEditorStrings.interactionTypeSingleChoice$() - : qtiEditorStrings.interactionTypeMultipleChoice$(); - } - return descriptor.value ? descriptor.value.label : interactionTypeUnknown$(); + if (currentQuestionType.value === QuestionType.SINGLE_SELECT) { + return qtiEditorStrings.interactionTypeSingleChoice$(); + } + if (currentQuestionType.value === QuestionType.MULTI_SELECT) { + return qtiEditorStrings.interactionTypeMultipleChoice$(); } - const typeKey = INTERACTION_TYPE_STRING_KEY[props.item.type]; - return typeKey ? qtiEditorStrings[`${typeKey}$`]() : interactionTypeUnknown$(); + return interactionTypeUnknown$(); }); const questionNumberAndTypeLabel = computed(() => @@ -131,6 +109,7 @@ ); return { + currentQuestionType, interactions, questionNumberLabel, questionNumberAndTypeLabel, @@ -141,7 +120,7 @@ props: { /** - * Assessment item: { id, type (QtiInteraction value), title, raw_data? } + * Assessment item: { assessment_id, type, raw_data? } * raw_data is the full QTI XML string; absent on blank newly-created items. */ item: { @@ -165,7 +144,7 @@ validator: val => ['view', 'edit'].includes(val), }, /** Whether to show answer previews for closed items */ - displayAnswersPreview: { + showAnswers: { type: Boolean, default: false, }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js index c8037c5863..b091e5378d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js @@ -2,7 +2,7 @@ import { render } from '@testing-library/vue'; import { defineComponent, ref, nextTick } from 'vue'; import VueRouter from 'vue-router'; import useInteractionDescriptor from '../useInteractionDescriptor'; -import { QtiInteraction } from '../../constants'; +import { QtiInteraction, QuestionType } from '../../constants'; import { CHOICE_SINGLE_SELECT_XML, @@ -45,12 +45,12 @@ describe('useInteractionDescriptor', () => { it('resolves questionType as singleSelect when max-choices is 1', () => { const { result } = renderDescriptor(CHOICE_SINGLE_SELECT_XML); - expect(result.questionType.value).toBe('singleSelect'); + expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT); }); it('resolves questionType as multiSelect when max-choices > 1', () => { const { result } = renderDescriptor(CHOICE_MULTI_SELECT_XML); - expect(result.questionType.value).toBe('multiSelect'); + expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT); }); it('returns null parseError for valid XML', () => { @@ -114,19 +114,19 @@ describe('useInteractionDescriptor', () => { bodyXmlRef.value = CHOICE_SINGLE_SELECT_XML; await nextTick(); - expect(result.questionType.value).toBe('singleSelect'); + expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT); }); it('recomputes questionType when switching from single-select to multi-select', async () => { const { result, bodyXmlRef } = renderDescriptor(CHOICE_SINGLE_SELECT_XML); await nextTick(); - expect(result.questionType.value).toBe('singleSelect'); + expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT); bodyXmlRef.value = CHOICE_MULTI_SELECT_XML; await nextTick(); - expect(result.questionType.value).toBe('multiSelect'); + expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT); }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 9cd468344e..e9ba95c570 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -19,6 +19,26 @@ export const BaseType = Object.freeze({ URI: 'uri', }); +/** + * There are three distinct type concepts used within the QTI architecture: + * + * 1. AssessmentItemType (AssessmentItemTypes) -> The type stored in the database. + * Values representing how the backend and legacy code interpret items. For + * all QTI 3.0 items, this will be AssessmentItemTypes.QTI. + * + * 2. QuestionType -> The type editors will select per assessment item. + * It's different from AssessmentItemType because we will extend this for all + * new question types without confusing it with values stored in the database + * (all of these will be assessment item type: "qti"). Value is related to how + * Studio presents different question options to users in the UI. + * + * 3. InteractionType (QtiInteraction) -> The actual interactions defined by QTI, + * and the ones that dictate how to parse and what descriptor we will use. + * Each QTI interaction can have multiple related question types (e.g., choice + * can be singleSelect or multiSelect), but all of them will have assessment + * item type "qti". + */ + /** * QTI 3.0 interaction type identifiers. * Values are the actual XML element tag names used in QTI 3.0 documents, @@ -32,8 +52,19 @@ export const QtiInteraction = Object.freeze({ EXTENDED_TEXT: 'qti-extended-text-interaction', }); -/** - * Selector-ready list of all known QTI interaction element tag names. - * Derived directly from QtiInteraction so there is a single source of truth. - */ export const QTI_INTERACTION_TAGS = Object.freeze(Object.values(QtiInteraction)); + +export const AssessmentItemTypes = Object.freeze({ + SINGLE_SELECTION: 'single_selection', + MULTIPLE_SELECTION: 'multiple_selection', + TRUE_FALSE: 'true_false', + INPUT_QUESTION: 'input_question', + PERSEUS_QUESTION: 'perseus_question', + FREE_RESPONSE: 'free_response', + QTI: 'qti', +}); + +export const QuestionType = Object.freeze({ + SINGLE_SELECT: 'single_selection', + MULTI_SELECT: 'multiple_selection', +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index 0d2e34e32b..34f4d58f92 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -9,7 +9,7 @@ >

@@ -61,7 +61,7 @@ import { ref, computed } from 'vue'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import { qtiEditorStrings } from './qtiEditorStrings'; - import { QtiInteraction } from './constants'; + import { AssessmentItemTypes } from './constants'; import QTIItemEditor from './components/QTIItemEditor/index'; import CollapsibleToolbar from './components/CollapsibleToolbar/index.vue'; import useQTIEditorActions from './useQTIEditorActions'; @@ -74,9 +74,8 @@ /** Creates a blank item with a stable UUID and the default interaction type. */ function createBlankItem() { return { - id: uuid4(), - type: QtiInteraction.CHOICE, - title: '', + assessment_id: uuid4(), + type: AssessmentItemTypes.QTI, }; } @@ -97,7 +96,7 @@ const items = computed(() => props.assessments); const activeId = ref(null); - const displayAnswersPreview = ref(false); + const showAnswers = ref(false); function openItem(id) { activeId.value = id; @@ -118,15 +117,14 @@ const pos = atIndex !== undefined ? atIndex : list.length; list.splice(pos, 0, newItem); emit('update', list); - // open the newly created card - activeId.value = newItem.id; + activeId.value = newItem.assessment_id; } function deleteItem(item) { - if (activeId.value === item.id) closeItem(); + if (activeId.value === item.assessment_id) closeItem(); emit( 'update', - props.assessments.filter(i => i.id !== item.id), + props.assessments.filter(i => i.assessment_id !== item.assessment_id), ); } @@ -161,7 +159,7 @@ containerStyle, items, activeId, - displayAnswersPreview, + showAnswers, closeItem, addItem, getToolbarActions, @@ -174,9 +172,9 @@ props: { /** * Ordered list of assessment items. Each item must have: - * id {String} — stable unique identifier (UUID) - * type {String} — a QtiInteraction value - * title {String} — optional display title + * assessment_id {String} — stable unique identifier (UUID) + * type {String} — e.g., AssessmentItemTypes.QTI + * raw_data {String} — optional, full QTI XML string * * Array index is the display order. * This component never mutates the prop — it emits `update` with the new list. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js index 6b47242d2b..7380bc5957 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js @@ -4,7 +4,6 @@ import defineInteraction from '../defineInteraction'; const makeValidDescriptor = (overrides = {}) => ({ type: 'test', placement: 'block', - label: 'Test', editorComponent: {}, convertsFrom: [], matches: () => false, @@ -23,7 +22,6 @@ describe('defineInteraction', () => { const REQUIRED_KEYS = [ 'type', 'placement', - 'label', 'editorComponent', 'convertsFrom', 'matches', 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 807c9e992e..d8d545cc39 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue @@ -10,10 +10,10 @@ {{ prompt }}

- -