From f66667047bad6fda443adae932dce3c59a586164 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Thu, 23 Jul 2026 00:31:38 +0530 Subject: [PATCH 1/3] feat(QTIEditor): add text-entry interaction support - Add TextEntryInteraction component with support for numeric, string, and free-response question types - Create useTextEntryInteraction composable for managing text-entry state and validation - Add TextEntryInteractionDescriptor to define interaction metadata and parsing rules - Implement parse.js and validation.js utilities for text-entry specific logic - Add comprehensive test coverage for TextEntryEditor, parsing, and validation - Extend useInteractionDescriptor to support text-entry interaction type - Add math utilities for numeric answer evaluation - Expand qtiDemoData with four new demo items: numeric, text-entry, and free-response questions - Update QTIDemoPage to reference demo items from qtiDemoData module - Update QTIItemEditor to handle text-entry interaction rendering - Register TextEntryInteraction in interactions index Signed-off-by: Abhishek-Punhani --- .../channelEdit/pages/QTIDemoPage.vue | 30 +- .../frontend/channelEdit/pages/qtiDemoData.js | 115 +++- .../__tests__/QTIItemEditor.spec.js | 2 + .../components/QTIItemEditor/index.vue | 20 +- .../useInteractionDescriptor.spec.js | 50 +- .../__tests__/useTextEntryInteraction.spec.js | 142 +++++ .../composables/useInteractionDescriptor.js | 19 +- .../composables/useTextEntryInteraction.js | 87 +++ .../shared/views/QTIEditor/constants.js | 5 + .../views/QTIEditor/interactions/index.js | 3 +- .../textEntry/TextEntryEditor.vue | 575 ++++++++++++++++++ .../TextEntryInteractionDescriptor.js | 115 ++++ .../__tests__/TextEntryEditor.spec.js | 284 +++++++++ .../textEntry/__tests__/parse.spec.js | 345 +++++++++++ .../textEntry/__tests__/validation.spec.js | 171 ++++++ .../QTIEditor/interactions/textEntry/index.js | 25 + .../QTIEditor/interactions/textEntry/parse.js | 239 ++++++++ .../interactions/textEntry/validation.js | 49 ++ .../views/QTIEditor/qtiEditorStrings.js | 61 +- .../QTIEditor/serialization/parseItem.js | 13 +- .../shared/views/QTIEditor/utils/math.js | 8 + .../views/QTIEditor/utils/testingFixtures.js | 13 + 22 files changed, 2311 insertions(+), 60 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/math.js diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue b/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue index 30073a6634..9cc15f4aa5 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/QTIDemoPage.vue @@ -28,36 +28,8 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js new file mode 100644 index 0000000000..befe553d99 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js @@ -0,0 +1,115 @@ +import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { parseTextEntryInteraction, buildTextEntryInteractionXML } from './parse'; +import { validateTextEntryInteraction } from './validation'; + +/** + * Owns all text-entry-specific interaction logic: schema, parse, buildXML, validate. + * + * placement: 'inline' — signals to parseItem that the whole + * should be passed as bodyXml rather than just the interaction element, so + * parse() can recover the prompt from body siblings. + */ +export class TextEntryInteractionDescriptor { + constructor() { + this.type = QtiInteraction.TEXT_ENTRY; + this.placement = 'inline'; + this.questionTypes = [ + QuestionType.NUMERIC, + QuestionType.TEXT_ENTRY, + QuestionType.FREE_RESPONSE, + ]; + this.editorComponent = null; + this.convertsFrom = []; + } + + /** @param {Element} el */ + matches(el) { + return el.tagName.toLowerCase() === QtiInteraction.TEXT_ENTRY; + } + + /** + * Reads base-type from the response declaration to determine question type. + * + * @param {Element} _el - unused; present to match the descriptor interface + * @param {string[]} [responseDeclarations] + * @returns {string} + */ + getQuestionType(_el, responseDeclarations = []) { + if (!responseDeclarations.length) return QuestionType.FREE_RESPONSE; + + try { + const doc = new DOMParser().parseFromString(responseDeclarations[0], 'text/html'); + // In text/html, the root is usually doc.body.firstElementChild + const root = doc.body.firstElementChild ?? doc.body; + const baseType = root.getAttribute('base-type'); + + if (baseType === BaseType.FLOAT) return QuestionType.NUMERIC; + + // base-type string: if a is present the author + // expects a specific answer (TEXT_ENTRY); otherwise it is open-ended. + const hasCorrectResponse = !!root.querySelector('qti-correct-response'); + return hasCorrectResponse ? QuestionType.TEXT_ENTRY : QuestionType.FREE_RESPONSE; + } catch { + return QuestionType.FREE_RESPONSE; + } + } + + /** + * Returns the response declaration schema for the given question type. + * + * For numeric, cardinality is derived from the current answer count so the + * declaration stays correct as answers are added/removed. + * + * @param {string} questionType + * @param {TextEntryState|null} [state] + * @returns {{ baseType: string, cardinality: string }} + */ + getResponseDeclarationSchema(questionType, state = null) { + // Both freeResponse and textEntry use base-type string, cardinality single. + // The presence or absence of is determined by + // buildTextEntryInteractionXML based on answers.length, not the schema. + if (questionType === QuestionType.FREE_RESPONSE || questionType === QuestionType.TEXT_ENTRY) { + return { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE }; + } + // NUMERIC: cardinality depends on how many acceptable answers are defined. + const answerCount = state?.answers?.length ?? 0; + return { + baseType: BaseType.FLOAT, + cardinality: answerCount > 1 ? Cardinality.MULTIPLE : Cardinality.SINGLE, + }; + } + + /** + * @param {string} bodyXml - Full `` XML string + * @param {string[]} responseDeclarations + * @returns {TextEntryState} + */ + parse(bodyXml, responseDeclarations) { + return parseTextEntryInteraction(bodyXml, responseDeclarations); + } + + /** + * @param {TextEntryState} state + * @param {string} questionType + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ + buildXML(state, questionType) { + return buildTextEntryInteractionXML( + state, + questionType, + this.getResponseDeclarationSchema(questionType, state), + ); + } + + /** + * @param {TextEntryState} state + * @param {string} questionType + * @returns {Array<{ code: string, id?: string }>} + */ + validate(state, questionType) { + return validateTextEntryInteraction(state, questionType); + } +} + +/** Singleton — safe to import from any file in the textEntry module tree. */ +export const textEntryInteractionDescriptor = new TextEntryInteractionDescriptor(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js new file mode 100644 index 0000000000..0e79d02b97 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js @@ -0,0 +1,284 @@ +import { render, screen, fireEvent } from '@testing-library/vue'; +import { nextTick } from 'vue'; +import VueRouter from 'vue-router'; +import TextEntryEditor from '../TextEntryEditor.vue'; + +import { + TEXT_ENTRY_BODY_XML, + TEXT_ENTRY_NUMERIC_DECL_XML as NUMERIC_DECL, + TEXT_ENTRY_STRING_DECL_XML as STRING_DECL, + TEXT_ENTRY_FREE_DECL_XML as FREE_DECL, + mockInteractionBlock as block, + mockInteractionBlockWithDecl as blockWithDecl, +} from '../../../utils/testingFixtures'; +import { QuestionType } from '../../../constants'; +import { qtiEditorStrings as tr } from '../../../qtiEditorStrings'; + +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); +jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => { + const { ref } = require('vue'); + return { + __esModule: true, + default: () => ({ windowIsSmall: ref(false) }), + }; +}); + +const renderEditor = (props = {}) => + render(TextEntryEditor, { + props: { mode: 'edit', ...props }, + routes: new VueRouter(), + }); + +// ─── numeric ───────────────────────────────────────────────────────────────── + +describe('TextEntryEditor — numeric', () => { + // Helper: count only the answer-native-input elements (excludes csrf and other env inputs) + const answerInputs = () => + screen.getAllByRole('textbox').filter(el => el.classList.contains('answer-native-input')); + + describe('answer list', () => { + it('renders one answer row per value in the declaration', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + expect(answerInputs().length).toBeGreaterThanOrEqual(1); + }); + + it('renders the Add acceptable answer button', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + expect(screen.getByRole('button', { name: tr.$tr('addAnswerBtn') })).toBeInTheDocument(); + }); + + it('adds a new answer row when Add button is clicked', async () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + const before = answerInputs().length; + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addAnswerBtn') })); + expect(answerInputs().length).toBe(before + 1); + }); + + it('renders a delete button for each answer row', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + expect(screen.getAllByRole('button', { name: tr.$tr('deleteAnswerBtn') })).toHaveLength(1); + }); + + it('disables delete when only one answer remains', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + expect(screen.getByRole('button', { name: tr.$tr('deleteAnswerBtn') })).toBeDisabled(); + }); + + it('removes an answer row when delete is clicked (with 2+ rows)', async () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addAnswerBtn') })); + expect(answerInputs().length).toBe(2); + const deleteBtns = screen.getAllByRole('button', { name: tr.$tr('deleteAnswerBtn') }); + await fireEvent.click(deleteBtns[0]); + expect(answerInputs().length).toBe(1); + }); + }); + + describe('view mode', () => { + it('hides Add button when mode=view and showAnswers=false', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + mode: 'view', + showAnswers: false, + }); + expect( + screen.queryByRole('button', { name: tr.$tr('addAnswerBtn') }), + ).not.toBeInTheDocument(); + }); + + it('shows answer inputs when mode=view and showAnswers=true', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + mode: 'view', + showAnswers: true, + }); + expect(answerInputs().length).toBeGreaterThanOrEqual(1); + }); + + it('hides the Add button in view mode even when showAnswers=true', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + mode: 'view', + showAnswers: true, + }); + expect( + screen.queryByRole('button', { name: tr.$tr('addAnswerBtn') }), + ).not.toBeInTheDocument(); + }); + }); + + describe('validation', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('does not show errors before any field is touched', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('shows an error after typing a non-numeric value and blurring', async () => { + jest.useFakeTimers(); + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + const input = answerInputs()[0]; + await fireEvent.input(input, { target: { value: 'not-a-number' } }); + await fireEvent.blur(input); + jest.useRealTimers(); + await nextTick(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('shows validation errors after a state mutation and debounce', async () => { + jest.useFakeTimers(); + renderEditor({ + interaction: block(TEXT_ENTRY_BODY_XML), + questionType: QuestionType.NUMERIC, + }); + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addAnswerBtn') })); + await nextTick(); + jest.advanceTimersByTime(400); + jest.useRealTimers(); + await nextTick(); + expect(screen.getAllByRole('alert').length).toBeGreaterThan(0); + }); + }); +}); + +// ─── textEntry (string with correct answer) ──────────────────────────────────── + +describe('TextEntryEditor — textEntry', () => { + it('renders the answer list section', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, STRING_DECL), + questionType: QuestionType.TEXT_ENTRY, + }); + // Filter to only answer-native-input elements (excludes env inputs like csrfmiddlewaretoken) + const inputs = screen + .getAllByRole('textbox') + .filter(el => el.classList.contains('answer-native-input')); + expect(inputs.length).toBeGreaterThanOrEqual(1); + }); + + it('renders a case-sensitive checkbox for each answer', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, STRING_DECL), + questionType: QuestionType.TEXT_ENTRY, + }); + expect( + screen.getByRole('checkbox', { name: tr.$tr('caseSensitiveLabel') }), + ).toBeInTheDocument(); + }); + + it('does not render case-sensitive checkboxes for numeric', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + expect( + screen.queryByRole('checkbox', { name: tr.$tr('caseSensitiveLabel') }), + ).not.toBeInTheDocument(); + }); +}); + +// ─── freeResponse ────────────────────────────────────────────────────────────── + +describe('TextEntryEditor — freeResponse', () => { + it('does not render the Add answer button', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, FREE_DECL), + questionType: QuestionType.FREE_RESPONSE, + }); + expect(screen.queryByRole('button', { name: tr.$tr('addAnswerBtn') })).not.toBeInTheDocument(); + }); + + it('does not render any answer input rows', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, FREE_DECL), + questionType: QuestionType.FREE_RESPONSE, + }); + const inputs = screen + .queryAllByRole('textbox') + .filter(el => el.classList.contains('answer-native-input')); + expect(inputs.length).toBe(0); + }); +}); + +// ─── emits ───────────────────────────────────────────────────────────────────── + +describe('TextEntryEditor — emits', () => { + it('emits update:interaction on mount with bodyXml and responseDeclarations', () => { + const { emitted } = renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + expect(emitted()['update:interaction']).toBeTruthy(); + const payload = emitted()['update:interaction'][0][0]; + expect(typeof payload.bodyXml).toBe('string'); + expect(Array.isArray(payload.responseDeclarations)).toBe(true); + }); + + it('emits update:interaction after adding an answer row', async () => { + const { emitted } = renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + const before = emitted()['update:interaction'].length; + await fireEvent.click(screen.getByRole('button', { name: tr.$tr('addAnswerBtn') })); + expect(emitted()['update:interaction'].length).toBeGreaterThan(before); + }); +}); + +// ─── accessibility ────────────────────────────────────────────────────────────── + +describe('TextEntryEditor — accessibility', () => { + it('delete icon buttons have accessible labels', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + }); + screen + .getAllByRole('button', { name: tr.$tr('deleteAnswerBtn') }) + .forEach(b => expect(b).toHaveAccessibleName()); + }); +}); + +// ─── graceful fallback ────────────────────────────────────────────────────────── + +describe('TextEntryEditor — graceful fallback', () => { + it('does not crash with empty bodyXml for numeric', () => { + renderEditor({ interaction: block(''), questionType: QuestionType.NUMERIC }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('does not crash with empty bodyXml for freeResponse', () => { + renderEditor({ interaction: block(''), questionType: QuestionType.FREE_RESPONSE }); + expect(screen.queryByRole('button', { name: tr.$tr('addAnswerBtn') })).not.toBeInTheDocument(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js new file mode 100644 index 0000000000..36fd06129e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js @@ -0,0 +1,345 @@ +import { + _defaultState, + _extractAnswers, + _extractPromptHTML, + parseTextEntryInteraction, + buildTextEntryInteractionXML, + FREE_RESPONSE_EXPECTED_LENGTH, +} from '../parse'; +import { BaseType, Cardinality, QuestionType } from '../../../constants'; + +// ─── Fixtures ────────────────────────────────────────────────────────────── + +const FREE_RESPONSE_DECLARATION = ` + +`.trim(); + +const SINGLE_NUMERIC_DECLARATION = ` + + + 12 + + +`.trim(); + +const MULTI_NUMERIC_DECLARATION = ` + + + 0.5 + 1.5 + + +`.trim(); + +/** Build a minimal with the given prompt div and the interaction. */ +function makeBodyXml({ promptHtml = '', expectedLength = null } = {}) { + const interactionAttrs = `response-identifier="RESPONSE"${expectedLength ? ` expected-length="${expectedLength}"` : ''}`; + return `
${promptHtml ? `
${promptHtml}
` : ''}

`; +} + +// ─── _defaultState ───────────────────────────────────────────────────────── + +describe('_defaultState', () => { + it('returns prompt as empty string', () => { + expect(_defaultState().prompt).toBe(''); + }); + + it('returns answers as empty array', () => { + expect(_defaultState().answers).toEqual([]); + }); + + it('returns expectedLength as 0', () => { + expect(_defaultState().expectedLength).toBe(0); + }); +}); + +// ─── _extractAnswers ─────────────────────────────────────────────────────── + +describe('_extractAnswers', () => { + it('returns [] when no declaration is provided', () => { + expect(_extractAnswers([])).toEqual([]); + }); + + it('returns [] for a free-response (string base-type) declaration', () => { + expect(_extractAnswers([FREE_RESPONSE_DECLARATION])).toEqual([]); + }); + + it('returns one answer for a single numeric declaration', () => { + const result = _extractAnswers([SINGLE_NUMERIC_DECLARATION]); + expect(result).toHaveLength(1); + expect(result[0].value).toBe('12'); + expect(result[0].id).toMatch(/^answer_/); + }); + + it('returns two answers for a multiple numeric declaration', () => { + const result = _extractAnswers([MULTI_NUMERIC_DECLARATION]); + expect(result).toHaveLength(2); + expect(result.map(a => a.value)).toEqual(['0.5', '1.5']); + }); + + it('returns [] when declaration has no ', () => { + const declXml = ` + + `.trim(); + expect(_extractAnswers([declXml])).toEqual([]); + }); + + it('assigns unique ids to each answer', () => { + const result = _extractAnswers([MULTI_NUMERIC_DECLARATION]); + expect(result[0].id).not.toBe(result[1].id); + }); +}); + +// ─── _extractPromptHTML ──────────────────────────────────────────────────── + +describe('_extractPromptHTML', () => { + it('returns empty string when no non-interaction content exists', () => { + const doc = new DOMParser().parseFromString(makeBodyXml(), 'text/xml'); + const bodyEl = doc.documentElement; + expect(_extractPromptHTML(bodyEl).trim()).toBe(''); + }); + + it('excludes the interaction container from the prompt', () => { + const doc = new DOMParser().parseFromString( + makeBodyXml({ promptHtml: '

What is 3 × 4?

' }), + 'text/xml', + ); + const bodyEl = doc.documentElement; + const html = _extractPromptHTML(bodyEl); + expect(html).not.toContain('qti-text-entry-interaction'); + expect(html).toContain('3'); + }); +}); + +// ─── parseTextEntryInteraction ───────────────────────────────────────────── + +describe('parseTextEntryInteraction', () => { + it('returns defaultState when bodyXml is empty', () => { + expect(parseTextEntryInteraction('', [])).toEqual(_defaultState()); + }); + + it('returns defaultState when bodyXml is unparseable', () => { + expect(parseTextEntryInteraction('< { + const bodyXml = '

No interaction here

'; + expect(parseTextEntryInteraction(bodyXml, [])).toEqual(_defaultState()); + }); + + describe('freeResponse', () => { + it('sets answers to [] for a free-response declaration', () => { + const state = parseTextEntryInteraction(makeBodyXml(), [FREE_RESPONSE_DECLARATION]); + expect(state.answers).toEqual([]); + }); + + it('defaults to freeResponse (answers: []) when declaration is missing', () => { + const state = parseTextEntryInteraction(makeBodyXml(), []); + expect(state.answers).toEqual([]); + }); + + it('reads expectedLength from the element attribute', () => { + const state = parseTextEntryInteraction( + makeBodyXml({ expectedLength: FREE_RESPONSE_EXPECTED_LENGTH }), + [FREE_RESPONSE_DECLARATION], + ); + expect(state.expectedLength).toBe(FREE_RESPONSE_EXPECTED_LENGTH); + }); + }); + + describe('numeric', () => { + it('parses a single numeric answer', () => { + const state = parseTextEntryInteraction(makeBodyXml(), [SINGLE_NUMERIC_DECLARATION]); + expect(state.answers).toHaveLength(1); + expect(state.answers[0].value).toBe('12'); + }); + + it('parses multiple numeric answers', () => { + const state = parseTextEntryInteraction(makeBodyXml(), [MULTI_NUMERIC_DECLARATION]); + expect(state.answers).toHaveLength(2); + expect(state.answers.map(a => a.value)).toEqual(['0.5', '1.5']); + }); + + it('defaults expectedLength to 0 when absent', () => { + const state = parseTextEntryInteraction(makeBodyXml(), [SINGLE_NUMERIC_DECLARATION]); + expect(state.expectedLength).toBe(0); + }); + }); +}); + +// ─── buildTextEntryInteractionXML ────────────────────────────────────────── + +describe('buildTextEntryInteractionXML', () => { + const FREE_SCHEMA = { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE }; + const NUMERIC_SINGLE_SCHEMA = { baseType: BaseType.FLOAT, cardinality: Cardinality.SINGLE }; + const NUMERIC_MULTI_SCHEMA = { baseType: BaseType.FLOAT, cardinality: Cardinality.MULTIPLE }; + + describe('bodyXml', () => { + it('produces a well-formed ', () => { + const { bodyXml } = buildTextEntryInteractionXML( + { prompt: '

Hello

', answers: [], expectedLength: 0 }, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + const doc = new DOMParser().parseFromString(bodyXml, 'text/xml'); + expect(doc.querySelector('parsererror')).toBeNull(); + expect(doc.querySelector('qti-item-body')).not.toBeNull(); + }); + + it('contains a element', () => { + const { bodyXml } = buildTextEntryInteractionXML( + { prompt: '', answers: [], expectedLength: 0 }, + QuestionType.NUMERIC, + NUMERIC_SINGLE_SCHEMA, + ); + expect(bodyXml).toContain('qti-text-entry-interaction'); + }); + + it('sets response-identifier="RESPONSE"', () => { + const { bodyXml } = buildTextEntryInteractionXML( + { prompt: '', answers: [], expectedLength: 0 }, + QuestionType.NUMERIC, + NUMERIC_SINGLE_SCHEMA, + ); + expect(bodyXml).toContain('response-identifier="RESPONSE"'); + }); + + it('adds expected-length for freeResponse using FREE_RESPONSE_EXPECTED_LENGTH', () => { + const { bodyXml } = buildTextEntryInteractionXML( + { prompt: '', answers: [], expectedLength: 0 }, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(bodyXml).toContain(`expected-length="${FREE_RESPONSE_EXPECTED_LENGTH}"`); + }); + + it('does not add expected-length for numeric when expectedLength is 0', () => { + const { bodyXml } = buildTextEntryInteractionXML( + { prompt: '', answers: [{ id: 'a1', value: '12' }], expectedLength: 0 }, + QuestionType.NUMERIC, + NUMERIC_SINGLE_SCHEMA, + ); + expect(bodyXml).not.toContain('expected-length'); + }); + }); + + describe('responseDeclarations', () => { + it('emits exactly one declaration', () => { + const { responseDeclarations } = buildTextEntryInteractionXML( + { prompt: '', answers: [], expectedLength: 0 }, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(responseDeclarations).toHaveLength(1); + }); + + it('free response has base-type="string" and no ', () => { + const { responseDeclarations } = buildTextEntryInteractionXML( + { prompt: '', answers: [], expectedLength: 0 }, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(responseDeclarations[0]).toContain('base-type="string"'); + expect(responseDeclarations[0]).not.toContain('qti-correct-response'); + }); + + it('numeric with 1 answer gets cardinality="single"', () => { + const { responseDeclarations } = buildTextEntryInteractionXML( + { prompt: '', answers: [{ id: 'a1', value: '12' }], expectedLength: 0 }, + QuestionType.NUMERIC, + NUMERIC_SINGLE_SCHEMA, + ); + expect(responseDeclarations[0]).toContain('cardinality="single"'); + expect(responseDeclarations[0]).toContain('base-type="float"'); + }); + + it('numeric with 2+ answers gets cardinality="multiple"', () => { + const { responseDeclarations } = buildTextEntryInteractionXML( + { + prompt: '', + answers: [ + { id: 'a1', value: '0.5' }, + { id: 'a2', value: '1.5' }, + ], + expectedLength: 0, + }, + QuestionType.NUMERIC, + NUMERIC_MULTI_SCHEMA, + ); + expect(responseDeclarations[0]).toContain('cardinality="multiple"'); + }); + + it('numeric includes with each answer value', () => { + const { responseDeclarations } = buildTextEntryInteractionXML( + { + prompt: '', + answers: [ + { id: 'a1', value: '0.5' }, + { id: 'a2', value: '1.5' }, + ], + expectedLength: 0, + }, + QuestionType.NUMERIC, + NUMERIC_MULTI_SCHEMA, + ); + expect(responseDeclarations[0]).toContain('qti-correct-response'); + expect(responseDeclarations[0]).toContain('>0.5<'); + expect(responseDeclarations[0]).toContain('>1.5<'); + }); + }); + + describe('round-trip', () => { + it('numeric: parse → buildXML → parse yields equivalent state', () => { + const original = { + prompt: '

What is 3 × 4?

', + answers: [{ id: 'a1', value: '12' }], + expectedLength: 0, + }; + const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML( + original, + QuestionType.NUMERIC, + NUMERIC_SINGLE_SCHEMA, + ); + const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations); + + expect(parsed.answers).toHaveLength(1); + expect(parsed.answers[0].value).toBe('12'); + expect(parsed.expectedLength).toBe(0); + }); + + it('freeResponse: parse → buildXML → parse yields equivalent state', () => { + const original = { + prompt: '

Describe photosynthesis.

', + answers: [], + expectedLength: FREE_RESPONSE_EXPECTED_LENGTH, + }; + const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML( + original, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations); + + expect(parsed.answers).toEqual([]); + expect(parsed.expectedLength).toBe(FREE_RESPONSE_EXPECTED_LENGTH); + }); + + it('multi-answer numeric: round-trip preserves all values', () => { + const original = { + prompt: '

Q

', + answers: [ + { id: 'a1', value: '0.5' }, + { id: 'a2', value: '1.5' }, + ], + expectedLength: 0, + }; + const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML( + original, + QuestionType.NUMERIC, + NUMERIC_MULTI_SCHEMA, + ); + const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations); + expect(parsed.answers.map(a => a.value)).toEqual(['0.5', '1.5']); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js new file mode 100644 index 0000000000..fd0f072e41 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js @@ -0,0 +1,171 @@ +import { validateTextEntryInteraction } from '../validation'; +import { QuestionType, ValidationError } from '../../../constants'; + +const VALID_NUMERIC_STATE = { + prompt: '

What is 3 × 4?

', + answers: [{ id: 'a1', value: '12' }], + expectedLength: 0, +}; + +const VALID_FREE_STATE = { + prompt: '

Describe photosynthesis.

', + answers: [], + expectedLength: 50, +}; + +describe('validateTextEntryInteraction', () => { + describe('PROMPT_REQUIRED', () => { + it('returns PROMPT_REQUIRED when prompt is empty', () => { + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, prompt: '' }, + QuestionType.NUMERIC, + ); + expect(errors.some(e => e.code === ValidationError.PROMPT_REQUIRED)).toBe(true); + }); + + it('returns PROMPT_REQUIRED when prompt is whitespace-only', () => { + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, prompt: ' ' }, + QuestionType.NUMERIC, + ); + expect(errors.some(e => e.code === ValidationError.PROMPT_REQUIRED)).toBe(true); + }); + + it('returns PROMPT_REQUIRED when prompt contains only HTML tags with no text', () => { + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, prompt: '

' }, + QuestionType.NUMERIC, + ); + expect(errors.some(e => e.code === ValidationError.PROMPT_REQUIRED)).toBe(true); + }); + + it('returns PROMPT_REQUIRED for a visually-empty  -only prompt (entity regression)', () => { + // The naive /<[^>]*>/g regex leaves the literal text " " which is truthy, + // so PROMPT_REQUIRED would silently pass. QTISanitizer.stripTags parses via + // DOMParser text/html, which decodes entities and returns actual whitespace. + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, prompt: '

 

' }, + QuestionType.NUMERIC, + ); + expect(errors.some(e => e.code === ValidationError.PROMPT_REQUIRED)).toBe(true); + }); + + it('does not return PROMPT_REQUIRED when prompt has text content', () => { + const errors = validateTextEntryInteraction(VALID_NUMERIC_STATE, QuestionType.NUMERIC); + expect(errors.some(e => e.code === ValidationError.PROMPT_REQUIRED)).toBe(false); + }); + }); + + describe('TEXT_ENTRY constraints', () => { + it('returns NO_CORRECT_ANSWER for textEntry with 0 answers', () => { + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, answers: [] }, + QuestionType.TEXT_ENTRY, + ); + expect(errors.some(e => e.code === ValidationError.NO_CORRECT_ANSWER)).toBe(true); + }); + + it('returns EMPTY_ANSWER_CONTENT for textEntry with empty answers', () => { + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, answers: [{ id: 'a1', value: ' ' }] }, + QuestionType.TEXT_ENTRY, + ); + expect(errors.some(e => e.code === ValidationError.EMPTY_ANSWER_CONTENT)).toBe(true); + }); + }); + + describe('NO_CORRECT_ANSWER (numeric only)', () => { + it('returns NO_CORRECT_ANSWER for numeric with 0 answers', () => { + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, answers: [] }, + QuestionType.NUMERIC, + ); + expect(errors.some(e => e.code === ValidationError.NO_CORRECT_ANSWER)).toBe(true); + }); + + it('does not return NO_CORRECT_ANSWER for numeric with at least 1 answer', () => { + const errors = validateTextEntryInteraction(VALID_NUMERIC_STATE, QuestionType.NUMERIC); + expect(errors.some(e => e.code === ValidationError.NO_CORRECT_ANSWER)).toBe(false); + }); + + it('does not return NO_CORRECT_ANSWER for freeResponse with 0 answers', () => { + const errors = validateTextEntryInteraction(VALID_FREE_STATE, QuestionType.FREE_RESPONSE); + expect(errors.some(e => e.code === ValidationError.NO_CORRECT_ANSWER)).toBe(false); + }); + }); + + describe('INVALID_NUMERIC_VALUE', () => { + it.each([ + ['integer', '12'], + ['negative integer', '-3'], + ['decimal', '0.5'], + ['scientific notation', '1.5e2'], + ])('does not flag a valid %s value (%s)', (_, value) => { + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, answers: [{ id: 'a1', value }] }, + QuestionType.NUMERIC, + ); + expect(errors.some(e => e.code === ValidationError.INVALID_NUMERIC_VALUE)).toBe(false); + }); + + it.each([ + ['letters', 'abc'], + ['expression', '1+2'], + ['fraction', '1/2'], + ['empty string', ''], + ])('flags an invalid value: %s', (_, value) => { + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, answers: [{ id: 'a1', value }] }, + QuestionType.NUMERIC, + ); + expect(errors.some(e => e.code === ValidationError.INVALID_NUMERIC_VALUE)).toBe(true); + }); + + it('attaches the answer id to the error', () => { + const errors = validateTextEntryInteraction( + { ...VALID_NUMERIC_STATE, answers: [{ id: 'answer_abc', value: 'bad' }] }, + QuestionType.NUMERIC, + ); + const err = errors.find(e => e.code === ValidationError.INVALID_NUMERIC_VALUE); + expect(err?.id).toBe('answer_abc'); + }); + + it('emits one INVALID_NUMERIC_VALUE error per invalid answer', () => { + const errors = validateTextEntryInteraction( + { + ...VALID_NUMERIC_STATE, + answers: [ + { id: 'a1', value: 'bad' }, + { id: 'a2', value: 'also bad' }, + ], + }, + QuestionType.NUMERIC, + ); + const invalids = errors.filter(e => e.code === ValidationError.INVALID_NUMERIC_VALUE); + expect(invalids).toHaveLength(2); + expect(invalids.map(e => e.id)).toEqual(['a1', 'a2']); + }); + + it('does not flag INVALID_NUMERIC_VALUE for freeResponse', () => { + // freeResponse state with a clearly non-numeric "answer" — should not error + // (answers is always [] for freeResponse, but just in case) + const errors = validateTextEntryInteraction( + { ...VALID_FREE_STATE, answers: [{ id: 'a1', value: 'abc' }] }, + QuestionType.FREE_RESPONSE, + ); + expect(errors.some(e => e.code === ValidationError.INVALID_NUMERIC_VALUE)).toBe(false); + }); + }); + + describe('valid states return empty array', () => { + it('returns [] for a valid numeric state', () => { + expect(validateTextEntryInteraction(VALID_NUMERIC_STATE, QuestionType.NUMERIC)).toEqual([]); + }); + + it('returns [] for a valid freeResponse state', () => { + expect(validateTextEntryInteraction(VALID_FREE_STATE, QuestionType.FREE_RESPONSE)).toEqual( + [], + ); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js new file mode 100644 index 0000000000..584f235e43 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js @@ -0,0 +1,25 @@ +import defineInteraction from '../defineInteraction'; +import TextEntryEditor from './TextEntryEditor.vue'; +import { textEntryInteractionDescriptor } from './TextEntryInteractionDescriptor'; + +/** + * @typedef {object} TextEntryAnswer + * @property {string} id - Client-side slug (not serialized to XML) + * @property {string} value - The answer value as a string. For numeric this is a + * float/int string (e.g. "12", "0.5"); for textEntry it + * is a free-form string (e.g. "Paris"). + * @property {boolean} caseSensitive - textEntry only. When true, "H2O" ≠ "h2o". + * Always false for numeric answers. + */ + +/** + * @typedef {object} TextEntryState + * @property {string} prompt - HTML content of the question prompt; default "" + * @property {TextEntryAnswer[]} answers - Acceptable correct answers. + * Empty ([]) for freeResponse. + * @property {number} expectedLength - Value of the `expected-length` attribute. + * FREE_RESPONSE_EXPECTED_LENGTH for freeResponse; + * 0 (absent) for numeric and textEntry. + */ + +export default defineInteraction(textEntryInteractionDescriptor, TextEntryEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js new file mode 100644 index 0000000000..98a25892fc --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -0,0 +1,239 @@ +import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; +import { parseXML } from '../../serialization/parseItem'; +import { buildXmlNode } from '../../serialization/assembleItem'; +import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; +import { generateRandomSlug } from '../../utils/generateRandomSlug'; +import { BaseType } from '../../constants'; + +const serializer = new XMLSerializer(); + +const RESPONSE_IDENTIFIER = 'RESPONSE'; + +/** + * Default `expected-length` attribute written on `` + * for free-response items. The QTI 3.0 spec does not mandate a value; 50 is a + * widely-used authoring convention (sourced from the textEntry interaction PR + * description). Change this constant if your platform uses a different default. + * + * This attribute is informational — it hints to the player how wide to render + * the input box. It has no effect on scoring. + */ +export const FREE_RESPONSE_EXPECTED_LENGTH = 50; + +/** + * Default state — used when bodyXml is absent or unparseable. + * + * @returns {TextEntryState} + */ +export function _defaultState() { + return { + prompt: '', + answers: [], + expectedLength: 0, + }; +} + +/** + * Serialize body children to an HTML string, excluding the element that + * directly contains the ``. + * + * buildTextEntryInteractionXML wraps body content in a single `
`, so we + * look inside that wrapper for prompt children and the interaction container. + * + * @param {Element} bodyEl - The `` element + * @returns {string} + */ +export function _extractPromptHTML(bodyEl) { + const interactionEl = bodyEl.querySelector('qti-text-entry-interaction'); + if (!interactionEl) return ''; + + // The interaction sits in a

; the

itself (or the interaction) is the + // container we want to exclude. + const interactionContainer = interactionEl.parentElement; + + // buildTextEntryInteractionXML wraps everything in a single outer

. + // If the body has exactly one
child, iterate its children so we + // exclude only the

holding the interaction rather than the entire wrapper. + const bodyChildren = bodyEl.children; + const searchRoot = + bodyChildren.length === 1 && bodyChildren[0].tagName.toLowerCase() === 'div' + ? bodyChildren[0] + : bodyEl; + + const parts = []; + for (const child of searchRoot.childNodes) { + if (child === interactionContainer || child === interactionEl) continue; + parts.push( + child.nodeType === Node.TEXT_NODE ? child.textContent : serializer.serializeToString(child), + ); + } + return parts.join('').trim(); +} + +/** + * Extract correct answer values from the response declaration string. + * Returns an array of `{ id, value, caseSensitive }` objects, or [] when no + * correct response is declared (i.e. free-response items). + * + * Supports both float (numeric) and string (textEntry) base-types. + * The `caseSensitive` field is only meaningful for string base-type answers. + * + * @param {string[]} responseDeclarations + * @returns {{ id: string, value: string, caseSensitive: boolean }[]} + */ +export function _extractAnswers(responseDeclarations) { + const [declXml] = responseDeclarations || []; + if (!declXml) return []; + + try { + const declEl = parseXML(declXml).documentElement; + const isFloat = declEl.getAttribute('base-type') === BaseType.FLOAT; + const isString = declEl.getAttribute('base-type') === BaseType.STRING; + + if (!isFloat && !isString) return []; + + const correctResponseEl = declEl.querySelector('qti-correct-response'); + if (!correctResponseEl) return []; + + const valueEls = [...correctResponseEl.querySelectorAll('qti-value')]; + if (valueEls.length === 0) return []; + + return valueEls.map(el => ({ + id: generateRandomSlug('answer'), + value: el.textContent.trim(), + // case-sensitive is only meaningful for string base-type answers. + caseSensitive: isString && el.getAttribute('case-sensitive') === 'true', + })); + } catch { + return []; + } +} + +/** + * Parse a `` XML string + response declarations → TextEntryState. + * + * `bodyXml` is the serialized `` (not just the interaction + * element) because the prompt lives in the body siblings, not in a + * `` child. + * + * @param {string} bodyXml - Serialized `` element + * @param {string[]} responseDeclarations + * @returns {TextEntryState} + */ +export function parseTextEntryInteraction(bodyXml, responseDeclarations) { + if (!bodyXml) return _defaultState(); + + let bodyEl; + try { + const doc = parseXML(bodyXml, 'text/html'); + // bodyXml may be the itself or wrap it — handle both. + bodyEl = + doc.body.firstElementChild && + doc.body.firstElementChild.tagName.toLowerCase() === 'qti-item-body' + ? doc.body.firstElementChild + : (doc.querySelector('qti-item-body') ?? doc.body.firstElementChild ?? doc.body); + } catch { + return _defaultState(); + } + + const interactionEl = bodyEl.querySelector('qti-text-entry-interaction'); + if (!interactionEl) return _defaultState(); + + const expectedLength = parseInt(interactionEl.getAttribute('expected-length') ?? '0', 10); + const prompt = _extractPromptHTML(bodyEl); + const answers = _extractAnswers(responseDeclarations); + + return { prompt, answers, expectedLength }; +} + +/** + * Serialize TextEntryState → { bodyXml, responseDeclarations }. + * + * The output `bodyXml` is a full `` so that parseItem can + * store it and the next parse() call can extract the prompt correctly. + * + * @param {TextEntryState} state + * @param {string} questionType - QuestionType.NUMERIC | QuestionType.FREE_RESPONSE + * @param {{ baseType: string, cardinality: string }} declarationSchema + * @returns {{ bodyXml: string, responseDeclarations: string[] }} + */ +export function buildTextEntryInteractionXML(state, questionType, declarationSchema) { + const { prompt, answers, expectedLength } = state; + const { baseType, cardinality } = declarationSchema; + + // Only freeResponse has an expected-length; textEntry and numeric do not. + const isFreeResponse = baseType === BaseType.STRING && answers.length === 0; + + const interactionAttrs = { + 'response-identifier': RESPONSE_IDENTIFIER, + }; + + const effectiveExpectedLength = isFreeResponse + ? FREE_RESPONSE_EXPECTED_LENGTH + : expectedLength || null; + if (effectiveExpectedLength) { + interactionAttrs['expected-length'] = effectiveExpectedLength; + } + + const interactionEl = buildXmlNode({ + tag: 'qti-text-entry-interaction', + attrs: interactionAttrs, + }); + + // Wrap the interaction in

as QTI inline elements must appear in flow content. + const interactionParagraph = buildXmlNode({ + tag: 'p', + children: [interactionEl], + }); + + // Build the body content: prompt HTML (if any) followed by the interaction paragraph. + const divChildren = []; + if (prompt) { + divChildren.push(buildXmlNode({ tag: 'div', innerHTML: prompt })); + } + divChildren.push(interactionParagraph); + + const bodyEl = buildXmlNode({ + tag: 'qti-item-body', + children: [buildXmlNode({ tag: 'div', children: divChildren })], + }); + + const bodyXml = serializer.serializeToString(bodyEl); + + // Build the response declaration. + const declaration = new QTIDeclaration({ + identifier: RESPONSE_IDENTIFIER, + baseType, + cardinality, + tag: 'qti-response-declaration', + }); + + // Write a correct-response for numeric and textEntry (not freeResponse). + if (!isFreeResponse && answers.length > 0) { + if (baseType === BaseType.STRING) { + // Build manually so we can write the optional + // case-sensitive="true" attribute on individual elements. + const valueEls = answers.map(a => { + const valueEl = buildXmlNode({ tag: 'qti-value', children: [a.value] }); + if (a.caseSensitive) { + valueEl.setAttribute('case-sensitive', 'true'); + } + return valueEl; + }); + const correctResponseEl = buildXmlNode({ + tag: 'qti-correct-response', + children: valueEls, + }); + declaration.getXML().appendChild(correctResponseEl); + } else { + // Numeric: delegate to CorrectResponse (no per-value attrs needed). + new CorrectResponse( + answers.map(a => a.value), + declaration, + ); + } + } + + const declarationXml = serializer.serializeToString(declaration.getXML()); + return { bodyXml, responseDeclarations: [declarationXml] }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js new file mode 100644 index 0000000000..44a2fb6f8b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js @@ -0,0 +1,49 @@ +import { QuestionType, ValidationError } from '../../constants'; +import { QTISanitizer } from '../../serialization/qti/QTISanitizer'; +import { floatOrIntRegex } from '../../utils/math'; + +/** + * Validate TextEntryState → ValidationError[]. + * + * - numeric: prompt required + at least one answer + each value must be a valid number + * - textEntry: prompt required + at least one answer (any non-blank string) + * - freeResponse: prompt required only + * + * @param {TextEntryState} state + * @param {string} questionType + * @returns {Array<{ code: string, id?: string }>} + */ +export function validateTextEntryInteraction(state, questionType) { + const errors = []; + const { prompt, answers } = state; + + if (!QTISanitizer.stripTags(prompt).trim()) { + errors.push({ code: ValidationError.PROMPT_REQUIRED }); + } + + if (questionType === QuestionType.NUMERIC) { + if (answers.length === 0) { + errors.push({ code: ValidationError.NO_CORRECT_ANSWER }); + } + + for (const answer of answers) { + if (!floatOrIntRegex.test(answer.value.trim())) { + errors.push({ code: ValidationError.INVALID_NUMERIC_VALUE, id: answer.id }); + } + } + } + + if (questionType === QuestionType.TEXT_ENTRY) { + if (answers.length === 0) { + errors.push({ code: ValidationError.NO_CORRECT_ANSWER }); + } + + for (const answer of answers) { + if (!answer.value.trim()) { + errors.push({ code: ValidationError.EMPTY_ANSWER_CONTENT, id: answer.id }); + } + } + } + + return errors; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 0e98cccf29..3cce233bdc 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -29,11 +29,11 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Show answers', context: 'Checkbox label to toggle displaying answers/previews', }, - singleChoiceLabel: { + singleSelectLabel: { message: 'Single Choice', context: 'Display name for a single-select question type', }, - multipleChoiceLabel: { + multiSelectLabel: { message: 'Multiple Choice', context: 'Display name for a multiple-select question type', }, @@ -61,10 +61,6 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Match', context: 'Display name for a match question type', }, - textEntryLabel: { - message: 'Text entry', - context: 'Display name for a text entry question type', - }, extendedTextLabel: { message: 'Extended text', context: 'Display name for an extended text question type', @@ -133,8 +129,61 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Answer cannot be blank', context: 'Validation error when an answer option is empty', }, + errorEmptyAnswerContent: { + message: 'Cannot be empty', + context: 'Validation error when a text entry answer is empty', + }, errorDuplicateChoiceContent: { message: 'Duplicate answer options are not allowed', context: 'Validation error when two or more answer options have identical content', }, + numericLabel: { + message: 'Numeric', + context: 'Display name for a numeric text-entry question type', + }, + textEntryLabel: { + message: 'Text entry', + context: 'Display name for a string text-entry question type with a required correct answer', + }, + freeResponseLabel: { + message: 'Free response', + context: 'Display name for a free-response text-entry question type', + }, + acceptableAnswersLabel: { + message: 'Acceptable answers', + context: 'Section header above the list of correct numeric answer values', + }, + acceptableAnswersDescription: { + message: 'Enter all acceptable numeric values', + context: 'Subtitle under the acceptable answers header for numeric questions', + }, + acceptableAnswersDescriptionTextEntry: { + message: 'Enter all acceptable spellings or formats', + context: 'Subtitle under the acceptable answers header for text entry questions', + }, + addAnswerBtn: { + message: 'Add acceptable answer', + context: 'Button that appends a new answer row', + }, + deleteAnswerBtn: { + message: 'Delete answer', + context: 'Accessible label for the delete icon button next to an answer row', + }, + caseSensitiveLabel: { + message: 'Case-sensitive', + context: + 'Checkbox label — when checked, the answer must match exact casing (e.g. "H2O" ≠ "h2o")', + }, + answerValuePlaceholder: { + message: 'Enter a number', + context: 'Placeholder inside a numeric answer input field', + }, + answerTextPlaceholder: { + message: 'Enter an accepted answer', + context: 'Placeholder inside a text-entry answer input field', + }, + errorInvalidNumericValue: { + message: 'Must be a valid number (e.g. 12, 0.5, -3.14)', + context: 'Validation error shown when an answer value is not a valid number', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js index 1d604bca95..e1ef90a1f7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -1,4 +1,4 @@ -import { QTI_INTERACTION_TAGS } from '../constants'; +import { QTI_INTERACTION_TAGS, QtiInteraction } from '../constants'; const serializer = new XMLSerializer(); const parser = new DOMParser(); @@ -50,6 +50,10 @@ export function getPromptHTML(interactionEl) { * A response declaration belongs to an interaction when the declaration's * `identifier` matches the interaction's `response-identifier` attribute. * + * For descriptors with `placement: 'inline'`, `bodyXml` is the serialized + * `` rather than the interaction element alone, so the + * interaction's parse() function can recover prompt content from body siblings. + * * @param {string} rawData - Raw QTI XML string (the full assessment item XML) * @returns {{ * identifier: string, @@ -84,8 +88,13 @@ export function parseItem(rawData) { .filter(d => d.getAttribute('identifier') === responseId) .map(d => serializer.serializeToString(d)); + // Inline interactions (e.g. text-entry) embed their prompt in body + // siblings, not inside the element. Pass the full so + // parse() can recover the prompt from the surrounding context. + const isInline = el.tagName.toLowerCase() === QtiInteraction.TEXT_ENTRY; + interactions.push({ - bodyXml: serializer.serializeToString(el), + bodyXml: isInline ? serializer.serializeToString(body) : serializer.serializeToString(el), responseDeclarations, }); } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/math.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/math.js new file mode 100644 index 0000000000..789fe0f90c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/math.js @@ -0,0 +1,8 @@ +/** + * Math utilities for the QTI editor. + */ + +/** + * Matches valid numeric answer values: integers, decimals, and scientific notation. + */ +export const floatOrIntRegex = /^(?=.)([+-]?([0-9e]*)(\.([0-9e]+))?)$/; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index f32d992478..2bd903afbc 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -22,6 +22,19 @@ export const UNKNOWN_INTERACTION_XML = `Unknown. `; +/** + * Text-entry fixtures use inline placement, so bodyXml is the full + * rather than just the interaction element. + * This is the shape that parseItem() produces for qti-text-entry-interaction. + */ +export const TEXT_ENTRY_BODY_XML = `

What is H2O?

`; + +export const TEXT_ENTRY_NUMERIC_DECL_XML = `42`; + +export const TEXT_ENTRY_STRING_DECL_XML = `H2O`; + +export const TEXT_ENTRY_FREE_DECL_XML = ``; + export const CHOICE_SINGLE_DECL_XML = ` mercury `; From 942cf8101697a3252d1a02aec4c724190f840a9e Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Tue, 28 Jul 2026 01:49:03 +0530 Subject: [PATCH 2/3] refactor: consolidate QTI interaction state management and improve text-entry parsing robustness Signed-off-by: Abhishek-Punhani --- .../components/AddListItemButton/index.vue | 76 ++++++++++ .../useInteractionDescriptor.spec.js | 8 +- .../composables/useInteractionDescriptor.js | 19 +-- .../shared/views/QTIEditor/constants.js | 11 ++ .../choice/ChoiceInteractionDescriptor.js | 3 +- .../choice/ChoiceInteractionEditor.vue | 47 +------ .../QTIEditor/interactions/choice/index.js | 18 --- .../QTIEditor/interactions/choice/parse.js | 29 +++- .../views/QTIEditor/interactions/index.js | 10 ++ .../textEntry/TextEntryEditor.vue | 133 +++++++----------- .../TextEntryInteractionDescriptor.js | 11 +- .../textEntry/__tests__/parse.spec.js | 71 ++++------ .../QTIEditor/interactions/textEntry/index.js | 20 --- .../QTIEditor/interactions/textEntry/parse.js | 116 +++++++++------ .../interactions/textEntry/validation.js | 32 +++-- .../views/QTIEditor/qtiEditorStrings.js | 16 +++ .../QTIEditor/serialization/assembleItem.js | 11 +- .../QTIEditor/serialization/parseItem.js | 15 +- 18 files changed, 341 insertions(+), 305 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/AddListItemButton/index.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/AddListItemButton/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/AddListItemButton/index.vue new file mode 100644 index 0000000000..ff128fa1b5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/AddListItemButton/index.vue @@ -0,0 +1,76 @@ + + + + + + + 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 210c3e00b0..1d8a3f748f 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 @@ -109,12 +109,12 @@ describe('useInteractionDescriptor', () => { }); describe('with malformed XML', () => { - it('returns null parseError (text/html parser recovers silently from malformed input)', async () => { - // inferFromXml now uses text/html which never throws — malformed fragments - // are recovered gracefully by the browser HTML parser, so parseError stays null. + it('returns a parse error for malformed XML', async () => { + // inferFromXml uses text/xml which throws a parser error for malformed fragments. const { result } = renderDescriptor(' { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js index db74e54963..34e3e960cc 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js @@ -24,23 +24,8 @@ export default function useInteractionDescriptor(interactionRef) { return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, error: null }; } try { - // Parse as text/html — the serialized bodyXml carries an xmlns attribute - // (e.g. xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0") that causes - // CSS querySelector to silently fail in Chrome/Firefox when using the - // strict text/xml parser. text/html strips namespaces and always works. - const doc = parseXML(xml, 'text/html'); - // In html mode the document is: …content… - // For block interactions the content IS the interaction element itself; - // for inline interactions it is the wrapper. - const root = doc.body.firstElementChild ?? doc.body; - const interactionEl = - descriptors.reduce((found, d) => { - if (found) return found; - // Try root itself first, then search descendants. - if (d.matches(root)) return root; - return root.querySelector(d.type) ?? null; - }, null) ?? root; - + const doc = parseXML(xml); + const interactionEl = doc.documentElement; const desc = descriptors.find(d => d.matches(interactionEl)) ?? registry[DEFAULT_INTERACTION]; return { descriptor: desc, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index ce61b9e6c2..bf03debb10 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -95,4 +95,15 @@ export const ValidationError = Object.freeze({ DUPLICATE_CHOICE_CONTENT: 'DUPLICATE_CHOICE_CONTENT', INVALID_NUMERIC_VALUE: 'INVALID_NUMERIC_VALUE', EMPTY_ANSWER_CONTENT: 'EMPTY_ANSWER_CONTENT', + DUPLICATE_ANSWER_CONTENT: 'DUPLICATE_ANSWER_CONTENT', }); + +export const RESPONSE_IDENTIFIER = 'RESPONSE'; + +/** + * Set of QTI interaction tag names that have `placement: 'inline'`. + * Used by parseItem to decide whether to serialize the full `` + * (inline) or just the interaction element (block). + * Kept here to avoid a circular dependency with the descriptor registry. + */ +export const INLINE_INTERACTION_TAGS = new Set([QtiInteraction.TEXT_ENTRY]); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js index f575d37903..5aa0e0d4cf 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionDescriptor.js @@ -1,4 +1,5 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { parseXML } from '../../serialization/parseItem'; import { parseChoiceInteraction, buildChoiceInteractionXML } from './parse'; import { validateChoiceInteraction } from './validation'; @@ -28,7 +29,7 @@ export class ChoiceInteractionDescriptor { */ getQuestionType(el, responseDeclarations = []) { if (responseDeclarations.length > 0) { - const doc = new DOMParser().parseFromString(responseDeclarations[0], 'text/xml'); + const doc = parseXML(responseDeclarations[0]); const cardinality = doc.documentElement.getAttribute('cardinality'); if (cardinality) { return cardinality === Cardinality.MULTIPLE 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 1595555e04..b46aa1a058 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/ChoiceInteractionEditor.vue @@ -169,22 +169,12 @@
- -
- - {{ addChoiceBtn$() }} -
-
+ />
@@ -201,13 +191,14 @@ import { useChoiceInteraction } from '../../composables/useChoiceInteraction'; import CollapsibleToolbar from '../../components/CollapsibleToolbar/index.vue'; import ValidationMessage from '../../components/ValidationMessage/index.vue'; + import AddListItemButton from '../../components/AddListItemButton/index.vue'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor'; import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; export default { name: 'ChoiceInteractionEditor', - components: { TipTapEditor, CollapsibleToolbar, ValidationMessage }, + components: { TipTapEditor, CollapsibleToolbar, ValidationMessage, AddListItemButton }, setup(props, { emit }) { const { windowIsSmall } = useKResponsiveWindow(); @@ -231,17 +222,6 @@ const palette = themePalette(); const tokens = themeTokens(); - const buttonAppearanceOverrides = computed(() => ({ - backgroundColor: palette.blue.v_50, - border: `1px dashed ${palette.blue.v_200}`, - color: `${palette.blue.v_500} !important`, - fontSize: '14px', - fontWeight: '600', - textTransform: 'none', - ':hover': { - backgroundColor: palette.blue.v_100, - }, - })); // questionType prop is not a Ref — wrap it so useChoiceInteraction can react to changes. const questionTypeRef = computed(() => props.questionType); @@ -511,7 +491,6 @@ errorEmptyChoiceContent$, errorDuplicateChoiceContent$, questionLabel$, - buttonAppearanceOverrides, }; }, @@ -693,20 +672,4 @@ cursor: pointer; } - .choice-editor-button { - justify-content: center; - width: 100%; - padding: 11px 16px !important; - margin-top: 10px; - line-height: unset !important; - border-radius: 4px !important; - } - - .add-choice-btn-content { - display: flex; - gap: 10px; - align-items: center; - justify-content: center; - } - diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js index 2e191fa3e1..966cc2dd7e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/index.js @@ -2,22 +2,4 @@ import defineInteraction from '../defineInteraction'; import ChoiceInteractionEditor from './ChoiceInteractionEditor.vue'; import { choiceInteractionDescriptor } from './ChoiceInteractionDescriptor'; -/** - * @typedef {object} ChoiceAnswer - * @property {string} id - QTI identifier, e.g. "choice_xlqTuVoq" - * @property {string} content - HTML content of the - * @property {boolean} correct - Whether this choice is in the correct response - * @property {boolean} fixed - Whether this choice is fixed (round-trip only) - */ - -/** - * @typedef {object} ChoiceState - * @property {string} prompt - HTML content of ; default "" - * @property {ChoiceAnswer[]} answers - * @property {number} maxChoices - From max-choices attribute (0 = unlimited) - * @property {number} minChoices - From min-choices attribute; default 0 - * @property {boolean} shuffle - From shuffle attribute; default false - * @property {string} orientation - From orientation attribute; default "vertical" - */ - export default defineInteraction(choiceInteractionDescriptor, ChoiceInteractionEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js index 3df9b37483..a2a52448c1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js @@ -1,15 +1,33 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { parseXML, getPromptHTML } from '../../serialization/parseItem'; +import { getPromptHTML, parseXML } from '../../serialization/parseItem'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; -import { Orientation } from '../../constants'; +import { Orientation, RESPONSE_IDENTIFIER } from '../../constants'; + +/** + * @typedef {object} ChoiceAnswer + * @property {string} id - QTI identifier, e.g. "choice_xlqTuVoq" + * @property {string} content - HTML content of the + * @property {boolean} correct - Whether this choice is in the correct response + * @property {boolean} fixed - Whether this choice is fixed (round-trip only) + */ + +/** + * @typedef {object} ChoiceState + * @property {string} prompt - HTML content of ; default "" + * @property {ChoiceAnswer[]} answers + * @property {number} maxChoices - From max-choices attribute (0 = unlimited) + * @property {number} minChoices - From min-choices attribute; default 0 + * @property {boolean} shuffle - From shuffle attribute; default false + * @property {string} orientation - From orientation attribute; default "vertical" + */ const serializer = new XMLSerializer(); export function _defaultState() { return { - responseIdentifier: generateRandomSlug('response'), + responseIdentifier: RESPONSE_IDENTIFIER, prompt: '', choices: [{ id: generateRandomSlug('choice'), content: '', correct: false }], maxChoices: 1, @@ -65,8 +83,7 @@ export function parseChoiceInteraction(bodyXml, responseDeclarations) { return _defaultState(); } - const responseIdentifier = - root.getAttribute('response-identifier') || generateRandomSlug('response'); + const responseIdentifier = root.getAttribute('response-identifier') || RESPONSE_IDENTIFIER; const maxChoices = parseInt(root.getAttribute('max-choices') ?? '0', 10); const minChoices = parseInt(root.getAttribute('min-choices') ?? '0', 10); const shuffle = root.getAttribute('shuffle') === 'true'; @@ -95,7 +112,7 @@ export function parseChoiceInteraction(bodyXml, responseDeclarations) { */ export function buildChoiceInteractionXML(state, questionType, declarationSchema) { const { - responseIdentifier = generateRandomSlug('response'), + responseIdentifier = RESPONSE_IDENTIFIER, prompt, choices, maxChoices, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js index 9568bb1afe..a14f295267 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -32,3 +32,13 @@ export const registry = Object.fromEntries(descriptors.map(d => [d.type, d])); export function getDescriptorForQuestionType(questionType) { return descriptors.find(d => d.questionTypes.includes(questionType)); } + +/** + * Find the interaction descriptor for a given QTI interaction tag name. + * + * @param {string} tagName + * @returns {import('./defineInteraction').InteractionDescriptor|undefined} + */ +export function getDescriptorForInteractionType(tagName) { + return registry[tagName]; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue index 8d6d509449..5ae6bd0aa8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue @@ -62,7 +62,7 @@ >
@@ -128,26 +129,23 @@ > {{ errorEmptyAnswerContent$() }} + + + {{ errorDuplicateAnswerContent$() }} +
- -
- - {{ addAnswerBtn$() }} -
-
+ /> @@ -158,19 +156,19 @@ import { computed, ref, watch, nextTick } from 'vue'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; - import { themePalette, themeTokens } from 'kolibri-design-system/lib/styles/theme'; - import { QTISanitizer } from '../../serialization/qti/QTISanitizer'; + import { themeTokens } from 'kolibri-design-system/lib/styles/theme'; import { qtiEditorStrings } from '../../qtiEditorStrings'; import { QuestionType, ValidationError } from '../../constants'; import { useTextEntryInteraction } from '../../composables/useTextEntryInteraction'; import ValidationMessage from 'shared/views/QTIEditor/components/ValidationMessage'; + import AddListItemButton from 'shared/views/QTIEditor/components/AddListItemButton'; import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor'; export default { name: 'TextEntryEditor', - components: { TipTapEditor, ValidationMessage }, + components: { TipTapEditor, ValidationMessage, AddListItemButton }, setup(props, { emit }) { const { windowIsSmall } = useKResponsiveWindow(); @@ -189,9 +187,9 @@ errorNoCorrectAnswer$, errorInvalidNumericValue$, errorEmptyAnswerContent$, + errorDuplicateAnswerContent$, } = qtiEditorStrings; - const palette = themePalette(); const tokens = themeTokens(); const questionTypeRef = computed(() => props.questionType); @@ -242,7 +240,7 @@ () => props.mode, newMode => { if (newMode === 'edit') { - if (!QTISanitizer.stripTags(state.value.prompt).trim()) { + if (!state.value.prompt || !state.value.prompt.trim()) { openPrompt(); } } else { @@ -280,7 +278,8 @@ for (const e of errors.value) { if ( e.code === ValidationError.INVALID_NUMERIC_VALUE || - e.code === ValidationError.EMPTY_ANSWER_CONTENT + e.code === ValidationError.EMPTY_ANSWER_CONTENT || + e.code === ValidationError.DUPLICATE_ANSWER_CONTENT ) { if (!map.has(e.id)) map.set(e.id, new Set()); map.get(e.id).add(e.code); @@ -295,44 +294,26 @@ return true; } - // Per-row focus state - const focusedAnswerId = ref(null); - - function answerBorderStyle(id) { - const hasError = answerHasError(id); - if (hasError) return { borderColor: tokens.error }; - if (focusedAnswerId.value === id) return { borderColor: tokens.primaryDark }; - return { borderColor: tokens.fineLine }; - } - - function onAnswerFocus(id) { - focusedAnswerId.value = id; - } - - function onAnswerBlur() { - focusedAnswerId.value = null; - runValidation(); - } - - const addBtnOverrides = computed(() => ({ - backgroundColor: palette.blue.v_50, - border: `1px dashed ${palette.blue.v_200}`, - color: `${palette.blue.v_500} !important`, - fontSize: '14px', - fontWeight: '600', - textTransform: 'none', - ':hover': { backgroundColor: palette.blue.v_100 }, - })); - // Mutations function onAnswerInput(id, value) { updateAnswerValue(id, value); } + // Map of answer id -> input DOM element for focusing + const answerInputRefs = ref({}); + + function setAnswerInputRef(id, el) { + if (el) { + answerInputRefs.value[id] = el; + } else { + delete answerInputRefs.value[id]; + } + } + async function onAddAnswer() { const newId = addAnswer(); await nextTick(); - const input = document.getElementById(`answer-input-${newId}`); + const input = answerInputRefs.value[newId]; if (input) input.focus(); } @@ -370,10 +351,7 @@ questionHasError, noCorrectAnswerError, answerHasError, - answerBorderStyle, - addBtnOverrides, - onAnswerFocus, - onAnswerBlur, + runValidation, handlePromptClick, closePrompt, setPrompt, @@ -395,7 +373,9 @@ errorNoCorrectAnswer$, errorInvalidNumericValue$, errorEmptyAnswerContent$, + errorDuplicateAnswerContent$, ValidationError, + setAnswerInputRef, }; }, @@ -454,9 +434,20 @@ /* Bordered answer card */ .answer-border { - border: 1px solid; + border: 1px solid v-bind('$themeTokens.fineLine'); border-radius: 4px; - transition: background-color 0.3s; + transition: + background-color 0.3s, + border-color 0.3s; + + &:not(.has-error):hover, + &:not(.has-error):focus-within { + border-color: v-bind('$themeTokens.primaryDark'); + } + + &.has-error { + border-color: v-bind('$themeTokens.error'); + } } /* Prompt card when open */ @@ -509,7 +500,7 @@ outline: none; &::placeholder { - color: var(--placeholder-color); + color: v-bind('$themeTokens.annotation'); opacity: 1; } } @@ -546,28 +537,6 @@ gap: 4px; } - .answer-group { - display: flex; - flex-direction: column; - } - - /* Add option */ - .add-answer-btn { - justify-content: center; - width: 100%; - padding: 11px 16px !important; - margin-top: 10px; - line-height: unset !important; - border-radius: 4px !important; - } - - .add-answer-btn-content { - display: flex; - gap: 10px; - align-items: center; - justify-content: center; - } - .editor { width: 100%; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js index befe553d99..1a57591909 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js @@ -1,4 +1,5 @@ import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { parseXML } from '../../serialization/parseItem'; import { parseTextEntryInteraction, buildTextEntryInteractionXML } from './parse'; import { validateTextEntryInteraction } from './validation'; @@ -9,7 +10,7 @@ import { validateTextEntryInteraction } from './validation'; * should be passed as bodyXml rather than just the interaction element, so * parse() can recover the prompt from body siblings. */ -export class TextEntryInteractionDescriptor { +class TextEntryInteractionDescriptor { constructor() { this.type = QtiInteraction.TEXT_ENTRY; this.placement = 'inline'; @@ -24,7 +25,8 @@ export class TextEntryInteractionDescriptor { /** @param {Element} el */ matches(el) { - return el.tagName.toLowerCase() === QtiInteraction.TEXT_ENTRY; + if (el.tagName.toLowerCase() === QtiInteraction.TEXT_ENTRY) return true; + return !!el.querySelector(QtiInteraction.TEXT_ENTRY); } /** @@ -38,9 +40,8 @@ export class TextEntryInteractionDescriptor { if (!responseDeclarations.length) return QuestionType.FREE_RESPONSE; try { - const doc = new DOMParser().parseFromString(responseDeclarations[0], 'text/html'); - // In text/html, the root is usually doc.body.firstElementChild - const root = doc.body.firstElementChild ?? doc.body; + const doc = parseXML(responseDeclarations[0]); + const root = doc.documentElement; const baseType = root.getAttribute('base-type'); if (baseType === BaseType.FLOAT) return QuestionType.NUMERIC; diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js index 36fd06129e..16c69667fd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js @@ -1,10 +1,9 @@ import { _defaultState, _extractAnswers, - _extractPromptHTML, parseTextEntryInteraction, buildTextEntryInteractionXML, - FREE_RESPONSE_EXPECTED_LENGTH, + DEFAULT_EXPECTED_LENGTH, } from '../parse'; import { BaseType, Cardinality, QuestionType } from '../../../constants'; @@ -48,8 +47,8 @@ describe('_defaultState', () => { expect(_defaultState().answers).toEqual([]); }); - it('returns expectedLength as 0', () => { - expect(_defaultState().expectedLength).toBe(0); + it('returns expectedLength as DEFAULT_EXPECTED_LENGTH', () => { + expect(_defaultState().expectedLength).toBe(DEFAULT_EXPECTED_LENGTH); }); }); @@ -90,27 +89,6 @@ describe('_extractAnswers', () => { }); }); -// ─── _extractPromptHTML ──────────────────────────────────────────────────── - -describe('_extractPromptHTML', () => { - it('returns empty string when no non-interaction content exists', () => { - const doc = new DOMParser().parseFromString(makeBodyXml(), 'text/xml'); - const bodyEl = doc.documentElement; - expect(_extractPromptHTML(bodyEl).trim()).toBe(''); - }); - - it('excludes the interaction container from the prompt', () => { - const doc = new DOMParser().parseFromString( - makeBodyXml({ promptHtml: '

What is 3 × 4?

' }), - 'text/xml', - ); - const bodyEl = doc.documentElement; - const html = _extractPromptHTML(bodyEl); - expect(html).not.toContain('qti-text-entry-interaction'); - expect(html).toContain('3'); - }); -}); - // ─── parseTextEntryInteraction ───────────────────────────────────────────── describe('parseTextEntryInteraction', () => { @@ -140,10 +118,10 @@ describe('parseTextEntryInteraction', () => { it('reads expectedLength from the element attribute', () => { const state = parseTextEntryInteraction( - makeBodyXml({ expectedLength: FREE_RESPONSE_EXPECTED_LENGTH }), + makeBodyXml({ expectedLength: DEFAULT_EXPECTED_LENGTH }), [FREE_RESPONSE_DECLARATION], ); - expect(state.expectedLength).toBe(FREE_RESPONSE_EXPECTED_LENGTH); + expect(state.expectedLength).toBe(DEFAULT_EXPECTED_LENGTH); }); }); @@ -159,11 +137,6 @@ describe('parseTextEntryInteraction', () => { expect(state.answers).toHaveLength(2); expect(state.answers.map(a => a.value)).toEqual(['0.5', '1.5']); }); - - it('defaults expectedLength to 0 when absent', () => { - const state = parseTextEntryInteraction(makeBodyXml(), [SINGLE_NUMERIC_DECLARATION]); - expect(state.expectedLength).toBe(0); - }); }); }); @@ -204,22 +177,24 @@ describe('buildTextEntryInteractionXML', () => { expect(bodyXml).toContain('response-identifier="RESPONSE"'); }); - it('adds expected-length for freeResponse using FREE_RESPONSE_EXPECTED_LENGTH', () => { + it('adds expected-length using DEFAULT_EXPECTED_LENGTH for all types', () => { const { bodyXml } = buildTextEntryInteractionXML( - { prompt: '', answers: [], expectedLength: 0 }, + _defaultState(), QuestionType.FREE_RESPONSE, FREE_SCHEMA, ); - expect(bodyXml).toContain(`expected-length="${FREE_RESPONSE_EXPECTED_LENGTH}"`); + expect(bodyXml).toContain(`expected-length="${DEFAULT_EXPECTED_LENGTH}"`); }); - it('does not add expected-length for numeric when expectedLength is 0', () => { + it('uses provided expectedLength instead of DEFAULT_EXPECTED_LENGTH', () => { + const state = _defaultState(); + state.expectedLength = 100; const { bodyXml } = buildTextEntryInteractionXML( - { prompt: '', answers: [{ id: 'a1', value: '12' }], expectedLength: 0 }, - QuestionType.NUMERIC, - NUMERIC_SINGLE_SCHEMA, + state, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, ); - expect(bodyXml).not.toContain('expected-length'); + expect(bodyXml).toContain(`expected-length="100"`); }); }); @@ -304,24 +279,26 @@ describe('buildTextEntryInteractionXML', () => { expect(parsed.answers).toHaveLength(1); expect(parsed.answers[0].value).toBe('12'); - expect(parsed.expectedLength).toBe(0); + expect(parsed.expectedLength).toBe(DEFAULT_EXPECTED_LENGTH); + expect(parsed.prompt).toBe(original.prompt); }); it('freeResponse: parse → buildXML → parse yields equivalent state', () => { - const original = { - prompt: '

Describe photosynthesis.

', + const state = { + prompt: '

A question prompt.

', + expectedLength: DEFAULT_EXPECTED_LENGTH, answers: [], - expectedLength: FREE_RESPONSE_EXPECTED_LENGTH, }; const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML( - original, + state, QuestionType.FREE_RESPONSE, FREE_SCHEMA, ); + const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations); - expect(parsed.answers).toEqual([]); - expect(parsed.expectedLength).toBe(FREE_RESPONSE_EXPECTED_LENGTH); + expect(parsed.prompt).toBe('

A question prompt.

'); + expect(parsed.expectedLength).toBe(DEFAULT_EXPECTED_LENGTH); }); it('multi-answer numeric: round-trip preserves all values', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js index 584f235e43..d587280220 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js @@ -2,24 +2,4 @@ import defineInteraction from '../defineInteraction'; import TextEntryEditor from './TextEntryEditor.vue'; import { textEntryInteractionDescriptor } from './TextEntryInteractionDescriptor'; -/** - * @typedef {object} TextEntryAnswer - * @property {string} id - Client-side slug (not serialized to XML) - * @property {string} value - The answer value as a string. For numeric this is a - * float/int string (e.g. "12", "0.5"); for textEntry it - * is a free-form string (e.g. "Paris"). - * @property {boolean} caseSensitive - textEntry only. When true, "H2O" ≠ "h2o". - * Always false for numeric answers. - */ - -/** - * @typedef {object} TextEntryState - * @property {string} prompt - HTML content of the question prompt; default "" - * @property {TextEntryAnswer[]} answers - Acceptable correct answers. - * Empty ([]) for freeResponse. - * @property {number} expectedLength - Value of the `expected-length` attribute. - * FREE_RESPONSE_EXPECTED_LENGTH for freeResponse; - * 0 (absent) for numeric and textEntry. - */ - export default defineInteraction(textEntryInteractionDescriptor, TextEntryEditor); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js index 98a25892fc..ff381e857c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -3,11 +3,29 @@ import { parseXML } from '../../serialization/parseItem'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; -import { BaseType } from '../../constants'; +import { BaseType, RESPONSE_IDENTIFIER } from '../../constants'; const serializer = new XMLSerializer(); -const RESPONSE_IDENTIFIER = 'RESPONSE'; +/** + * @typedef {object} TextEntryAnswer + * @property {string} id - Client-side slug (not serialized to XML) + * @property {string} value - The answer value as a string. For numeric this is a + * float/int string (e.g. "12", "0.5"); for textEntry it + * is a free-form string (e.g. "Paris"). + * @property {boolean} caseSensitive - textEntry only. When true, "H2O" ≠ "h2o". + * Always false for numeric answers. + */ + +/** + * @typedef {object} TextEntryState + * @property {string} prompt - HTML content of the question prompt; default "" + * @property {TextEntryAnswer[]} answers - Acceptable correct answers. + * Empty ([]) for freeResponse. + * @property {number} expectedLength - Value of the `expected-length` attribute. + * FREE_RESPONSE_EXPECTED_LENGTH for freeResponse; + * 0 (absent) for numeric and textEntry. + */ /** * Default `expected-length` attribute written on `` @@ -18,7 +36,7 @@ const RESPONSE_IDENTIFIER = 'RESPONSE'; * This attribute is informational — it hints to the player how wide to render * the input box. It has no effect on scoring. */ -export const FREE_RESPONSE_EXPECTED_LENGTH = 50; +export const DEFAULT_EXPECTED_LENGTH = 50; /** * Default state — used when bodyXml is absent or unparseable. @@ -29,7 +47,7 @@ export function _defaultState() { return { prompt: '', answers: [], - expectedLength: 0, + expectedLength: DEFAULT_EXPECTED_LENGTH, }; } @@ -40,34 +58,36 @@ export function _defaultState() { * buildTextEntryInteractionXML wraps body content in a single `
`, so we * look inside that wrapper for prompt children and the interaction container. * - * @param {Element} bodyEl - The `` element + * @param {Element} bodyEl - The wrapper element that contains both the + * prompt and the text entry interaction * @returns {string} */ -export function _extractPromptHTML(bodyEl) { - const interactionEl = bodyEl.querySelector('qti-text-entry-interaction'); +function extractPromptHTML(bodyEl) { + const clone = bodyEl.cloneNode(true); + const interactionEl = clone.querySelector('qti-text-entry-interaction'); if (!interactionEl) return ''; - // The interaction sits in a

; the

itself (or the interaction) is the - // container we want to exclude. + // The interaction typically sits in a

; remove the

(or just the interaction) const interactionContainer = interactionEl.parentElement; + if ( + interactionContainer && + interactionContainer !== clone && + interactionContainer.tagName.toLowerCase() === 'p' + ) { + interactionContainer.remove(); + } else { + interactionEl.remove(); + } // buildTextEntryInteractionXML wraps everything in a single outer

. - // If the body has exactly one
child, iterate its children so we - // exclude only the

holding the interaction rather than the entire wrapper. - const bodyChildren = bodyEl.children; + // If the body has exactly one

child, extract from inside it. + const bodyChildren = clone.children; const searchRoot = bodyChildren.length === 1 && bodyChildren[0].tagName.toLowerCase() === 'div' ? bodyChildren[0] - : bodyEl; + : clone; - const parts = []; - for (const child of searchRoot.childNodes) { - if (child === interactionContainer || child === interactionEl) continue; - parts.push( - child.nodeType === Node.TEXT_NODE ? child.textContent : serializer.serializeToString(child), - ); - } - return parts.join('').trim(); + return searchRoot.innerHTML.replace(/ xmlns="http:\/\/www\.w3\.org\/1999\/xhtml"/g, '').trim(); } /** @@ -90,10 +110,22 @@ export function _extractAnswers(responseDeclarations) { const isFloat = declEl.getAttribute('base-type') === BaseType.FLOAT; const isString = declEl.getAttribute('base-type') === BaseType.STRING; - if (!isFloat && !isString) return []; + if (!isFloat && !isString) { + // eslint-disable-next-line no-console + console.error( + `[QTI Editor] Unsupported text-entry base-type: ${declEl.getAttribute('base-type')}`, + ); + return []; + } const correctResponseEl = declEl.querySelector('qti-correct-response'); - if (!correctResponseEl) return []; + if (!correctResponseEl) { + if (isFloat) { + // eslint-disable-next-line no-console + console.error('[QTI Editor] Missing for numeric interaction'); + } + return []; + } const valueEls = [...correctResponseEl.querySelectorAll('qti-value')]; if (valueEls.length === 0) return []; @@ -104,7 +136,9 @@ export function _extractAnswers(responseDeclarations) { // case-sensitive is only meaningful for string base-type answers. caseSensitive: isString && el.getAttribute('case-sensitive') === 'true', })); - } catch { + } catch (err) { + // eslint-disable-next-line no-console + console.error('[QTI Editor] Failed to parse text-entry response declaration:', err); return []; } } @@ -125,22 +159,22 @@ export function parseTextEntryInteraction(bodyXml, responseDeclarations) { let bodyEl; try { - const doc = parseXML(bodyXml, 'text/html'); - // bodyXml may be the itself or wrap it — handle both. - bodyEl = - doc.body.firstElementChild && - doc.body.firstElementChild.tagName.toLowerCase() === 'qti-item-body' - ? doc.body.firstElementChild - : (doc.querySelector('qti-item-body') ?? doc.body.firstElementChild ?? doc.body); - } catch { + const doc = parseXML(bodyXml); + bodyEl = doc.documentElement; + } catch (err) { + // eslint-disable-next-line no-console + console.error('[QTI Editor] Failed to parse text-entry interaction XML:', err); return _defaultState(); } const interactionEl = bodyEl.querySelector('qti-text-entry-interaction'); if (!interactionEl) return _defaultState(); - const expectedLength = parseInt(interactionEl.getAttribute('expected-length') ?? '0', 10); - const prompt = _extractPromptHTML(bodyEl); + const expectedLength = parseInt( + interactionEl.getAttribute('expected-length') ?? String(DEFAULT_EXPECTED_LENGTH), + 10, + ); + const prompt = extractPromptHTML(bodyEl); const answers = _extractAnswers(responseDeclarations); return { prompt, answers, expectedLength }; @@ -161,16 +195,11 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch const { prompt, answers, expectedLength } = state; const { baseType, cardinality } = declarationSchema; - // Only freeResponse has an expected-length; textEntry and numeric do not. - const isFreeResponse = baseType === BaseType.STRING && answers.length === 0; - const interactionAttrs = { 'response-identifier': RESPONSE_IDENTIFIER, }; - const effectiveExpectedLength = isFreeResponse - ? FREE_RESPONSE_EXPECTED_LENGTH - : expectedLength || null; + const effectiveExpectedLength = expectedLength || DEFAULT_EXPECTED_LENGTH; if (effectiveExpectedLength) { interactionAttrs['expected-length'] = effectiveExpectedLength; } @@ -189,7 +218,11 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch // Build the body content: prompt HTML (if any) followed by the interaction paragraph. const divChildren = []; if (prompt) { - divChildren.push(buildXmlNode({ tag: 'div', innerHTML: prompt })); + const promptDoc = new DOMParser().parseFromString( + `${prompt}`, + 'text/html', + ); + divChildren.push(...promptDoc.body.childNodes); } divChildren.push(interactionParagraph); @@ -209,6 +242,7 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch }); // Write a correct-response for numeric and textEntry (not freeResponse). + const isFreeResponse = baseType === BaseType.STRING && answers.length === 0; if (!isFreeResponse && answers.length > 0) { if (baseType === BaseType.STRING) { // Build manually so we can write the optional diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js index 44a2fb6f8b..94bf5a3e0b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js @@ -21,26 +21,34 @@ export function validateTextEntryInteraction(state, questionType) { errors.push({ code: ValidationError.PROMPT_REQUIRED }); } - if (questionType === QuestionType.NUMERIC) { + if (questionType === QuestionType.NUMERIC || questionType === QuestionType.TEXT_ENTRY) { if (answers.length === 0) { errors.push({ code: ValidationError.NO_CORRECT_ANSWER }); } + const seen = new Set(); + for (const answer of answers) { - if (!floatOrIntRegex.test(answer.value.trim())) { - errors.push({ code: ValidationError.INVALID_NUMERIC_VALUE, id: answer.id }); + const val = answer.value.trim(); + + if (questionType === QuestionType.NUMERIC) { + if (!floatOrIntRegex.test(val)) { + errors.push({ code: ValidationError.INVALID_NUMERIC_VALUE, id: answer.id }); + } + } else { + if (!val) { + errors.push({ code: ValidationError.EMPTY_ANSWER_CONTENT, id: answer.id }); + } } - } - } - if (questionType === QuestionType.TEXT_ENTRY) { - if (answers.length === 0) { - errors.push({ code: ValidationError.NO_CORRECT_ANSWER }); - } + const lookupKey = + questionType === QuestionType.TEXT_ENTRY && !answer.caseSensitive ? val.toLowerCase() : val; - for (const answer of answers) { - if (!answer.value.trim()) { - errors.push({ code: ValidationError.EMPTY_ANSWER_CONTENT, id: answer.id }); + if (val) { + if (seen.has(lookupKey)) { + errors.push({ code: ValidationError.DUPLICATE_ANSWER_CONTENT, id: answer.id }); + } + seen.add(lookupKey); } } } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 3cce233bdc..dc9438010a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -137,18 +137,34 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Duplicate answer options are not allowed', context: 'Validation error when two or more answer options have identical content', }, + errorDuplicateAnswerContent: { + message: 'Duplicate answers are not allowed', + context: 'Validation error when two or more text/numeric answers have identical values', + }, numericLabel: { message: 'Numeric', context: 'Display name for a numeric text-entry question type', }, + numericDescription: { + message: 'Learners must enter a specific number or mathematical expression.', + context: 'Description for the numeric question type in the info modal', + }, textEntryLabel: { message: 'Text entry', context: 'Display name for a string text-entry question type with a required correct answer', }, + textEntryDescription: { + message: 'Learners must type a specific word or phrase. Exact matches can be required.', + context: 'Description for the text entry question type in the info modal', + }, freeResponseLabel: { message: 'Free response', context: 'Display name for a free-response text-entry question type', }, + freeResponseDescription: { + message: 'Learners can write an open-ended response. No correct answer is enforced.', + context: 'Description for the free response question type in the info modal', + }, acceptableAnswersLabel: { message: 'Acceptable answers', context: 'Section header above the list of correct numeric answer values', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 5654aebd10..4232b014a0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -90,10 +90,13 @@ export function assembleItemXml({ identifier, title, language, bodyXml, response const bodyDoc = parseXML(bodyXml || ''); const interactionNode = bodyDoc.documentElement; - const itemBodyNode = buildXmlNode({ - tag: 'qti-item-body', - children: [interactionNode], - }); + const itemBodyNode = + interactionNode.tagName.toLowerCase() === 'qti-item-body' + ? interactionNode + : buildXmlNode({ + tag: 'qti-item-body', + children: [interactionNode], + }); const assessmentItemNode = buildXmlNode({ tag: 'qti-assessment-item', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js index e1ef90a1f7..7d36d879ee 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -1,4 +1,4 @@ -import { QTI_INTERACTION_TAGS, QtiInteraction } from '../constants'; +import { QTI_INTERACTION_TAGS, INLINE_INTERACTION_TAGS } from '../constants'; const serializer = new XMLSerializer(); const parser = new DOMParser(); @@ -14,7 +14,13 @@ const parser = new DOMParser(); * contains a parsererror. HTML parsing never throws. */ export function parseXML(xmlString, mimeType = 'text/xml') { - const doc = parser.parseFromString(xmlString, mimeType); + let input = xmlString; + // Remove xmlns to ensure querySelector works. + if (mimeType === 'text/xml') { + input = xmlString.replace(/ xmlns="[^"]*"/g, ''); + } + + const doc = parser.parseFromString(input, mimeType); // DOMParser never throws — it signals failure via a node. This // only applies to XML: the HTML parser recovers silently and never emits one, @@ -88,10 +94,7 @@ export function parseItem(rawData) { .filter(d => d.getAttribute('identifier') === responseId) .map(d => serializer.serializeToString(d)); - // Inline interactions (e.g. text-entry) embed their prompt in body - // siblings, not inside the element. Pass the full so - // parse() can recover the prompt from the surrounding context. - const isInline = el.tagName.toLowerCase() === QtiInteraction.TEXT_ENTRY; + const isInline = INLINE_INTERACTION_TAGS.has(el.tagName.toLowerCase()); interactions.push({ bodyXml: isInline ? serializer.serializeToString(body) : serializer.serializeToString(el), From fdeef39e4f62c2572bac37ff7500e183e3833e79 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Wed, 29 Jul 2026 15:29:15 +0530 Subject: [PATCH 3/3] refactor: replace incorrect choice-interaction fallback with explicit parse error handling in QTI editor Signed-off-by: Abhishek-Punhani --- .../__tests__/InteractionSection.spec.js | 10 +- .../components/QTIItemEditor/index.vue | 12 ++- .../__tests__/useChoiceInteraction.spec.js | 20 ++-- .../__tests__/useInteraction.spec.js | 11 +-- .../useInteractionDescriptor.spec.js | 29 +----- .../__tests__/useTextEntryInteraction.spec.js | 12 +-- .../composables/useInteractionDescriptor.js | 52 +++++----- .../__tests__/defineInteraction.spec.js | 3 - .../choice/__tests__/parse.spec.js | 1 - .../views/QTIEditor/interactions/index.js | 15 +-- .../textEntry/TextEntryEditor.vue | 73 ++++++++------ .../TextEntryInteractionDescriptor.js | 20 ++-- .../__tests__/TextEntryEditor.spec.js | 38 +++----- .../textEntry/__tests__/parse.spec.js | 40 +++++--- .../textEntry/__tests__/validation.spec.js | 2 - .../QTIEditor/interactions/textEntry/parse.js | 96 ++++++------------- .../interactions/textEntry/validation.js | 6 +- .../views/QTIEditor/qtiEditorStrings.js | 6 +- .../serialization/__tests__/parseItem.spec.js | 15 --- .../QTIEditor/serialization/assembleItem.js | 10 +- .../QTIEditor/serialization/parseItem.js | 3 +- 21 files changed, 191 insertions(+), 283 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js index 4681c4b63c..438046186b 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 @@ -17,10 +17,6 @@ const renderSection = (props = {}) => routes: new VueRouter(), }); -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('InteractionSection', () => { describe('choice interaction', () => { it('renders the prompt from the XML via ChoiceInteractionEditor', () => { @@ -43,11 +39,9 @@ describe('InteractionSection', () => { }); describe('parse error handling', () => { - it('gracefully falls back to default interaction state when XML is malformed', () => { + it('shows a parse error when XML is malformed', () => { renderSection({ interaction: interactionBlock('not-xml<{{') }); - // It should render exactly 1 choice fallback element - const inputs = screen.queryAllByRole('radio').concat(screen.queryAllByRole('checkbox')); - expect(inputs).toHaveLength(1); + expect(screen.getByText('This question could not be loaded')).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 3a736dd468..659f052d2c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -62,6 +62,7 @@ import { computed, ref, watch } from 'vue'; import { qtiEditorStrings } from '../../qtiEditorStrings'; + import { QuestionType } from '../../constants'; import useQtiItem from '../../composables/useQtiItem'; import InteractionSection from '../InteractionSection/index.vue'; @@ -119,9 +120,14 @@ const interactionTypeLabel = computed(() => { const type = currentQuestionType.value; if (!type) return unknownTypeLabel$(); - // Dynamic access to the localized string method (e.g. 'singleSelectLabel$()') - const methodKey = `${type}Label$`; - return qtiEditorStrings[methodKey]?.() ?? unknownTypeLabel$(); + const QUESTION_TYPE_LABELS = { + [QuestionType.SINGLE_SELECT]: qtiEditorStrings.singleSelectLabel$, + [QuestionType.MULTI_SELECT]: qtiEditorStrings.multiSelectLabel$, + [QuestionType.NUMERIC]: qtiEditorStrings.numericLabel$, + [QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$, + [QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$, + }; + return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)(); }); const questionNumberAndTypeLabel = computed(() => diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js index dd66283d0f..3c85a35f11 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js @@ -30,15 +30,11 @@ function makeBlock(choices, questionType = QuestionType.SINGLE_SELECT) { } function setup(choices, questionType = QuestionType.SINGLE_SELECT) { - const qt = ref(questionType); + const questionTypeRef = ref(questionType); const block = makeBlock(choices, questionType); - return { qt, ...useChoiceInteraction(block, qt) }; + return { questionTypeRef, ...useChoiceInteraction(block, questionTypeRef) }; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('useChoiceInteraction', () => { describe('addChoice()', () => { it('appends a new choice to the list', () => { @@ -118,33 +114,33 @@ describe('useChoiceInteraction', () => { describe('toggleCorrectChoice()', () => { it('singleSelect: sets only the target as correct and clears others', () => { - const { state, toggleCorrectChoice, qt } = setup([ + const { state, toggleCorrectChoice, questionTypeRef } = setup([ makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false }), ]); - qt.value = QuestionType.SINGLE_SELECT; + questionTypeRef.value = QuestionType.SINGLE_SELECT; toggleCorrectChoice('b'); expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true); expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false); }); it('multiSelect: toggles only the target, leaves others unchanged', () => { - const { state, toggleCorrectChoice, qt } = setup( + const { state, toggleCorrectChoice, questionTypeRef } = setup( [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })], QuestionType.MULTI_SELECT, ); - qt.value = QuestionType.MULTI_SELECT; + questionTypeRef.value = QuestionType.MULTI_SELECT; toggleCorrectChoice('b'); expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true); expect(state.value.choices.find(a => a.id === 'a').correct).toBe(true); }); it('multiSelect: toggles correct off when already correct', () => { - const { state, toggleCorrectChoice, qt } = setup( + const { state, toggleCorrectChoice, questionTypeRef } = setup( [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })], QuestionType.MULTI_SELECT, ); - qt.value = QuestionType.MULTI_SELECT; + questionTypeRef.value = QuestionType.MULTI_SELECT; toggleCorrectChoice('a'); expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false); expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js index e5c9bc546c..57dcd875de 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js @@ -9,10 +9,6 @@ jest.mock('lodash/debounce', () => { }); }); -// --------------------------------------------------------------------------- -// Minimal descriptor stub -// --------------------------------------------------------------------------- - function makeDescriptor({ parseReturn = {}, buildReturn = null, validateReturn = [] } = {}) { return { parse: jest.fn(() => parseReturn), @@ -76,7 +72,6 @@ describe('useInteraction', () => { questionType, ); - // Because of immediate: true and mocked debounce, validate runs immediately expect(errors.value).toEqual(validateReturn); }); @@ -92,7 +87,6 @@ describe('useInteraction', () => { expect(errors.value).toEqual([]); - // Change the mock to return something else to simulate state change descriptor.validate.mockReturnValueOnce([{ code: 'NEW_ERROR' }]); runValidation(); @@ -163,15 +157,12 @@ describe('useInteraction', () => { questionType, ); - // With mocked debounce, validate fires synchronously on immediate watcher. - // errors are already populated from the initial watcher run. expect(errors.value).toEqual(validateReturn); - // Reset mock and update state — validate should be called again. descriptor.validate.mockReset(); descriptor.validate.mockReturnValue([{ code: 'UPDATED_ERROR' }]); state.value = { prompt: 'updated' }; - await nextTick(); // flush Vue watcher queue + await nextTick(); expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'singleSelect'); expect(errors.value).toEqual([{ code: 'UPDATED_ERROR' }]); 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 1d8a3f748f..0549f256fd 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 @@ -16,12 +16,6 @@ import { jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); -// --------------------------------------------------------------------------- -// Helper: renders a wrapper component that runs the composable inside setup(). -// Because questionType is now set on mount (not as a computed), we must wait -// for the component to mount before checking reactive values. -// --------------------------------------------------------------------------- - function renderDescriptor(initialXml = null, declarations = []) { const interactionRef = ref( initialXml ? { bodyXml: initialXml, responseDeclarations: declarations } : null, @@ -40,10 +34,6 @@ function renderDescriptor(initialXml = null, declarations = []) { return { result, interactionRef }; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('useInteractionDescriptor', () => { describe('with a valid choice interaction', () => { it('resolves the Choice descriptor by its type', async () => { @@ -110,11 +100,10 @@ describe('useInteractionDescriptor', () => { describe('with malformed XML', () => { it('returns a parse error for malformed XML', async () => { - // inferFromXml uses text/xml which throws a parser error for malformed fragments. const { result } = renderDescriptor(' { @@ -132,22 +121,14 @@ describe('useInteractionDescriptor', () => { expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT); expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE); - // Simulate user switching question type via the selector result.questionType.value = QuestionType.MULTI_SELECT; await nextTick(); - // Still the same descriptor (choice handles both), but questionType changed expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE); expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT); }); }); - // --------------------------------------------------------------------------- - // Regression: inline placement — bodyXml is a full - // Before the fix, documentElement was and no descriptor - // matched it, so the code fell back to the choice descriptor for every - // text-entry item, showing them as "Multiple Choice / Single Choice". - // --------------------------------------------------------------------------- describe('with an inline text-entry interaction (bodyXml is qti-item-body)', () => { it('resolves the TextEntry descriptor for a numeric declaration', async () => { const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_NUMERIC_DECL_XML]); @@ -169,13 +150,5 @@ describe('useInteractionDescriptor', () => { expect(result.descriptor.value.type).toBe(QtiInteraction.TEXT_ENTRY); expect(result.questionType.value).toBe(QuestionType.FREE_RESPONSE); }); - - it('does NOT resolve as choice when bodyXml is a qti-item-body (regression)', async () => { - // This is the exact bug: without the fix every text-entry item was - // treated as a choice interaction because documentElement was . - const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_FREE_DECL_XML]); - await nextTick(); - expect(result.descriptor.value.type).not.toBe(QtiInteraction.CHOICE); - }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js index 402378564a..9da18c9334 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js @@ -2,8 +2,6 @@ import { ref } from 'vue'; import { useTextEntryInteraction } from '../useTextEntryInteraction'; import { QuestionType, ValidationError } from '../../constants'; -// ─── Helpers ─────────────────────────────────────────────────────────────── - function makeNumericBlock(answerValues = ['12']) { const values = answerValues.map(v => `${v}`).join(''); const cardinality = answerValues.length > 1 ? 'multiple' : 'single'; @@ -21,17 +19,15 @@ function makeFreeBlock() { } function setupNumeric(answerValues = ['12']) { - const qt = ref(QuestionType.NUMERIC); - return { qt, ...useTextEntryInteraction(makeNumericBlock(answerValues), qt) }; + const questionType = ref(QuestionType.NUMERIC); + return { questionType, ...useTextEntryInteraction(makeNumericBlock(answerValues), questionType) }; } function setupFree() { - const qt = ref(QuestionType.FREE_RESPONSE); - return { qt, ...useTextEntryInteraction(makeFreeBlock(), qt) }; + const questionType = ref(QuestionType.FREE_RESPONSE); + return { questionType, ...useTextEntryInteraction(makeFreeBlock(), questionType) }; } -// ─── Tests ───────────────────────────────────────────────────────────────── - describe('useTextEntryInteraction', () => { describe('initial state', () => { it('parses existing answers from the block', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js index 34e3e960cc..76b40f311b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js @@ -1,23 +1,21 @@ -import { computed, ref, onMounted } from 'vue'; +import { computed, ref } from 'vue'; import { parseXML } from '../serialization/parseItem'; import { descriptors, registry, DEFAULT_INTERACTION } from '../interactions/index'; +import { qtiEditorStrings } from '../qtiEditorStrings'; + +const { errorParsingQuestion$ } = qtiEditorStrings; /** - * Composable that manages the question type and descriptor for a single - * interaction block. + * Composable that resolves the interaction descriptor and question type for a + * single interaction block. * * @param {import('vue').Ref} interactionRef * Ref to the interaction block { bodyXml, responseDeclarations }. */ export default function useInteractionDescriptor(interactionRef) { - /** Writable question type — set from XML on mount, then driven by UI selections. */ - const questionType = ref(null); - /** Any parse error message from the initial XML parse; null when clean. */ - const parseError = ref(null); - /** - * Parses bodyXml and returns { descriptor, questionType } without touching - * any reactive state — pure helper used only on mount. + * Parses bodyXml and returns the matching descriptor, resolved + * question type, and any parse error without touching reactive state. */ function inferFromXml(xml, declarations) { if (!xml) { @@ -33,29 +31,35 @@ export default function useInteractionDescriptor(interactionRef) { error: null, }; } catch (e) { + // eslint-disable-next-line no-console + console.error('[QTI] Failed to parse interaction XML:', e.message); return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, - error: e.message, + error: errorParsingQuestion$(), }; } } - /** Parse XML once when the host component mounts to set the initial state. */ - onMounted(() => { - const inferred = inferFromXml( - interactionRef.value?.bodyXml, - interactionRef.value?.responseDeclarations, - ); - questionType.value = inferred.questionType; - parseError.value = inferred.error; - }); + /** + * Parse the initial XML synchronously during component setup. + * + * This ensures `questionType` is immediately available for downstream components + * on first render, avoiding prop validation warnings that would occur if + * initialization was deferred to a lifecycle hook. + */ + const initial = inferFromXml( + interactionRef.value?.bodyXml, + interactionRef.value?.responseDeclarations, + ); + + /** Writable ref driven by UI selections after initial parse. */ + const questionType = ref(initial.questionType); + const parseError = ref(initial.error); /** - * Descriptor is derived from the current questionType ref. - * Uses each descriptor's `questionTypes` array so the lookup stays accurate - * when the user changes the question type via the selector. - * Falls back to the default descriptor when no match is found. + * Derived from questionType so the descriptor updates when the user switches + * question types via the selector. Falls back to the default when no match. */ const descriptor = computed( () => 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 33572d9db8..f56266a565 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 @@ -31,8 +31,6 @@ describe('defineInteraction', () => { expect(descriptor.editorComponent).toBe(component); }); - // Keys that must be present on the descriptor itself (editorComponent is injected - // by defineInteraction from the second argument, so it is excluded here). const REQUIRED_DESCRIPTOR_KEYS = [ 'type', 'placement', @@ -56,7 +54,6 @@ describe('defineInteraction', () => { it('throws when editorComponent is not passed as the second argument', () => { const descriptor = makeValidDescriptor(); - // Calling with no second arg means editorComponent is undefined — still flagged. expect(() => defineInteraction(descriptor)).toThrow(/missing required key "editorComponent"/i); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js index ed6f7ef246..833bbbc638 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js @@ -144,7 +144,6 @@ describe('parse()', () => { }); describe('buildXML()', () => { - // Helper: parse an XML string and return the document root element. function parseXmlString(xml) { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'text/xml'); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js index a14f295267..0260e0fbfc 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -9,9 +9,8 @@ import textEntryDescriptor from './textEntry/index'; 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. + * Ordered list of all registered interaction descriptors. + * Searched in order; the first whose `matches(el)` returns true wins. */ export const descriptors = [choiceDescriptor, textEntryDescriptor]; @@ -32,13 +31,3 @@ export const registry = Object.fromEntries(descriptors.map(d => [d.type, d])); export function getDescriptorForQuestionType(questionType) { return descriptors.find(d => d.questionTypes.includes(questionType)); } - -/** - * Find the interaction descriptor for a given QTI interaction tag name. - * - * @param {string} tagName - * @returns {import('./defineInteraction').InteractionDescriptor|undefined} - */ -export function getDescriptorForInteractionType(tagName) { - return registry[tagName]; -} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue index 5ae6bd0aa8..0a38335f62 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue @@ -14,9 +14,16 @@
@@ -73,16 +80,30 @@ :id="`answer-input-${answer.id}`" :ref="el => setAnswerInputRef(answer.id, el)" :value="answer.value" + :aria-label="isNumeric ? answerValuePlaceholder$() : answerTextPlaceholder$()" :placeholder="isNumeric ? answerValuePlaceholder$() : answerTextPlaceholder$()" class="answer-native-input" dir="auto" - maxlength="250" + :maxlength="DEFAULT_EXPECTED_LENGTH" + :disabled="mode !== 'edit'" :style="{ color: $themeTokens.text, }" @input="e => onAnswerInput(answer.id, e.target.value)" - @blur="runValidation" + @focus="focusedAnswerId = answer.id" + @blur=" + focusedAnswerId = null; + runValidation(); + " + > +
`.trim(); @@ -36,15 +34,17 @@ function makeBodyXml({ promptHtml = '', expectedLength = null } = {}) { return `
${promptHtml ? `
${promptHtml}
` : ''}

`; } -// ─── _defaultState ───────────────────────────────────────────────────────── - describe('_defaultState', () => { it('returns prompt as empty string', () => { expect(_defaultState().prompt).toBe(''); }); - it('returns answers as empty array', () => { - expect(_defaultState().answers).toEqual([]); + it('returns answers as an array with one empty answer seeded', () => { + const answers = _defaultState().answers; + expect(answers).toHaveLength(1); + expect(answers[0].value).toBe(''); + expect(answers[0].caseSensitive).toBe(false); + expect(typeof answers[0].id).toBe('string'); }); it('returns expectedLength as DEFAULT_EXPECTED_LENGTH', () => { @@ -52,8 +52,6 @@ describe('_defaultState', () => { }); }); -// ─── _extractAnswers ─────────────────────────────────────────────────────── - describe('_extractAnswers', () => { it('returns [] when no declaration is provided', () => { expect(_extractAnswers([])).toEqual([]); @@ -89,20 +87,25 @@ describe('_extractAnswers', () => { }); }); -// ─── parseTextEntryInteraction ───────────────────────────────────────────── - describe('parseTextEntryInteraction', () => { it('returns defaultState when bodyXml is empty', () => { - expect(parseTextEntryInteraction('', [])).toEqual(_defaultState()); + const state = parseTextEntryInteraction('', []); + expect(state.prompt).toBe(''); + expect(state.expectedLength).toBe(DEFAULT_EXPECTED_LENGTH); + expect(state.answers).toHaveLength(1); }); it('returns defaultState when bodyXml is unparseable', () => { - expect(parseTextEntryInteraction('< { const bodyXml = '

No interaction here

'; - expect(parseTextEntryInteraction(bodyXml, [])).toEqual(_defaultState()); + const state = parseTextEntryInteraction(bodyXml, []); + expect(state.prompt).toBe(''); + expect(state.answers).toHaveLength(1); }); describe('freeResponse', () => { @@ -140,8 +143,6 @@ describe('parseTextEntryInteraction', () => { }); }); -// ─── buildTextEntryInteractionXML ────────────────────────────────────────── - describe('buildTextEntryInteractionXML', () => { const FREE_SCHEMA = { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE }; const NUMERIC_SINGLE_SCHEMA = { baseType: BaseType.FLOAT, cardinality: Cardinality.SINGLE }; @@ -261,6 +262,15 @@ describe('buildTextEntryInteractionXML', () => { expect(responseDeclarations[0]).toContain('>0.5<'); expect(responseDeclarations[0]).toContain('>1.5<'); }); + + it('numeric with 0 answers omits (empty element is invalid per XSD)', () => { + const { responseDeclarations } = buildTextEntryInteractionXML( + { prompt: '', answers: [], expectedLength: 0 }, + QuestionType.NUMERIC, + NUMERIC_SINGLE_SCHEMA, + ); + expect(responseDeclarations[0]).not.toContain('qti-correct-response'); + }); }); describe('round-trip', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js index fd0f072e41..cd4126bdbb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js @@ -147,8 +147,6 @@ describe('validateTextEntryInteraction', () => { }); it('does not flag INVALID_NUMERIC_VALUE for freeResponse', () => { - // freeResponse state with a clearly non-numeric "answer" — should not error - // (answers is always [] for freeResponse, but just in case) const errors = validateTextEntryInteraction( { ...VALID_FREE_STATE, answers: [{ id: 'a1', value: 'abc' }] }, QuestionType.FREE_RESPONSE, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js index ff381e857c..efe173cc2a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -3,7 +3,8 @@ import { parseXML } from '../../serialization/parseItem'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; -import { BaseType, RESPONSE_IDENTIFIER } from '../../constants'; +import { BaseType, QuestionType, RESPONSE_IDENTIFIER } from '../../constants'; +import { CAPABILITY } from '../../serialization/qti/declarations/capabilities'; const serializer = new XMLSerializer(); @@ -23,18 +24,10 @@ const serializer = new XMLSerializer(); * @property {TextEntryAnswer[]} answers - Acceptable correct answers. * Empty ([]) for freeResponse. * @property {number} expectedLength - Value of the `expected-length` attribute. - * FREE_RESPONSE_EXPECTED_LENGTH for freeResponse; - * 0 (absent) for numeric and textEntry. */ /** - * Default `expected-length` attribute written on `` - * for free-response items. The QTI 3.0 spec does not mandate a value; 50 is a - * widely-used authoring convention (sourced from the textEntry interaction PR - * description). Change this constant if your platform uses a different default. - * - * This attribute is informational — it hints to the player how wide to render - * the input box. It has no effect on scoring. + * Default `expected-length` attribute for ``. */ export const DEFAULT_EXPECTED_LENGTH = 50; @@ -46,20 +39,16 @@ export const DEFAULT_EXPECTED_LENGTH = 50; export function _defaultState() { return { prompt: '', - answers: [], + answers: [{ id: generateRandomSlug('answer'), value: '', caseSensitive: false }], expectedLength: DEFAULT_EXPECTED_LENGTH, }; } /** - * Serialize body children to an HTML string, excluding the element that - * directly contains the ``. - * - * buildTextEntryInteractionXML wraps body content in a single `
`, so we - * look inside that wrapper for prompt children and the interaction container. + * Serializes the body element children to an HTML string, excluding the + * element that directly contains the ``. * - * @param {Element} bodyEl - The wrapper element that contains both the - * prompt and the text entry interaction + * @param {Element} bodyEl - The `` element * @returns {string} */ function extractPromptHTML(bodyEl) { @@ -67,7 +56,6 @@ function extractPromptHTML(bodyEl) { const interactionEl = clone.querySelector('qti-text-entry-interaction'); if (!interactionEl) return ''; - // The interaction typically sits in a

; remove the

(or just the interaction) const interactionContainer = interactionEl.parentElement; if ( interactionContainer && @@ -79,15 +67,7 @@ function extractPromptHTML(bodyEl) { interactionEl.remove(); } - // buildTextEntryInteractionXML wraps everything in a single outer

. - // If the body has exactly one
child, extract from inside it. - const bodyChildren = clone.children; - const searchRoot = - bodyChildren.length === 1 && bodyChildren[0].tagName.toLowerCase() === 'div' - ? bodyChildren[0] - : clone; - - return searchRoot.innerHTML.replace(/ xmlns="http:\/\/www\.w3\.org\/1999\/xhtml"/g, '').trim(); + return clone.innerHTML.trim(); } /** @@ -133,7 +113,6 @@ export function _extractAnswers(responseDeclarations) { return valueEls.map(el => ({ id: generateRandomSlug('answer'), value: el.textContent.trim(), - // case-sensitive is only meaningful for string base-type answers. caseSensitive: isString && el.getAttribute('case-sensitive') === 'true', })); } catch (err) { @@ -183,11 +162,8 @@ export function parseTextEntryInteraction(bodyXml, responseDeclarations) { /** * Serialize TextEntryState → { bodyXml, responseDeclarations }. * - * The output `bodyXml` is a full `` so that parseItem can - * store it and the next parse() call can extract the prompt correctly. - * * @param {TextEntryState} state - * @param {string} questionType - QuestionType.NUMERIC | QuestionType.FREE_RESPONSE + * @param {string} questionType - One of QuestionType.NUMERIC, TEXT_ENTRY, FREE_RESPONSE * @param {{ baseType: string, cardinality: string }} declarationSchema * @returns {{ bodyXml: string, responseDeclarations: string[] }} */ @@ -215,22 +191,15 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch children: [interactionEl], }); - // Build the body content: prompt HTML (if any) followed by the interaction paragraph. - const divChildren = []; + // Build body children: prompt HTML nodes (if any) followed by the interaction paragraph. + const bodyChildren = []; if (prompt) { - const promptDoc = new DOMParser().parseFromString( - `${prompt}`, - 'text/html', - ); - divChildren.push(...promptDoc.body.childNodes); + const promptDoc = parseXML(`${prompt}`, 'text/html'); + bodyChildren.push(...promptDoc.body.childNodes); } - divChildren.push(interactionParagraph); - - const bodyEl = buildXmlNode({ - tag: 'qti-item-body', - children: [buildXmlNode({ tag: 'div', children: divChildren })], - }); + bodyChildren.push(interactionParagraph); + const bodyEl = buildXmlNode({ tag: 'qti-item-body', children: bodyChildren }); const bodyXml = serializer.serializeToString(bodyEl); // Build the response declaration. @@ -241,26 +210,23 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch tag: 'qti-response-declaration', }); - // Write a correct-response for numeric and textEntry (not freeResponse). - const isFreeResponse = baseType === BaseType.STRING && answers.length === 0; - if (!isFreeResponse && answers.length > 0) { - if (baseType === BaseType.STRING) { - // Build manually so we can write the optional - // case-sensitive="true" attribute on individual elements. - const valueEls = answers.map(a => { - const valueEl = buildXmlNode({ tag: 'qti-value', children: [a.value] }); - if (a.caseSensitive) { - valueEl.setAttribute('case-sensitive', 'true'); - } - return valueEl; - }); - const correctResponseEl = buildXmlNode({ - tag: 'qti-correct-response', - children: valueEls, + if (questionType === QuestionType.FREE_RESPONSE) { + // No correct answer + } else if (baseType === BaseType.STRING) { + if (answers.length > 0) { + const textAnswers = answers.slice(); + declaration.registerCapability(CAPABILITY.CORRECT_RESPONSE, { + get: () => textAnswers.map(a => a.value), + getXML: () => { + const valueEls = textAnswers.map(a => + buildXmlNode({ tag: 'qti-value', children: [a.value] }), + ); + return buildXmlNode({ tag: 'qti-correct-response', children: valueEls }); + }, }); - declaration.getXML().appendChild(correctResponseEl); - } else { - // Numeric: delegate to CorrectResponse (no per-value attrs needed). + } + } else { + if (answers.length > 0) { new CorrectResponse( answers.map(a => a.value), declaration, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js index 94bf5a3e0b..ec29868555 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js @@ -41,8 +41,12 @@ export function validateTextEntryInteraction(state, questionType) { } } - const lookupKey = + const normalizedVal = questionType === QuestionType.TEXT_ENTRY && !answer.caseSensitive ? val.toLowerCase() : val; + const lookupKey = + questionType === QuestionType.TEXT_ENTRY + ? `${normalizedVal}|${answer.caseSensitive}` + : normalizedVal; if (val) { if (seen.has(lookupKey)) { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index dc9438010a..8e00d238ba 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -182,7 +182,7 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { context: 'Button that appends a new answer row', }, deleteAnswerBtn: { - message: 'Delete answer', + message: 'Delete answer {number}', context: 'Accessible label for the delete icon button next to an answer row', }, caseSensitiveLabel: { @@ -202,4 +202,8 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { message: 'Must be a valid number (e.g. 12, 0.5, -3.14)', context: 'Validation error shown when an answer value is not a valid number', }, + errorParsingQuestion: { + message: 'This question could not be loaded', + context: 'Shown in place of the interaction editor when the QTI XML fails to parse', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js index 7734e81ea6..48b8941460 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js @@ -2,10 +2,7 @@ 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); @@ -61,10 +55,7 @@ describe('parseXML', () => { }); }); -// --------------------------------------------------------------------------- // parseItem — meta extraction -// --------------------------------------------------------------------------- - describe('parseItem — meta', () => { it('returns an object with the top-level item attributes', () => { const model = parseItem(VALID_CHOICE_ITEM_DOCUMENT); @@ -83,10 +74,7 @@ describe('parseItem — meta', () => { }); }); -// --------------------------------------------------------------------------- // parseItem — interaction blocks -// --------------------------------------------------------------------------- - describe('parseItem — interaction blocks', () => { it('extracts interactions and their corresponding response declarations', () => { const model = parseItem(VALID_CHOICE_ITEM_DOCUMENT); @@ -109,10 +97,7 @@ describe('parseItem — interaction blocks', () => { }); }); -// --------------------------------------------------------------------------- // parseItem — response declaration matching -// --------------------------------------------------------------------------- - describe('parseItem — response declaration matching', () => { it('matches each interaction to its own response declaration', () => { const model = parseItem(TWO_INTERACTIONS_DOCUMENT); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 4232b014a0..48295a8889 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -86,16 +86,14 @@ export function assembleItemXml({ identifier, title, language, bodyXml, response return doc.documentElement; }); - // Parse the interaction body XML into a DOM node. const bodyDoc = parseXML(bodyXml || ''); - const interactionNode = bodyDoc.documentElement; - + const bodyRoot = bodyDoc.documentElement; const itemBodyNode = - interactionNode.tagName.toLowerCase() === 'qti-item-body' - ? interactionNode + bodyRoot.tagName.toLowerCase() === 'qti-item-body' + ? bodyRoot : buildXmlNode({ tag: 'qti-item-body', - children: [interactionNode], + children: [bodyRoot], }); const assessmentItemNode = buildXmlNode({ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js index 7d36d879ee..d0bccb018c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/parseItem.js @@ -15,9 +15,8 @@ const parser = new DOMParser(); */ export function parseXML(xmlString, mimeType = 'text/xml') { let input = xmlString; - // Remove xmlns to ensure querySelector works. if (mimeType === 'text/xml') { - input = xmlString.replace(/ xmlns="[^"]*"/g, ''); + input = xmlString.replace(/ xmlns="[^"]*"/, ''); } const doc = parser.parseFromString(input, mimeType);