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/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..3a736dd468 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -62,7 +62,6 @@ import { computed, ref, watch } from 'vue'; import { qtiEditorStrings } from '../../qtiEditorStrings'; - import { QuestionType } from '../../constants'; import useQtiItem from '../../composables/useQtiItem'; import InteractionSection from '../InteractionSection/index.vue'; @@ -117,18 +116,13 @@ */ 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$(); + // Dynamic access to the localized string method (e.g. 'singleSelectLabel$()') + const methodKey = `${type}Label$`; + return qtiEditorStrings[methodKey]?.() ?? unknownTypeLabel$(); + }); const questionNumberAndTypeLabel = computed(() => questionNumberAndTypeLabel$({ 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..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 @@ -8,8 +8,14 @@ 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'; +jest.mock('shared/views/TipTapEditor/TipTapEditor/TipTapEditor'); + // --------------------------------------------------------------------------- // Helper: renders a wrapper component that runs the composable inside setup(). // Because questionType is now set on mount (not as a computed), we must wait @@ -103,11 +109,12 @@ describe('useInteractionDescriptor', () => { }); describe('with malformed XML', () => { - it('returns a non-null parseError', async () => { + it('returns a parse error for malformed XML', async () => { + // inferFromXml uses text/xml which throws a parser error for malformed fragments. const { result } = renderDescriptor(' { @@ -134,4 +141,41 @@ describe('useInteractionDescriptor', () => { expect(result.questionType.value).toBe(QuestionType.MULTI_SELECT); }); }); + + // --------------------------------------------------------------------------- + // Regression: inline placement — bodyXml is a full + // Before the fix, documentElement was and no descriptor + // matched it, so the code fell back to the choice descriptor for every + // text-entry item, showing them as "Multiple Choice / Single Choice". + // --------------------------------------------------------------------------- + describe('with an inline text-entry interaction (bodyXml is qti-item-body)', () => { + it('resolves the TextEntry descriptor for a numeric declaration', async () => { + const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_NUMERIC_DECL_XML]); + 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); + }); + + it('does NOT resolve as choice when bodyXml is a qti-item-body (regression)', async () => { + // This is the exact bug: without the fix every text-entry item was + // treated as a choice interaction because documentElement was . + const { result } = renderDescriptor(TEXT_ENTRY_BODY_XML, [TEXT_ENTRY_FREE_DECL_XML]); + await nextTick(); + expect(result.descriptor.value.type).not.toBe(QtiInteraction.CHOICE); + }); + }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js new file mode 100644 index 0000000000..402378564a --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js @@ -0,0 +1,142 @@ +import { ref } from 'vue'; +import { useTextEntryInteraction } from '../useTextEntryInteraction'; +import { QuestionType, ValidationError } from '../../constants'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function makeNumericBlock(answerValues = ['12']) { + const values = answerValues.map(v => `${v}`).join(''); + const cardinality = answerValues.length > 1 ? 'multiple' : 'single'; + + 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 qt = ref(QuestionType.NUMERIC); + return { qt, ...useTextEntryInteraction(makeNumericBlock(answerValues), qt) }; +} + +function setupFree() { + const qt = ref(QuestionType.FREE_RESPONSE); + return { qt, ...useTextEntryInteraction(makeFreeBlock(), qt) }; +} + +// ─── Tests ───────────────────────────────────────────────────────────────── + +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/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/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 e951f2b8ae..a14f295267 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 @@ -12,7 +13,7 @@ export const DEFAULT_INTERACTION = QtiInteraction.CHOICE; * InteractionSection iterates this to find the first descriptor whose * matches(el) returns true. */ -export const descriptors = [choiceDescriptor]; +export const descriptors = [choiceDescriptor, textEntryDescriptor]; /** * Registry map keyed by descriptor.type for O(1) direct lookup. @@ -31,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 new file mode 100644 index 0000000000..5ae6bd0aa8 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryEditor.vue @@ -0,0 +1,544 @@ + + + + + + + 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..1a57591909 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/TextEntryInteractionDescriptor.js @@ -0,0 +1,116 @@ +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. + * + * 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..16c69667fd --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js @@ -0,0 +1,322 @@ +import { + _defaultState, + _extractAnswers, + parseTextEntryInteraction, + buildTextEntryInteractionXML, + DEFAULT_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 DEFAULT_EXPECTED_LENGTH', () => { + expect(_defaultState().expectedLength).toBe(DEFAULT_EXPECTED_LENGTH); + }); +}); + +// ─── _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); + }); +}); + +// ─── 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: 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']); + }); + }); +}); + +// ─── 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 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<'); + }); + }); + + 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..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..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..ff381e857c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -0,0 +1,273 @@ +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, RESPONSE_IDENTIFIER } from '../../constants'; + +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. + * FREE_RESPONSE_EXPECTED_LENGTH for freeResponse; + * 0 (absent) for numeric and textEntry. + */ + +/** + * Default `expected-length` attribute written on `` + * for free-response items. The QTI 3.0 spec does not mandate a value; 50 is a + * widely-used authoring convention (sourced from the textEntry interaction PR + * description). Change this constant if your platform uses a different default. + * + * This attribute is informational — it hints to the player how wide to render + * the input box. It has no effect on scoring. + */ +export const DEFAULT_EXPECTED_LENGTH = 50; + +/** + * Default state — used when bodyXml is absent or unparseable. + * + * @returns {TextEntryState} + */ +export function _defaultState() { + return { + prompt: '', + answers: [], + expectedLength: DEFAULT_EXPECTED_LENGTH, + }; +} + +/** + * Serialize body children to an HTML string, excluding the element that + * directly contains the ``. + * + * buildTextEntryInteractionXML wraps body content in a single `
`, so we + * look inside that wrapper for prompt children and the interaction container. + * + * @param {Element} bodyEl - The wrapper element that contains both the + * prompt and the text entry interaction + * @returns {string} + */ +function extractPromptHTML(bodyEl) { + const clone = bodyEl.cloneNode(true); + const interactionEl = clone.querySelector('qti-text-entry-interaction'); + if (!interactionEl) return ''; + + // The interaction typically sits in a

; remove the

(or just the interaction) + const interactionContainer = interactionEl.parentElement; + if ( + interactionContainer && + 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, extract from inside it. + const bodyChildren = clone.children; + const searchRoot = + bodyChildren.length === 1 && bodyChildren[0].tagName.toLowerCase() === 'div' + ? bodyChildren[0] + : clone; + + return searchRoot.innerHTML.replace(/ xmlns="http:\/\/www\.w3\.org\/1999\/xhtml"/g, '').trim(); +} + +/** + * 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(), + // case-sensitive is only meaningful for string base-type answers. + 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 }. + * + * 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; + + 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 the body content: prompt HTML (if any) followed by the interaction paragraph. + const divChildren = []; + if (prompt) { + const promptDoc = new DOMParser().parseFromString( + `${prompt}`, + 'text/html', + ); + divChildren.push(...promptDoc.body.childNodes); + } + 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). + const isFreeResponse = baseType === BaseType.STRING && answers.length === 0; + if (!isFreeResponse && answers.length > 0) { + if (baseType === BaseType.STRING) { + // Build manually so we can write the optional + // case-sensitive="true" attribute on individual elements. + const valueEls = answers.map(a => { + const valueEl = buildXmlNode({ tag: 'qti-value', children: [a.value] }); + if (a.caseSensitive) { + valueEl.setAttribute('case-sensitive', 'true'); + } + return valueEl; + }); + const correctResponseEl = buildXmlNode({ + tag: 'qti-correct-response', + children: valueEls, + }); + 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..94bf5a3e0b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js @@ -0,0 +1,57 @@ +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 lookupKey = + questionType === QuestionType.TEXT_ENTRY && !answer.caseSensitive ? val.toLowerCase() : val; + + 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..dc9438010a 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,77 @@ 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', + 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/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 1d604bca95..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 } 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, @@ -50,6 +56,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 +94,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 `;