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/components/InteractionSection/__tests__/InteractionSection.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js index 4681c4b63c..438046186b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/__tests__/InteractionSection.spec.js @@ -17,10 +17,6 @@ const renderSection = (props = {}) => routes: new VueRouter(), }); -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('InteractionSection', () => { describe('choice interaction', () => { it('renders the prompt from the XML via ChoiceInteractionEditor', () => { @@ -43,11 +39,9 @@ describe('InteractionSection', () => { }); describe('parse error handling', () => { - it('gracefully falls back to default interaction state when XML is malformed', () => { + it('shows a parse error when XML is malformed', () => { renderSection({ interaction: interactionBlock('not-xml<{{') }); - // It should render exactly 1 choice fallback element - const inputs = screen.queryAllByRole('radio').concat(screen.queryAllByRole('checkbox')); - expect(inputs).toHaveLength(1); + expect(screen.getByText('This question could not be loaded')).toBeInTheDocument(); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index be4fa55cae..9e4a2fd42c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -4,6 +4,8 @@ import QTIItemEditor from '../index.vue'; import { qtiEditorStrings } from '../../../qtiEditorStrings'; import { AssessmentItemTypes } from '../../../constants'; +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); + const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings; const defaultProps = { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index 1c148488f7..659f052d2c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -117,18 +117,18 @@ */ const currentQuestionType = ref(null); - /** - * Maps each QuestionType to its localized display label. - * Add new entries here as more question types are introduced. - */ - const QUESTION_TYPE_LABELS = { - [QuestionType.SINGLE_SELECT]: () => qtiEditorStrings.singleChoiceLabel$(), - [QuestionType.MULTI_SELECT]: () => qtiEditorStrings.multipleChoiceLabel$(), - }; - - const interactionTypeLabel = computed( - () => QUESTION_TYPE_LABELS[currentQuestionType.value]?.() ?? unknownTypeLabel$(), - ); + const interactionTypeLabel = computed(() => { + const type = currentQuestionType.value; + if (!type) return unknownTypeLabel$(); + const QUESTION_TYPE_LABELS = { + [QuestionType.SINGLE_SELECT]: qtiEditorStrings.singleSelectLabel$, + [QuestionType.MULTI_SELECT]: qtiEditorStrings.multiSelectLabel$, + [QuestionType.NUMERIC]: qtiEditorStrings.numericLabel$, + [QuestionType.TEXT_ENTRY]: qtiEditorStrings.textEntryLabel$, + [QuestionType.FREE_RESPONSE]: qtiEditorStrings.freeResponseLabel$, + }; + return (QUESTION_TYPE_LABELS[type] ?? unknownTypeLabel$)(); + }); const questionNumberAndTypeLabel = computed(() => questionNumberAndTypeLabel$({ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js index dd66283d0f..3c85a35f11 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useChoiceInteraction.spec.js @@ -30,15 +30,11 @@ function makeBlock(choices, questionType = QuestionType.SINGLE_SELECT) { } function setup(choices, questionType = QuestionType.SINGLE_SELECT) { - const qt = ref(questionType); + const questionTypeRef = ref(questionType); const block = makeBlock(choices, questionType); - return { qt, ...useChoiceInteraction(block, qt) }; + return { questionTypeRef, ...useChoiceInteraction(block, questionTypeRef) }; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('useChoiceInteraction', () => { describe('addChoice()', () => { it('appends a new choice to the list', () => { @@ -118,33 +114,33 @@ describe('useChoiceInteraction', () => { describe('toggleCorrectChoice()', () => { it('singleSelect: sets only the target as correct and clears others', () => { - const { state, toggleCorrectChoice, qt } = setup([ + const { state, toggleCorrectChoice, questionTypeRef } = setup([ makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false }), ]); - qt.value = QuestionType.SINGLE_SELECT; + questionTypeRef.value = QuestionType.SINGLE_SELECT; toggleCorrectChoice('b'); expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true); expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false); }); it('multiSelect: toggles only the target, leaves others unchanged', () => { - const { state, toggleCorrectChoice, qt } = setup( + const { state, toggleCorrectChoice, questionTypeRef } = setup( [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: false })], QuestionType.MULTI_SELECT, ); - qt.value = QuestionType.MULTI_SELECT; + questionTypeRef.value = QuestionType.MULTI_SELECT; toggleCorrectChoice('b'); expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true); expect(state.value.choices.find(a => a.id === 'a').correct).toBe(true); }); it('multiSelect: toggles correct off when already correct', () => { - const { state, toggleCorrectChoice, qt } = setup( + const { state, toggleCorrectChoice, questionTypeRef } = setup( [makeAnswer({ id: 'a', correct: true }), makeAnswer({ id: 'b', correct: true })], QuestionType.MULTI_SELECT, ); - qt.value = QuestionType.MULTI_SELECT; + questionTypeRef.value = QuestionType.MULTI_SELECT; toggleCorrectChoice('a'); expect(state.value.choices.find(a => a.id === 'a').correct).toBe(false); expect(state.value.choices.find(a => a.id === 'b').correct).toBe(true); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js index e5c9bc546c..57dcd875de 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js @@ -9,10 +9,6 @@ jest.mock('lodash/debounce', () => { }); }); -// --------------------------------------------------------------------------- -// Minimal descriptor stub -// --------------------------------------------------------------------------- - function makeDescriptor({ parseReturn = {}, buildReturn = null, validateReturn = [] } = {}) { return { parse: jest.fn(() => parseReturn), @@ -76,7 +72,6 @@ describe('useInteraction', () => { questionType, ); - // Because of immediate: true and mocked debounce, validate runs immediately expect(errors.value).toEqual(validateReturn); }); @@ -92,7 +87,6 @@ describe('useInteraction', () => { expect(errors.value).toEqual([]); - // Change the mock to return something else to simulate state change descriptor.validate.mockReturnValueOnce([{ code: 'NEW_ERROR' }]); runValidation(); @@ -163,15 +157,12 @@ describe('useInteraction', () => { questionType, ); - // With mocked debounce, validate fires synchronously on immediate watcher. - // errors are already populated from the initial watcher run. expect(errors.value).toEqual(validateReturn); - // Reset mock and update state — validate should be called again. descriptor.validate.mockReset(); descriptor.validate.mockReturnValue([{ code: 'UPDATED_ERROR' }]); state.value = { prompt: 'updated' }; - await nextTick(); // flush Vue watcher queue + await nextTick(); expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'singleSelect'); expect(errors.value).toEqual([{ code: 'UPDATED_ERROR' }]); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js index 7b34f003a0..0549f256fd 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteractionDescriptor.spec.js @@ -8,13 +8,13 @@ import { CHOICE_SINGLE_SELECT_XML, CHOICE_MULTI_SELECT_XML, UNKNOWN_INTERACTION_XML, + TEXT_ENTRY_BODY_XML, + TEXT_ENTRY_NUMERIC_DECL_XML, + TEXT_ENTRY_STRING_DECL_XML, + TEXT_ENTRY_FREE_DECL_XML, } from '../../utils/testingFixtures'; -// --------------------------------------------------------------------------- -// Helper: renders a wrapper component that runs the composable inside setup(). -// Because questionType is now set on mount (not as a computed), we must wait -// for the component to mount before checking reactive values. -// --------------------------------------------------------------------------- +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); function renderDescriptor(initialXml = null, declarations = []) { const interactionRef = ref( @@ -34,10 +34,6 @@ function renderDescriptor(initialXml = null, declarations = []) { return { result, interactionRef }; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe('useInteractionDescriptor', () => { describe('with a valid choice interaction', () => { it('resolves the Choice descriptor by its type', async () => { @@ -103,11 +99,11 @@ describe('useInteractionDescriptor', () => { }); describe('with malformed XML', () => { - it('returns a non-null parseError', async () => { + it('returns a parse error for malformed XML', async () => { const { result } = renderDescriptor(' { @@ -125,13 +121,34 @@ describe('useInteractionDescriptor', () => { expect(result.questionType.value).toBe(QuestionType.SINGLE_SELECT); expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE); - // Simulate user switching question type via the selector result.questionType.value = QuestionType.MULTI_SELECT; await nextTick(); - // Still the same descriptor (choice handles both), but questionType changed expect(result.descriptor.value.type).toBe(QtiInteraction.CHOICE); expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT); }); }); + + describe('with an inline text-entry interaction (bodyXml is qti-item-body)', () => { + it('resolves the TextEntry descriptor for a numeric declaration', async () => { + const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_NUMERIC_DECL_XML]); + await nextTick(); + expect(result.descriptor.value.type).toBe(QtiInteraction.TEXT_ENTRY); + expect(result.questionType.value).toBe(QuestionType.NUMERIC); + }); + + it('resolves the TextEntry descriptor for a string + correct-response (textEntry)', async () => { + const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_STRING_DECL_XML]); + await nextTick(); + expect(result.descriptor.value.type).toBe(QtiInteraction.TEXT_ENTRY); + expect(result.questionType.value).toBe(QuestionType.TEXT_ENTRY); + }); + + it('resolves the TextEntry descriptor for a string with no correct-response (freeResponse)', async () => { + const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_FREE_DECL_XML]); + await nextTick(); + expect(result.descriptor.value.type).toBe(QtiInteraction.TEXT_ENTRY); + expect(result.questionType.value).toBe(QuestionType.FREE_RESPONSE); + }); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js new file mode 100644 index 0000000000..9da18c9334 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js @@ -0,0 +1,138 @@ +import { ref } from 'vue'; +import { useTextEntryInteraction } from '../useTextEntryInteraction'; +import { QuestionType, ValidationError } from '../../constants'; + +function makeNumericBlock(answerValues = ['12']) { + const values = answerValues.map(v => `${v}`).join(''); + const cardinality = answerValues.length > 1 ? 'multiple' : 'single'; + + const bodyXml = `

What is 3 \xd7 4?

`; + const declaration = `${values}`; + + return { bodyXml, responseDeclarations: [declaration] }; +} + +function makeFreeBlock() { + const bodyXml = `

Describe it.

`; + const declaration = ``; + return { bodyXml, responseDeclarations: [declaration] }; +} + +function setupNumeric(answerValues = ['12']) { + const questionType = ref(QuestionType.NUMERIC); + return { questionType, ...useTextEntryInteraction(makeNumericBlock(answerValues), questionType) }; +} + +function setupFree() { + const questionType = ref(QuestionType.FREE_RESPONSE); + return { questionType, ...useTextEntryInteraction(makeFreeBlock(), questionType) }; +} + +describe('useTextEntryInteraction', () => { + describe('initial state', () => { + it('parses existing answers from the block', () => { + const { state } = setupNumeric(['12']); + expect(state.value.answers).toHaveLength(1); + expect(state.value.answers[0].value).toBe('12'); + }); + + it('starts with empty errors', () => { + const { errors } = setupNumeric(); + // errors populates asynchronously via debounced watcher; + // immediately after setup it is still empty. + expect(errors.value).toEqual([]); + }); + }); + + describe('addAnswer()', () => { + it('appends a new answer with an "answer_" id and empty value', () => { + const { state, addAnswer } = setupNumeric(['12']); + addAnswer(); + expect(state.value.answers).toHaveLength(2); + const newAnswer = state.value.answers[1]; + expect(newAnswer.id).toMatch(/^answer_/); + expect(newAnswer.value).toBe(''); + }); + + it('each addAnswer call appends a unique id', () => { + const { state, addAnswer } = setupNumeric(); + addAnswer(); + addAnswer(); + const ids = state.value.answers.map(a => a.id); + expect(new Set(ids).size).toBe(ids.length); + }); + }); + + describe('removeAnswer()', () => { + it('removes the answer with the given id when 2+ answers exist', () => { + const { state, removeAnswer } = setupNumeric(['12', '6']); + // Use parsed ids from state (they are generated slugs, not fixture ids) + const [first, second] = state.value.answers; + removeAnswer(first.id); + expect(state.value.answers).toHaveLength(1); + expect(state.value.answers[0].id).toBe(second.id); + expect(state.value.answers[0].value).toBe('6'); + }); + + it('is a no-op when only one answer remains', () => { + const { state, removeAnswer } = setupNumeric(['12']); + const [first] = state.value.answers; + removeAnswer(first.id); + expect(state.value.answers).toHaveLength(1); + }); + + it('is a no-op when the id does not exist', () => { + const { state, removeAnswer } = setupNumeric(['12', '6']); + removeAnswer('nonexistent_id'); + expect(state.value.answers).toHaveLength(2); + }); + }); + + describe('updateAnswerValue()', () => { + it('updates the value for the given id', () => { + const { state, updateAnswerValue } = setupNumeric(['12']); + const [first] = state.value.answers; + updateAnswerValue(first.id, '99'); + expect(state.value.answers[0].value).toBe('99'); + }); + + it('does not mutate other answers', () => { + const { state, updateAnswerValue } = setupNumeric(['12', '6']); + const [first, second] = state.value.answers; + updateAnswerValue(first.id, '99'); + expect(state.value.answers[1].id).toBe(second.id); + expect(state.value.answers[1].value).toBe('6'); + }); + }); + + describe('setPrompt()', () => { + it('updates the prompt field', () => { + const { state, setPrompt } = setupNumeric(); + setPrompt('

New prompt

'); + expect(state.value.prompt).toBe('

New prompt

'); + }); + }); + + describe('runValidation()', () => { + it('populates errors immediately when called explicitly', () => { + const { errors, runValidation, setPrompt } = setupNumeric(); + setPrompt(''); + runValidation(); + expect(errors.value.some(e => e.code === ValidationError.PROMPT_REQUIRED)).toBe(true); + }); + + it('reports INVALID_NUMERIC_VALUE for a non-numeric answer value', () => { + const { state, errors, runValidation, updateAnswerValue } = setupNumeric(['12']); + const [first] = state.value.answers; + updateAnswerValue(first.id, 'not-a-number'); + runValidation(); + expect(errors.value.some(e => e.code === ValidationError.INVALID_NUMERIC_VALUE)).toBe(true); + }); + + it('returns empty errors for a valid freeResponse state', () => { + const { errors, runValidation } = setupFree(); + runValidation(); + expect(errors.value).toEqual([]); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js index 34e3e960cc..76b40f311b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteractionDescriptor.js @@ -1,23 +1,21 @@ -import { computed, ref, onMounted } from 'vue'; +import { computed, ref } from 'vue'; import { parseXML } from '../serialization/parseItem'; import { descriptors, registry, DEFAULT_INTERACTION } from '../interactions/index'; +import { qtiEditorStrings } from '../qtiEditorStrings'; + +const { errorParsingQuestion$ } = qtiEditorStrings; /** - * Composable that manages the question type and descriptor for a single - * interaction block. + * Composable that resolves the interaction descriptor and question type for a + * single interaction block. * * @param {import('vue').Ref} interactionRef * Ref to the interaction block { bodyXml, responseDeclarations }. */ export default function useInteractionDescriptor(interactionRef) { - /** Writable question type — set from XML on mount, then driven by UI selections. */ - const questionType = ref(null); - /** Any parse error message from the initial XML parse; null when clean. */ - const parseError = ref(null); - /** - * Parses bodyXml and returns { descriptor, questionType } without touching - * any reactive state — pure helper used only on mount. + * Parses bodyXml and returns the matching descriptor, resolved + * question type, and any parse error without touching reactive state. */ function inferFromXml(xml, declarations) { if (!xml) { @@ -33,29 +31,35 @@ export default function useInteractionDescriptor(interactionRef) { error: null, }; } catch (e) { + // eslint-disable-next-line no-console + console.error('[QTI] Failed to parse interaction XML:', e.message); return { descriptor: registry[DEFAULT_INTERACTION], questionType: null, - error: e.message, + error: errorParsingQuestion$(), }; } } - /** Parse XML once when the host component mounts to set the initial state. */ - onMounted(() => { - const inferred = inferFromXml( - interactionRef.value?.bodyXml, - interactionRef.value?.responseDeclarations, - ); - questionType.value = inferred.questionType; - parseError.value = inferred.error; - }); + /** + * Parse the initial XML synchronously during component setup. + * + * This ensures `questionType` is immediately available for downstream components + * on first render, avoiding prop validation warnings that would occur if + * initialization was deferred to a lifecycle hook. + */ + const initial = inferFromXml( + interactionRef.value?.bodyXml, + interactionRef.value?.responseDeclarations, + ); + + /** Writable ref driven by UI selections after initial parse. */ + const questionType = ref(initial.questionType); + const parseError = ref(initial.error); /** - * Descriptor is derived from the current questionType ref. - * Uses each descriptor's `questionTypes` array so the lookup stays accurate - * when the user changes the question type via the selector. - * Falls back to the default descriptor when no match is found. + * Derived from questionType so the descriptor updates when the user switches + * question types via the selector. Falls back to the default when no match. */ const descriptor = computed( () => diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js new file mode 100644 index 0000000000..e607fc206a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js @@ -0,0 +1,87 @@ +import { readonly } from 'vue'; +import { generateRandomSlug } from '../utils/generateRandomSlug'; +import { textEntryInteractionDescriptor } from '../interactions/textEntry/TextEntryInteractionDescriptor'; +import { useInteraction } from './useInteraction'; + +/** + * Composable for the text entry interaction editor. + * + * Extends useInteraction with mutation methods needed by TextEntryEditor.vue. + * There is no moveAnswerUp/Down — answer order is not meaningful for either + * numeric acceptable-answer lists or textEntry correct-answer lists. + * + * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock + * @param {import('vue').Ref} questionType + */ +export function useTextEntryInteraction(interactionBlock, questionType) { + const base = useInteraction(textEntryInteractionDescriptor, interactionBlock, questionType); + const { state } = base; + + function setPrompt(html) { + state.value = { ...state.value, prompt: html }; + } + + // Answer list mutations + + function addAnswer() { + const newId = generateRandomSlug('answer'); + state.value = { + ...state.value, + answers: [...state.value.answers, { id: newId, value: '', caseSensitive: false }], + }; + return newId; + } + + /** + * Remove an answer by id. No-op when only one answer remains so authors + * always have at least one row to fill in for numeric/textEntry questions. + * + * @param {string} id + */ + function removeAnswer(id) { + if (state.value.answers.length <= 1) return; + state.value = { + ...state.value, + answers: state.value.answers.filter(a => a.id !== id), + }; + } + + /** + * Update the answer value for a row. + * For numeric, this must be a valid float/int string. + * For textEntry, this is any non-blank string. + * + * @param {string} id + * @param {string} value + */ + function updateAnswerValue(id, value) { + state.value = { + ...state.value, + answers: state.value.answers.map(a => (a.id === id ? { ...a, value } : a)), + }; + } + + /** + * Toggle the caseSensitive flag for a textEntry answer row. + * + * @param {string} id + */ + function toggleCaseSensitive(id) { + state.value = { + ...state.value, + answers: state.value.answers.map(a => + a.id === id ? { ...a, caseSensitive: !a.caseSensitive } : a, + ), + }; + } + + return { + ...base, + state: readonly(state), + setPrompt, + addAnswer, + removeAnswer, + updateAnswerValue, + toggleCaseSensitive, + }; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js index 459e58ec03..bf03debb10 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/constants.js @@ -77,6 +77,9 @@ export const AssessmentItemTypes = Object.freeze({ export const QuestionType = Object.freeze({ SINGLE_SELECT: 'singleSelect', MULTI_SELECT: 'multiSelect', + NUMERIC: 'numeric', + TEXT_ENTRY: 'textEntry', + FREE_RESPONSE: 'freeResponse', }); /** @@ -90,4 +93,17 @@ export const ValidationError = Object.freeze({ TOO_MANY_CORRECT_ANSWERS: 'TOO_MANY_CORRECT_ANSWERS', EMPTY_CHOICE_CONTENT: 'EMPTY_CHOICE_CONTENT', 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/__tests__/defineInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js index 33572d9db8..f56266a565 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/__tests__/defineInteraction.spec.js @@ -31,8 +31,6 @@ describe('defineInteraction', () => { expect(descriptor.editorComponent).toBe(component); }); - // Keys that must be present on the descriptor itself (editorComponent is injected - // by defineInteraction from the second argument, so it is excluded here). const REQUIRED_DESCRIPTOR_KEYS = [ 'type', 'placement', @@ -56,7 +54,6 @@ describe('defineInteraction', () => { it('throws when editorComponent is not passed as the second argument', () => { const descriptor = makeValidDescriptor(); - // Calling with no second arg means editorComponent is undefined — still flagged. expect(() => defineInteraction(descriptor)).toThrow(/missing required key "editorComponent"/i); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/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/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js index ed6f7ef246..833bbbc638 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/parse.spec.js @@ -144,7 +144,6 @@ describe('parse()', () => { }); describe('buildXML()', () => { - // Helper: parse an XML string and return the document root element. function parseXmlString(xml) { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'text/xml'); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/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 e951f2b8ae..0260e0fbfc 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/index.js @@ -1,5 +1,6 @@ import { QtiInteraction } from '../constants'; import choiceDescriptor from './choice/index'; +import textEntryDescriptor from './textEntry/index'; /** * The default interaction type used as fallback when no descriptor matches @@ -8,11 +9,10 @@ import choiceDescriptor from './choice/index'; export const DEFAULT_INTERACTION = QtiInteraction.CHOICE; /** - * Ordered array of all registered interaction descriptors. - * InteractionSection iterates this to find the first descriptor whose - * matches(el) returns true. + * Ordered list of all registered interaction descriptors. + * Searched in order; the first whose `matches(el)` returns true wins. */ -export const descriptors = [choiceDescriptor]; +export const descriptors = [choiceDescriptor, textEntryDescriptor]; /** * Registry map keyed by descriptor.type for O(1) direct lookup. diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue new file mode 100644 index 0000000000..0a38335f62 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue @@ -0,0 +1,561 @@ + + + + + + + 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..6113fca18c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js @@ -0,0 +1,112 @@ +import { QtiInteraction, QuestionType, BaseType, Cardinality } from '../../constants'; +import { parseXML } from '../../serialization/parseItem'; +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. + */ +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) { + if (el.tagName.toLowerCase() === QtiInteraction.TEXT_ENTRY) return true; + return !!el.querySelector(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 = parseXML(responseDeclarations[0]); + const root = doc.documentElement; + 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. + * Cardinality is derived from answer count for NUMERIC and TEXT_ENTRY so it + * stays in sync as answers are added or removed. + * + * @param {string} questionType + * @param {TextEntryState|null} [state] + * @returns {{ baseType: string, cardinality: string }} + */ + getResponseDeclarationSchema(questionType, state = null) { + if (questionType === QuestionType.FREE_RESPONSE) { + return { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE }; + } + const answerCount = state?.answers?.length ?? 0; + const cardinality = answerCount > 1 ? Cardinality.MULTIPLE : Cardinality.SINGLE; + if (questionType === QuestionType.TEXT_ENTRY) { + return { baseType: BaseType.STRING, cardinality }; + } + return { baseType: BaseType.FLOAT, cardinality }; + } + + /** + * @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..a804d84e81 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/TextEntryEditor.spec.js @@ -0,0 +1,270 @@ +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(), + }); + +describe('TextEntryEditor — numeric', () => { + const answerInputs = () => + screen.queryAllByRole('textbox', { name: tr.$tr('answerValuePlaceholder') }); + + 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: /Delete answer/i })).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: /Delete answer/i })).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: /Delete answer/i }); + 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); + }); + }); +}); + +describe('TextEntryEditor — textEntry', () => { + it('renders the answer list section', () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, STRING_DECL), + questionType: QuestionType.TEXT_ENTRY, + }); + const inputs = screen.queryAllByRole('textbox', { name: tr.$tr('answerTextPlaceholder') }); + 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(); + }); +}); + +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, + }); + expect( + screen.queryAllByRole('textbox', { name: tr.$tr('answerValuePlaceholder') }).length, + ).toBe(0); + expect(screen.queryAllByRole('textbox', { name: tr.$tr('answerTextPlaceholder') }).length).toBe( + 0, + ); + }); +}); + +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); + }); +}); + +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: /Delete answer/i }) + .forEach(b => expect(b).toHaveAccessibleName()); + }); +}); + +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..6df343985b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js @@ -0,0 +1,332 @@ +import { + _defaultState, + _extractAnswers, + parseTextEntryInteraction, + buildTextEntryInteractionXML, + DEFAULT_EXPECTED_LENGTH, +} from '../parse'; +import { BaseType, Cardinality, QuestionType } from '../../../constants'; + +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}
` : ''}

`; +} + +describe('_defaultState', () => { + it('returns prompt as empty string', () => { + expect(_defaultState().prompt).toBe(''); + }); + + it('returns answers as an array with one empty answer seeded', () => { + const answers = _defaultState().answers; + expect(answers).toHaveLength(1); + expect(answers[0].value).toBe(''); + expect(answers[0].caseSensitive).toBe(false); + expect(typeof answers[0].id).toBe('string'); + }); + + it('returns expectedLength as DEFAULT_EXPECTED_LENGTH', () => { + expect(_defaultState().expectedLength).toBe(DEFAULT_EXPECTED_LENGTH); + }); +}); + +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); + }); +}); + +describe('parseTextEntryInteraction', () => { + it('returns defaultState when bodyXml is empty', () => { + const state = parseTextEntryInteraction('', []); + expect(state.prompt).toBe(''); + expect(state.expectedLength).toBe(DEFAULT_EXPECTED_LENGTH); + expect(state.answers).toHaveLength(1); + }); + + it('returns defaultState when bodyXml is unparseable', () => { + const state = parseTextEntryInteraction('< { + const bodyXml = '

No interaction here

'; + const state = parseTextEntryInteraction(bodyXml, []); + expect(state.prompt).toBe(''); + expect(state.answers).toHaveLength(1); + }); + + 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: DEFAULT_EXPECTED_LENGTH }), + [FREE_RESPONSE_DECLARATION], + ); + expect(state.expectedLength).toBe(DEFAULT_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']); + }); + }); +}); + +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 using DEFAULT_EXPECTED_LENGTH for all types', () => { + const { bodyXml } = buildTextEntryInteractionXML( + _defaultState(), + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(bodyXml).toContain(`expected-length="${DEFAULT_EXPECTED_LENGTH}"`); + }); + + it('uses provided expectedLength instead of DEFAULT_EXPECTED_LENGTH', () => { + const state = _defaultState(); + state.expectedLength = 100; + const { bodyXml } = buildTextEntryInteractionXML( + state, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(bodyXml).toContain(`expected-length="100"`); + }); + }); + + 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<'); + }); + + it('numeric with 0 answers omits (empty element is invalid per XSD)', () => { + const { responseDeclarations } = buildTextEntryInteractionXML( + { prompt: '', answers: [], expectedLength: 0 }, + QuestionType.NUMERIC, + NUMERIC_SINGLE_SCHEMA, + ); + expect(responseDeclarations[0]).not.toContain('qti-correct-response'); + }); + }); + + describe('round-trip', () => { + 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(DEFAULT_EXPECTED_LENGTH); + expect(parsed.prompt).toBe(original.prompt); + }); + + it('freeResponse: parse → buildXML → parse yields equivalent state', () => { + const state = { + prompt: '

A question prompt.

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

A question prompt.

'); + expect(parsed.expectedLength).toBe(DEFAULT_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..cd4126bdbb --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js @@ -0,0 +1,169 @@ +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', () => { + 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..d587280220 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/index.js @@ -0,0 +1,5 @@ +import defineInteraction from '../defineInteraction'; +import TextEntryEditor from './TextEntryEditor.vue'; +import { textEntryInteractionDescriptor } from './TextEntryInteractionDescriptor'; + +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..efe173cc2a --- /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, QuestionType, RESPONSE_IDENTIFIER } from '../../constants'; +import { CAPABILITY } from '../../serialization/qti/declarations/capabilities'; + +const serializer = new XMLSerializer(); + +/** + * @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. + */ + +/** + * Default `expected-length` attribute for ``. + */ +export const DEFAULT_EXPECTED_LENGTH = 50; + +/** + * Default state — used when bodyXml is absent or unparseable. + * + * @returns {TextEntryState} + */ +export function _defaultState() { + return { + prompt: '', + answers: [{ id: generateRandomSlug('answer'), value: '', caseSensitive: false }], + expectedLength: DEFAULT_EXPECTED_LENGTH, + }; +} + +/** + * Serializes the body element children to an HTML string, excluding the + * element that directly contains the ``. + * + * @param {Element} bodyEl - The `` element + * @returns {string} + */ +function extractPromptHTML(bodyEl) { + const clone = bodyEl.cloneNode(true); + const interactionEl = clone.querySelector('qti-text-entry-interaction'); + if (!interactionEl) return ''; + + const interactionContainer = interactionEl.parentElement; + if ( + interactionContainer && + interactionContainer !== clone && + interactionContainer.tagName.toLowerCase() === 'p' + ) { + interactionContainer.remove(); + } else { + interactionEl.remove(); + } + + return clone.innerHTML.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) { + // 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) { + 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 []; + + return valueEls.map(el => ({ + id: generateRandomSlug('answer'), + value: el.textContent.trim(), + caseSensitive: isString && el.getAttribute('case-sensitive') === 'true', + })); + } catch (err) { + // eslint-disable-next-line no-console + console.error('[QTI Editor] Failed to parse text-entry response declaration:', err); + 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); + 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') ?? String(DEFAULT_EXPECTED_LENGTH), + 10, + ); + const prompt = extractPromptHTML(bodyEl); + const answers = _extractAnswers(responseDeclarations); + + return { prompt, answers, expectedLength }; +} + +/** + * Serialize TextEntryState → { bodyXml, responseDeclarations }. + * + * @param {TextEntryState} state + * @param {string} questionType - One of QuestionType.NUMERIC, TEXT_ENTRY, 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; + + const interactionAttrs = { + 'response-identifier': RESPONSE_IDENTIFIER, + }; + + const effectiveExpectedLength = expectedLength || DEFAULT_EXPECTED_LENGTH; + 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 body children: prompt HTML nodes (if any) followed by the interaction paragraph. + const bodyChildren = []; + if (prompt) { + const promptDoc = parseXML(`${prompt}`, 'text/html'); + bodyChildren.push(...promptDoc.body.childNodes); + } + bodyChildren.push(interactionParagraph); + + const bodyEl = buildXmlNode({ tag: 'qti-item-body', children: bodyChildren }); + const bodyXml = serializer.serializeToString(bodyEl); + + // Build the response declaration. + const declaration = new QTIDeclaration({ + identifier: RESPONSE_IDENTIFIER, + baseType, + cardinality, + tag: 'qti-response-declaration', + }); + + if (questionType === QuestionType.FREE_RESPONSE) { + // No correct answer + } else if (baseType === BaseType.STRING) { + if (answers.length > 0) { + const textAnswers = answers.slice(); + declaration.registerCapability(CAPABILITY.CORRECT_RESPONSE, { + get: () => textAnswers.map(a => a.value), + getXML: () => { + const valueEls = textAnswers.map(a => + buildXmlNode({ tag: 'qti-value', children: [a.value] }), + ); + return buildXmlNode({ tag: 'qti-correct-response', children: valueEls }); + }, + }); + } + } else { + if (answers.length > 0) { + 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..ec29868555 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js @@ -0,0 +1,61 @@ +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 || questionType === QuestionType.TEXT_ENTRY) { + if (answers.length === 0) { + errors.push({ code: ValidationError.NO_CORRECT_ANSWER }); + } + + const seen = new Set(); + + for (const answer of answers) { + 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 }); + } + } + + const normalizedVal = + questionType === QuestionType.TEXT_ENTRY && !answer.caseSensitive ? val.toLowerCase() : val; + const lookupKey = + questionType === QuestionType.TEXT_ENTRY + ? `${normalizedVal}|${answer.caseSensitive}` + : normalizedVal; + + if (val) { + if (seen.has(lookupKey)) { + errors.push({ code: ValidationError.DUPLICATE_ANSWER_CONTENT, id: answer.id }); + } + seen.add(lookupKey); + } + } + } + + return errors; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 0e98cccf29..8e00d238ba 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,81 @@ 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', }, + 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', + }, + 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 {number}', + 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', + }, + errorParsingQuestion: { + message: 'This question could not be loaded', + context: 'Shown in place of the interaction editor when the QTI XML fails to parse', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js index 7734e81ea6..48b8941460 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/parseItem.spec.js @@ -2,10 +2,7 @@ import { parseXML, parseItem } from '../parseItem'; import { VALID_CHOICE_ITEM_DOCUMENT, TWO_INTERACTIONS_DOCUMENT } from '../../utils/testingFixtures'; -// --------------------------------------------------------------------------- // Fixtures -// --------------------------------------------------------------------------- - const ITEM_NO_INTERACTIONS = ` `; -// --------------------------------------------------------------------------- // parseXML -// --------------------------------------------------------------------------- - describe('parseXML', () => { it('parses valid XML into a Document', () => { const doc = parseXML(VALID_CHOICE_ITEM_DOCUMENT); @@ -61,10 +55,7 @@ describe('parseXML', () => { }); }); -// --------------------------------------------------------------------------- // parseItem — meta extraction -// --------------------------------------------------------------------------- - describe('parseItem — meta', () => { it('returns an object with the top-level item attributes', () => { const model = parseItem(VALID_CHOICE_ITEM_DOCUMENT); @@ -83,10 +74,7 @@ describe('parseItem — meta', () => { }); }); -// --------------------------------------------------------------------------- // parseItem — interaction blocks -// --------------------------------------------------------------------------- - describe('parseItem — interaction blocks', () => { it('extracts interactions and their corresponding response declarations', () => { const model = parseItem(VALID_CHOICE_ITEM_DOCUMENT); @@ -109,10 +97,7 @@ describe('parseItem — interaction blocks', () => { }); }); -// --------------------------------------------------------------------------- // parseItem — response declaration matching -// --------------------------------------------------------------------------- - describe('parseItem — response declaration matching', () => { it('matches each interaction to its own response declaration', () => { const model = parseItem(TWO_INTERACTIONS_DOCUMENT); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js index 5654aebd10..48295a8889 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js @@ -86,14 +86,15 @@ export function assembleItemXml({ identifier, title, language, bodyXml, response return doc.documentElement; }); - // Parse the interaction body XML into a DOM node. const bodyDoc = parseXML(bodyXml || ''); - const interactionNode = bodyDoc.documentElement; - - const itemBodyNode = buildXmlNode({ - tag: 'qti-item-body', - children: [interactionNode], - }); + const bodyRoot = bodyDoc.documentElement; + const itemBodyNode = + bodyRoot.tagName.toLowerCase() === 'qti-item-body' + ? bodyRoot + : buildXmlNode({ + tag: 'qti-item-body', + children: [bodyRoot], + }); 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 1d604bca95..d0bccb018c 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, INLINE_INTERACTION_TAGS } from '../constants'; const serializer = new XMLSerializer(); const parser = new DOMParser(); @@ -14,7 +14,12 @@ 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; + if (mimeType === 'text/xml') { + input = xmlString.replace(/ xmlns="[^"]*"/, ''); + } + + 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, @@ -50,6 +55,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 +93,10 @@ export function parseItem(rawData) { .filter(d => d.getAttribute('identifier') === responseId) .map(d => serializer.serializeToString(d)); + const isInline = INLINE_INTERACTION_TAGS.has(el.tagName.toLowerCase()); + 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 `;