diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js index eee0d600fd..d4f3f9d50b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js @@ -126,10 +126,18 @@ export const TEXT_ENTRY_ITEM_XML = ` base-type="string" > - H2O + H2O h2o H2o + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js index 6df343985b..9d308bfe41 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js @@ -28,6 +28,48 @@ const MULTI_NUMERIC_DECLARATION = ` `.trim(); +const TEXT_ENTRY_DECLARATION_WITH_MAPPING = ` + + + Paris + Madrid + + + + + + + +`.trim(); + +const TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING = ` + + + Paris + + +`.trim(); + +const BLANK_VALUE_DECLARATION = ` + + + + + + + + +`.trim(); + +/** `identifier` is required by the QTI schema — QTIDeclaration refuses to model this. */ +const DECLARATION_WITHOUT_IDENTIFIER = ` + + + Paris + + +`.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}"` : ''}`; @@ -53,6 +95,10 @@ describe('_defaultState', () => { }); describe('_extractAnswers', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('returns [] when no declaration is provided', () => { expect(_extractAnswers([])).toEqual([]); }); @@ -85,6 +131,42 @@ describe('_extractAnswers', () => { const result = _extractAnswers([MULTI_NUMERIC_DECLARATION]); expect(result[0].id).not.toBe(result[1].id); }); + + it('returns [] when the declaration is too malformed to model', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(_extractAnswers([DECLARATION_WITHOUT_IDENTIFIER])).toEqual([]); + expect(errorSpy).toHaveBeenCalled(); + }); + + describe('mapping-derived case sensitivity', () => { + it('reads caseSensitive by map-key, silently ignoring unmatched entries', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITH_MAPPING]); + // The Lisbon entry has no , so it contributes no answer and no error. + const byValue = Object.fromEntries(result.map(a => [a.value, a.caseSensitive])); + expect(byValue).toEqual({ Paris: false, Madrid: true }); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('falls back to caseSensitive false when there is no mapping', () => { + const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING]); + expect(result).toHaveLength(1); + expect(result[0].caseSensitive).toBe(false); + }); + + it('reports numeric answers as never case-sensitive', () => { + const result = _extractAnswers([SINGLE_NUMERIC_DECLARATION]); + expect(result[0].caseSensitive).toBe(false); + }); + + it('matches a map entry for a blank answer value', () => { + // Both the empty map-key and the empty coerce to null (QTI NULL) and + // format back to ''; the entry is case-sensitive="true" so a missed lookup can't + // slip through the false fallback. + const result = _extractAnswers([BLANK_VALUE_DECLARATION]); + expect(result).toEqual([expect.objectContaining({ value: '', caseSensitive: true })]); + }); + }); }); describe('parseTextEntryInteraction', () => { @@ -147,6 +229,7 @@ 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 }; + const TEXT_ENTRY_MULTI_SCHEMA = { baseType: BaseType.STRING, cardinality: Cardinality.MULTIPLE }; describe('bodyXml', () => { it('produces a well-formed ', () => { @@ -273,6 +356,72 @@ describe('buildTextEntryInteractionXML', () => { }); }); + describe('mapping', () => { + const CASE_ANSWERS = [ + { id: 'a1', value: 'Paris', caseSensitive: false }, + { id: 'a2', value: 'Madrid', caseSensitive: true }, + ]; + + const CASE_STATE = { prompt: '', answers: CASE_ANSWERS, expectedLength: 0 }; + + /** Build the declaration for the given state and return it as both string and DOM. */ + function buildDeclaration( + state, + questionType = QuestionType.TEXT_ENTRY, + schema = TEXT_ENTRY_MULTI_SCHEMA, + ) { + const { responseDeclarations } = buildTextEntryInteractionXML(state, questionType, schema); + const [decl] = responseDeclarations; + return { decl, doc: new DOMParser().parseFromString(decl, 'text/xml') }; + } + + it('emits one qti-map-entry per string answer', () => { + const { doc } = buildDeclaration(CASE_STATE); + expect(doc.querySelectorAll('qti-mapping')).toHaveLength(1); + + const entries = [...doc.querySelectorAll('qti-map-entry')]; + expect(entries.map(e => e.getAttribute('map-key'))).toEqual(['Paris', 'Madrid']); + expect(entries.map(e => e.getAttribute('mapped-value'))).toEqual(['1', '1']); + }); + + it('writes case-sensitive="true" only for case-sensitive answers', () => { + const entries = [...buildDeclaration(CASE_STATE).doc.querySelectorAll('qti-map-entry')]; + // null = attribute absent; false is the XSD default and so is left unwritten. + expect(entries.map(e => e.getAttribute('case-sensitive'))).toEqual([null, 'true']); + }); + + it('emits no mapping for numeric answers', () => { + const { doc } = buildDeclaration( + { + prompt: '', + answers: [ + { id: 'a1', value: '0.5' }, + { id: 'a2', value: '1.5' }, + ], + expectedLength: 0, + }, + QuestionType.NUMERIC, + NUMERIC_MULTI_SCHEMA, + ); + expect(doc.querySelector('qti-mapping')).toBeNull(); + }); + + it('emits no mapping for free response', () => { + const { doc } = buildDeclaration(CASE_STATE, QuestionType.FREE_RESPONSE, FREE_SCHEMA); + expect(doc.querySelector('qti-mapping')).toBeNull(); + }); + + it('emits no mapping when there are zero answers', () => { + const { doc } = buildDeclaration({ ...CASE_STATE, answers: [] }); + expect(doc.querySelector('qti-mapping')).toBeNull(); + }); + + it('emits qti-mapping after qti-correct-response per the XSD sequence', () => { + const { decl } = buildDeclaration(CASE_STATE); + expect(decl.indexOf(' { it('numeric: parse → buildXML → parse yields equivalent state', () => { const original = { @@ -328,5 +477,28 @@ describe('buildTextEntryInteractionXML', () => { const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations); expect(parsed.answers.map(a => a.value)).toEqual(['0.5', '1.5']); }); + + it('textEntry: round-trip preserves per-answer caseSensitive', () => { + const original = { + prompt: '

Name a capital city.

', + answers: [ + { id: 'a1', value: 'Paris', caseSensitive: false }, + { id: 'a2', value: 'Madrid', caseSensitive: true }, + // Padded: keeps its flag only if map-key is written trimmed. Case-sensitive + // because false is also the no-match fallback, which would hide a miss. + { id: 'a3', value: ' Rome ', caseSensitive: true }, + ], + expectedLength: 0, + }; + const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML( + original, + QuestionType.TEXT_ENTRY, + TEXT_ENTRY_MULTI_SCHEMA, + ); + const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations); + + const byValue = Object.fromEntries(parsed.answers.map(a => [a.value, a.caseSensitive])); + expect(byValue).toEqual({ Paris: false, Madrid: true, Rome: true }); + }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js index e25a21cd78..921bd47091 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -2,6 +2,7 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; import { parseXML } from '../../serialization/parseItem'; import { buildXmlNode } from '../../serialization/assembleItem'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; +import Mapping from '../../serialization/qti/declarations/mapping'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; import { BaseType, QuestionType, RESPONSE_IDENTIFIER } from '../../constants'; @@ -75,7 +76,8 @@ function extractPromptHTML(bodyEl) { * 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. + * For string base-types `caseSensitive` comes from the declaration's + * , matched by `map-key`; it is always false for float. * * @param {string[]} responseDeclarations * @returns {{ id: string, value: string, caseSensitive: boolean }[]} @@ -85,35 +87,42 @@ export function _extractAnswers(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; + const declaration = QTIDeclaration.fromXML(parseXML(declXml).documentElement); + const { baseType, correctResponse } = declaration; - if (!isFloat && !isString) { + if (baseType !== BaseType.FLOAT && baseType !== BaseType.STRING) { // eslint-disable-next-line no-console - console.error( - `[QTI Editor] Unsupported text-entry base-type: ${declEl.getAttribute('base-type')}`, - ); + console.error(`[QTI Editor] Unsupported text-entry base-type: ${baseType}`); return []; } - const correctResponseEl = declEl.querySelector('qti-correct-response'); - if (!correctResponseEl) { - if (isFloat) { + if (correctResponse === null) { + if (baseType === BaseType.FLOAT) { // eslint-disable-next-line no-console console.error('[QTI Editor] Missing for numeric interaction'); } return []; } - const valueEls = [...correctResponseEl.querySelectorAll('qti-value')]; - if (valueEls.length === 0) return []; - - return valueEls.map(el => ({ - id: generateRandomSlug('answer'), - value: el.textContent.trim(), - caseSensitive: isString && el.getAttribute('case-sensitive') === 'true', - })); + // Case sensitivity is a string-only concept, so numeric answers never read the mapping. + const mapEntries = baseType === BaseType.STRING ? (declaration.mapping?.entries ?? []) : []; + // Key on the XML string form: both map-key and correct-response values are coerced + // on parse (empty → null under QTI NULL semantics), so formatting both back matches + // them on equal terms. + const caseSensitivity = new Map( + mapEntries.map(entry => [declaration.formatValue(entry.mapKey), entry.caseSensitive]), + ); + + return correctResponse.map(value => { + const formatted = declaration.formatValue(value); + return { + id: generateRandomSlug('answer'), + value: formatted, + // An answer with no matching qti-map-entry — including every answer in an + // item authored before mappings were written — takes the XSD default, false. + caseSensitive: caseSensitivity.get(formatted) ?? false, + }; + }); } catch (err) { // eslint-disable-next-line no-console console.error('[QTI Editor] Failed to parse text-entry response declaration:', err); @@ -209,10 +218,29 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch tag: 'qti-response-declaration', }); - if (questionType !== QuestionType.FREE_RESPONSE) { - if (answers.length !== 0) { - new CorrectResponse( - answers.map(a => a.value), + // CorrectResponse before Mapping: getXML emits children in capability insertion + // order, and the schema requires to precede . + if (questionType !== QuestionType.FREE_RESPONSE && answers.length !== 0) { + new CorrectResponse( + answers.map(a => a.value), + declaration, + ); + + // is the spec's home for per-answer case sensitivity (string-only). + // mapped-value is schema-required but unused: the editor does not score responses. + if (baseType === BaseType.STRING) { + new Mapping( + { + defaultValue: 0, + lowerBound: null, + upperBound: null, + entries: answers.map(a => ({ + // Trimmed to match how _extractAnswers reads text back. + mapKey: a.value.trim(), + mappedValue: 1, + caseSensitive: Boolean(a.caseSensitive), + })), + }, declaration, ); } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/fixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/fixtures.js index 2b19137e8a..13e8f793da 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/fixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/fixtures.js @@ -17,15 +17,6 @@ export const MAPPING_WITH_BOUNDS_XML = ` `.trim(); -export const MAPPING_WITH_CI_XML = ` - - - - - - -`.trim(); - export const AREA_MAPPING_XML = ` diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js index 1ead5d6831..9e11e1df50 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/mapping.spec.js @@ -5,7 +5,7 @@ import Mapping from '../../declarations/mapping.js'; import { QTIDeclaration } from '../../QTIDeclaration.js'; import { CAPABILITY } from '../../declarations/index.js'; import { parseXML, reparse } from '../testUtils.js'; -import { MAPPING_WITH_BOUNDS_XML, MAPPING_WITH_CI_XML, SIMPLE_MAPPING_XML } from './fixtures.js'; +import { MAPPING_WITH_BOUNDS_XML, SIMPLE_MAPPING_XML } from './fixtures.js'; function makeDeclaration() { return new QTIDeclaration({ @@ -80,24 +80,34 @@ describe('Mapping', () => { const declaration = makeDeclaration(); const m = Mapping.fromXML(node, declaration); expect(m.get().entries).toHaveLength(2); + // Neither entry declares case-sensitive, so both take the XSD default of false. expect(m.get().entries[0]).toEqual({ mapKey: 'ChoiceA', mappedValue: 1, - caseSensitive: true, + caseSensitive: false, }); expect(m.get().entries[1]).toEqual({ mapKey: 'ChoiceB', mappedValue: -0.5, - caseSensitive: true, + caseSensitive: false, }); }); - it('parses case-sensitive="false" on entries', () => { - const node = parseMappingXml(MAPPING_WITH_CI_XML); - const declaration = makeDeclaration(); - const m = Mapping.fromXML(node, declaration); - expect(m.get().entries[0].caseSensitive).toBe(false); - expect(m.get().entries[1].caseSensitive).toBe(true); + it.each([ + ['word', 'false', 'true'], + ['numeric', '0', '1'], + ])('parses %s xs:boolean lexical forms of case-sensitive on entries', (_, off, on) => { + const node = parseMappingXml(` + + + + + + + `); + const { entries } = Mapping.fromXML(node, makeDeclaration()).get(); + expect(entries[0].caseSensitive).toBe(false); + expect(entries[1].caseSensitive).toBe(true); }); it('registers itself as MAPPING capability', () => { @@ -157,7 +167,7 @@ describe('Mapping', () => { expect(entries[0].getAttribute('mapped-value')).toBe('1'); }); - it('only emits case-sensitive attr when false', () => { + it('only emits case-sensitive attr when true', () => { const data = { defaultValue: 0, lowerBound: null, @@ -170,8 +180,8 @@ describe('Mapping', () => { const entries = [ ...new Mapping(data, makeDeclaration()).getXML().querySelectorAll('qti-map-entry'), ]; - expect(entries[0].getAttribute('case-sensitive')).toBe('false'); - expect(entries[1].hasAttribute('case-sensitive')).toBe(false); + expect(entries[0].hasAttribute('case-sensitive')).toBe(false); + expect(entries[1].getAttribute('case-sensitive')).toBe('true'); }); it('round-trips fromXML → getXML preserves all attributes via DOM', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js index 3cf542a85c..bb9ea77d7a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/declarations/mapping.js @@ -64,8 +64,8 @@ export default class Mapping { const entries = [...xmlNode.querySelectorAll('qti-map-entry')].map(entry => ({ mapKey: declaration.coerceValue(entry.getAttribute('map-key')), mappedValue: parseFloat(entry.getAttribute('mapped-value')), - // Per QTI spec, case-sensitive defaults to true; only false when explicitly set. - caseSensitive: entry.getAttribute('case-sensitive') !== 'false', + // XSD default (MapEntryDType) is false; true only for xs:boolean's true forms. + caseSensitive: ['true', '1'].includes(entry.getAttribute('case-sensitive')), })); return new Mapping({ ...bounds, entries }, declaration); @@ -94,8 +94,9 @@ export default class Mapping { 'map-key': this._declaration.formatValue(entry.mapKey), 'mapped-value': entry.mappedValue, }; - // Omit case-sensitive when true — it is the spec default. - if (!entry.caseSensitive) entryAttrs['case-sensitive'] = 'false'; + // Omit when false — the XSD default — so a consumer applying attribute defaults + // reads back what was authored. + if (entry.caseSensitive) entryAttrs['case-sensitive'] = 'true'; return buildXmlNode({ tag: 'qti-map-entry', attrs: entryAttrs }); });