What is 3 \xd7 4?
Describe it.
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').RefNo interaction here
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 aWhat 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 `
; 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
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) { + // BuildWhat is H2O?