From f66667047bad6fda443adae932dce3c59a586164 Mon Sep 17 00:00:00 2001 From: Abhishek-Punhani Date: Thu, 23 Jul 2026 00:31:38 +0530 Subject: [PATCH 1/2] 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/2] 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),