From adea72221250f35c83135598b85d60a57363ca03 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 30 Jul 2026 16:29:10 -0700 Subject: [PATCH 1/8] fix: parse all xs:boolean lexical forms for case-sensitive map entries Mapping.fromXML treated only the literal string 'false' as false, so a spec-valid case-sensitive="0" parsed as true. Co-Authored-By: Claude Opus 5 (1M context) --- .../qti/__tests__/declarations/fixtures.js | 9 +++++++++ .../qti/__tests__/declarations/mapping.spec.js | 14 +++++++++++++- .../serialization/qti/declarations/mapping.js | 16 ++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) 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..9883a5412a 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 @@ -26,6 +26,15 @@ export const MAPPING_WITH_CI_XML = ` `.trim(); +export const MAPPING_WITH_NUMERIC_BOOLEANS_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..cf97e8f5cf 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,12 @@ 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, + MAPPING_WITH_CI_XML, + MAPPING_WITH_NUMERIC_BOOLEANS_XML, + SIMPLE_MAPPING_XML, +} from './fixtures.js'; function makeDeclaration() { return new QTIDeclaration({ @@ -100,6 +105,13 @@ describe('Mapping', () => { expect(m.get().entries[1].caseSensitive).toBe(true); }); + it('parses numeric xs:boolean lexical forms on entries', () => { + const node = parseMappingXml(MAPPING_WITH_NUMERIC_BOOLEANS_XML); + 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', () => { const node = parseMappingXml(SIMPLE_MAPPING_XML); const declaration = makeDeclaration(); 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..fe40c98fb9 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 @@ -61,12 +61,16 @@ export default class Mapping { static fromXML(xmlNode, declaration) { const bounds = parseScoringAttrs(xmlNode); - 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', - })); + const entries = [...xmlNode.querySelectorAll('qti-map-entry')].map(entry => { + // Per QTI spec, case-sensitive is an xs:boolean defaulting to true — it is false + // only when explicitly set to one of its false lexical forms ('false' or '0'). + const caseSensitiveAttr = entry.getAttribute('case-sensitive'); + return { + mapKey: declaration.coerceValue(entry.getAttribute('map-key')), + mappedValue: parseFloat(entry.getAttribute('mapped-value')), + caseSensitive: caseSensitiveAttr !== 'false' && caseSensitiveAttr !== '0', + }; + }); return new Mapping({ ...bounds, entries }, declaration); } From f51b170a7c4a4af6313116068a369d96a93c7850 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 30 Jul 2026 16:29:16 -0700 Subject: [PATCH 2/8] feat: round-trip text-entry case sensitivity through qti-mapping Per-answer case sensitivity was written nowhere on save and read from a case-sensitive attribute on qti-value that is not part of the QTI schema. Serialize a qti-mapping alongside qti-correct-response for string base-type answers, and derive caseSensitive from it on parse, matching by map-key. Co-Authored-By: Claude Opus 5 (1M context) --- .../frontend/channelEdit/pages/qtiDemoData.js | 10 +- .../textEntry/__tests__/parse.spec.js | 199 ++++++++++++++++++ .../QTIEditor/interactions/textEntry/parse.js | 84 +++++++- 3 files changed, 282 insertions(+), 11 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js index eee0d600fd..8d5e475af3 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..ce86eb9914 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}"` : ''}`; @@ -85,6 +127,49 @@ describe('_extractAnswers', () => { const result = _extractAnswers([MULTI_NUMERIC_DECLARATION]); expect(result[0].id).not.toBe(result[1].id); }); + + describe('mapping-derived case sensitivity', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('reads caseSensitive from qti-map-entry by map-key', () => { + const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITH_MAPPING]); + const byValue = Object.fromEntries(result.map(a => [a.value, a.caseSensitive])); + expect(byValue).toEqual({ Paris: false, Madrid: true }); + }); + + it('ignores a map entry with no matching qti-value', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITH_MAPPING]); + expect(result).toHaveLength(2); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('falls back to caseSensitive true when there is no mapping', () => { + const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING]); + expect(result).toHaveLength(1); + expect(result[0].caseSensitive).toBe(true); + }); + + 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', () => { + // An empty map-key coerces to null (QTI NULL semantics) while the answer value + // is the empty string, so the two must still be matched up. + const result = _extractAnswers([BLANK_VALUE_DECLARATION]); + expect(result).toEqual([expect.objectContaining({ value: '', caseSensitive: false })]); + }); + + it('keeps the answers when the declaration is too malformed to model', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = _extractAnswers([DECLARATION_WITHOUT_IDENTIFIER]); + expect(result).toEqual([expect.objectContaining({ value: 'Paris', caseSensitive: true })]); + }); + }); }); describe('parseTextEntryInteraction', () => { @@ -147,6 +232,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 +359,98 @@ describe('buildTextEntryInteractionXML', () => { }); }); + describe('mapping', () => { + const CASE_ANSWERS = [ + { id: 'a1', value: 'Paris', caseSensitive: false }, + { id: 'a2', value: 'Madrid', caseSensitive: true }, + ]; + + /** Build the declaration for the given state and return it as both string and DOM. */ + function buildDeclaration(state, questionType, schema) { + const { responseDeclarations } = buildTextEntryInteractionXML(state, questionType, schema); + const [decl] = responseDeclarations; + return { decl, doc: new DOMParser().parseFromString(decl, 'text/xml') }; + } + + /** The happy-path build, shared by the tests that assert on CASE_ANSWERS. */ + function buildCaseDeclaration() { + return buildDeclaration( + { prompt: '', answers: CASE_ANSWERS, expectedLength: 0 }, + QuestionType.TEXT_ENTRY, + TEXT_ENTRY_MULTI_SCHEMA, + ); + } + + it('emits one qti-map-entry per string answer', () => { + const { doc } = buildCaseDeclaration(); + 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="false" only for case-insensitive answers', () => { + const entries = [...buildCaseDeclaration().doc.querySelectorAll('qti-map-entry')]; + // getAttribute is null when the attribute is absent — Madrid is case-sensitive, + // which is the spec default and so is left unwritten. + expect(entries.map(e => e.getAttribute('case-sensitive'))).toEqual(['false', null]); + }); + + 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( + { prompt: '', answers: CASE_ANSWERS, expectedLength: 0 }, + QuestionType.FREE_RESPONSE, + FREE_SCHEMA, + ); + expect(doc.querySelector('qti-mapping')).toBeNull(); + }); + + it('emits no mapping when there are zero answers', () => { + const { doc } = buildDeclaration( + { prompt: '', answers: [], expectedLength: 0 }, + QuestionType.TEXT_ENTRY, + TEXT_ENTRY_MULTI_SCHEMA, + ); + expect(doc.querySelector('qti-mapping')).toBeNull(); + }); + + it('emits qti-mapping after qti-correct-response per the XSD sequence', () => { + const { decl } = buildCaseDeclaration(); + expect(decl.indexOf(' { + const { doc } = buildDeclaration( + { + prompt: '', + answers: [{ id: 'a1', value: ' Paris ', caseSensitive: false }], + expectedLength: 0, + }, + QuestionType.TEXT_ENTRY, + TEXT_ENTRY_MULTI_SCHEMA, + ); + const mapKey = doc.querySelector('qti-map-entry').getAttribute('map-key'); + expect(mapKey).toBe('Paris'); + }); + }); + describe('round-trip', () => { it('numeric: parse → buildXML → parse yields equivalent state', () => { const original = { @@ -328,5 +506,26 @@ 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 }, + { id: 'a3', value: ' Rome ', caseSensitive: false }, + ], + 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: false }); + }); }); }); 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..6b73c15296 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'; @@ -69,13 +70,49 @@ function extractPromptHTML(bodyEl) { return clone.innerHTML.trim(); } +/** + * Build a value → caseSensitive lookup from a declaration's . + * + * Entries whose map-key matches no correct-response value are never looked up, and + * answers with no matching entry fall back to the spec default at the call site — + * neither case is an error. + * + * @param {Element} declEl - The element + * @returns {Map} + */ +function caseSensitivityByValue(declEl) { + let declaration; + try { + declaration = QTIDeclaration.fromXML(declEl); + } catch (err) { + // QTIDeclaration validates the declaration more strictly than answer extraction + // needs — a missing identifier, say, makes it unmodellable. The correct-response + // values are still readable, so degrade to the spec default for case sensitivity + // rather than discarding the author's answers. + // eslint-disable-next-line no-console + console.warn('[QTI Editor] Could not read case sensitivity:', err); + return new Map(); + } + + const { mapping } = declaration; + return new Map( + (mapping?.entries ?? []).map(entry => [ + // Key on the XML string form: map-key is coerced on parse (an empty key becomes + // null under QTI NULL semantics) while answer values stay raw text. + declaration.formatValue(entry.mapKey), + entry.caseSensitive, + ]), + ); +} + /** * 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. + * 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 }[]} @@ -109,11 +146,17 @@ export function _extractAnswers(responseDeclarations) { 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', - })); + const caseSensitivity = isString ? caseSensitivityByValue(declEl) : null; + + return valueEls.map(el => { + const value = el.textContent.trim(); + return { + id: generateRandomSlug('answer'), + value, + // Per spec, an answer with no matching qti-map-entry is case-sensitive. + caseSensitive: isString ? (caseSensitivity.get(value) ?? true) : false, + }; + }); } catch (err) { // eslint-disable-next-line no-console console.error('[QTI Editor] Failed to parse text-entry response declaration:', err); @@ -209,10 +252,31 @@ 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 must be constructed before Mapping: QTIDeclaration.getXML emits its + // children in capability insertion order, and the QTI schema requires + // to precede . + if (questionType !== QuestionType.FREE_RESPONSE && answers.length !== 0) { + new CorrectResponse( + answers.map(a => a.value), + declaration, + ); + + // is the spec-defined home for per-answer case sensitivity, which + // is a string-only concept. mapped-value is required by the schema; the authoring + // editor does not score responses, so every accepted answer maps to the same value. + 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, ); } From ea8fbb8a95fa76d2a80924a95bb9ea8668c02333 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 30 Jul 2026 16:45:51 -0700 Subject: [PATCH 3/8] fix: align qti-map-entry case-sensitive with the XSD default of false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QTI 3.0 XSD declares MapEntryDType/@case-sensitive as an optional xs:boolean with default="false", and Studio's own backend model agrees (assessment_item.py MapEntry.case_sensitive = False). Both the parser and serializer assumed the default was true, so a case-sensitive answer was written with the attribute omitted — which any consumer applying XSD attribute defaults, including Studio's publish-time validation path, reads back as case-insensitive. Write case-sensitive="true" for case-sensitive entries and omit it otherwise; treat an absent attribute as false on parse. Answers with no matching map entry now fall back to false, which also matches the editor's new-answer default and leaves items authored before this change parsing exactly as they did. Co-Authored-By: Claude Opus 5 (1M context) --- .../frontend/channelEdit/pages/qtiDemoData.js | 10 +++--- .../textEntry/__tests__/parse.spec.js | 35 +++++++++---------- .../QTIEditor/interactions/textEntry/parse.js | 9 ++--- .../qti/__tests__/declarations/fixtures.js | 2 +- .../__tests__/declarations/mapping.spec.js | 13 +++---- .../serialization/qti/declarations/mapping.js | 13 ++++--- 6 files changed, 42 insertions(+), 40 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js index 8d5e475af3..d4f3f9d50b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js +++ b/contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js @@ -131,12 +131,12 @@ export const TEXT_ENTRY_ITEM_XML = ` 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 ce86eb9914..2f13b5006c 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 @@ -36,8 +36,8 @@ const TEXT_ENTRY_DECLARATION_WITH_MAPPING = ` - - + + `.trim(); @@ -56,7 +56,7 @@ const BLANK_VALUE_DECLARATION = ` - + `.trim(); @@ -133,23 +133,19 @@ describe('_extractAnswers', () => { jest.restoreAllMocks(); }); - it('reads caseSensitive from qti-map-entry by map-key', () => { + 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 }); - }); - - it('ignores a map entry with no matching qti-value', () => { - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITH_MAPPING]); - expect(result).toHaveLength(2); expect(errorSpy).not.toHaveBeenCalled(); }); - it('falls back to caseSensitive true when there is no mapping', () => { + 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(true); + expect(result[0].caseSensitive).toBe(false); }); it('reports numeric answers as never case-sensitive', () => { @@ -159,15 +155,16 @@ describe('_extractAnswers', () => { it('matches a map entry for a blank answer value', () => { // An empty map-key coerces to null (QTI NULL semantics) while the answer value - // is the empty string, so the two must still be matched up. + // is the empty string, so the two must still be matched up. The entry declares + // case-sensitive="true", which the no-match fallback would never produce. const result = _extractAnswers([BLANK_VALUE_DECLARATION]); - expect(result).toEqual([expect.objectContaining({ value: '', caseSensitive: false })]); + expect(result).toEqual([expect.objectContaining({ value: '', caseSensitive: true })]); }); it('keeps the answers when the declaration is too malformed to model', () => { jest.spyOn(console, 'warn').mockImplementation(() => {}); const result = _extractAnswers([DECLARATION_WITHOUT_IDENTIFIER]); - expect(result).toEqual([expect.objectContaining({ value: 'Paris', caseSensitive: true })]); + expect(result).toEqual([expect.objectContaining({ value: 'Paris', caseSensitive: false })]); }); }); }); @@ -390,11 +387,11 @@ describe('buildTextEntryInteractionXML', () => { expect(entries.map(e => e.getAttribute('mapped-value'))).toEqual(['1', '1']); }); - it('writes case-sensitive="false" only for case-insensitive answers', () => { + it('writes case-sensitive="true" only for case-sensitive answers', () => { const entries = [...buildCaseDeclaration().doc.querySelectorAll('qti-map-entry')]; - // getAttribute is null when the attribute is absent — Madrid is case-sensitive, - // which is the spec default and so is left unwritten. - expect(entries.map(e => e.getAttribute('case-sensitive'))).toEqual(['false', null]); + // getAttribute is null when the attribute is absent — Paris is case-insensitive, + // which is the XSD default for the attribute and so is left unwritten. + expect(entries.map(e => e.getAttribute('case-sensitive'))).toEqual([null, 'true']); }); it('emits no mapping for numeric answers', () => { 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 6b73c15296..1652db05d8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -74,7 +74,7 @@ function extractPromptHTML(bodyEl) { * Build a value → caseSensitive lookup from a declaration's . * * Entries whose map-key matches no correct-response value are never looked up, and - * answers with no matching entry fall back to the spec default at the call site — + * answers with no matching entry fall back to the schema default at the call site — * neither case is an error. * * @param {Element} declEl - The element @@ -87,7 +87,7 @@ function caseSensitivityByValue(declEl) { } catch (err) { // QTIDeclaration validates the declaration more strictly than answer extraction // needs — a missing identifier, say, makes it unmodellable. The correct-response - // values are still readable, so degrade to the spec default for case sensitivity + // values are still readable, so degrade to the schema default for case sensitivity // rather than discarding the author's answers. // eslint-disable-next-line no-console console.warn('[QTI Editor] Could not read case sensitivity:', err); @@ -153,8 +153,9 @@ export function _extractAnswers(responseDeclarations) { return { id: generateRandomSlug('answer'), value, - // Per spec, an answer with no matching qti-map-entry is case-sensitive. - caseSensitive: isString ? (caseSensitivity.get(value) ?? true) : false, + // 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: isString && (caseSensitivity.get(value) ?? false), }; }); } catch (err) { 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 9883a5412a..7c565597c1 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 @@ -21,7 +21,7 @@ export const MAPPING_WITH_CI_XML = ` - + `.trim(); 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 cf97e8f5cf..e6dd6b1b65 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 @@ -85,19 +85,20 @@ 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', () => { + it('parses explicit case-sensitive values on entries', () => { const node = parseMappingXml(MAPPING_WITH_CI_XML); const declaration = makeDeclaration(); const m = Mapping.fromXML(node, declaration); @@ -169,7 +170,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, @@ -182,8 +183,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 fe40c98fb9..77dc795e66 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 @@ -62,13 +62,14 @@ export default class Mapping { const bounds = parseScoringAttrs(xmlNode); const entries = [...xmlNode.querySelectorAll('qti-map-entry')].map(entry => { - // Per QTI spec, case-sensitive is an xs:boolean defaulting to true — it is false - // only when explicitly set to one of its false lexical forms ('false' or '0'). + // Per the QTI 3.0 XSD (MapEntryDType), case-sensitive is an optional xs:boolean + // defaulting to false — it is true only when explicitly set to one of its true + // lexical forms ('true' or '1'). const caseSensitiveAttr = entry.getAttribute('case-sensitive'); return { mapKey: declaration.coerceValue(entry.getAttribute('map-key')), mappedValue: parseFloat(entry.getAttribute('mapped-value')), - caseSensitive: caseSensitiveAttr !== 'false' && caseSensitiveAttr !== '0', + caseSensitive: caseSensitiveAttr === 'true' || caseSensitiveAttr === '1', }; }); @@ -98,8 +99,10 @@ 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 case-sensitive when false — it is the XSD default. Writing it only for + // true keeps the emitted value and the schema default in agreement, so a + // consumer that applies XSD attribute defaults reads back what was authored. + if (entry.caseSensitive) entryAttrs['case-sensitive'] = 'true'; return buildXmlNode({ tag: 'qti-map-entry', attrs: entryAttrs }); }); From 2ddcbe314ad42ec44c07f21c789563eb8cd71455 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 30 Jul 2026 16:53:17 -0700 Subject: [PATCH 4/8] test: make the padded round-trip answer case-sensitive With case-sensitive defaulting to false, the padded ' Rome ' answer asserted nothing: a map-key that failed to match would fall back to false and the expectation would still hold. Marking it case-sensitive makes the assertion fail if the serializer stops trimming map-key. Co-Authored-By: Claude Opus 5 (1M context) --- .../interactions/textEntry/__tests__/parse.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 2f13b5006c..5ad1d208ce 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 @@ -510,7 +510,9 @@ describe('buildTextEntryInteractionXML', () => { answers: [ { id: 'a1', value: 'Paris', caseSensitive: false }, { id: 'a2', value: 'Madrid', caseSensitive: true }, - { id: 'a3', value: ' Rome ', caseSensitive: false }, + // Padded and case-sensitive: false would also be the no-match fallback, so + // only a case-sensitive answer proves the padded value found its map entry. + { id: 'a3', value: ' Rome ', caseSensitive: true }, ], expectedLength: 0, }; @@ -522,7 +524,7 @@ describe('buildTextEntryInteractionXML', () => { 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: false }); + expect(byValue).toEqual({ Paris: false, Madrid: true, Rome: true }); }); }); }); From 8e99eb1f2bcaa9a43ca01de423cefb11fed7fff0 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 30 Jul 2026 17:00:13 -0700 Subject: [PATCH 5/8] refactor: tighten case-sensitivity lookup and drop duplicated trim test Use optional chaining on the (null for float) lookup map instead of re-testing isString, so caseSensitive is always a boolean without relying on && short-circuit ordering. The map-key trim is already proven end-to-end by the padded answer in the round-trip test; drop the granular serializer test covering the same path and say why the round-trip answer is padded. Co-Authored-By: Claude Opus 5 (1M context) --- .../textEntry/__tests__/parse.spec.js | 19 +++---------------- .../QTIEditor/interactions/textEntry/parse.js | 2 +- 2 files changed, 4 insertions(+), 17 deletions(-) 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 5ad1d208ce..af783f0d53 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 @@ -432,20 +432,6 @@ describe('buildTextEntryInteractionXML', () => { const { decl } = buildCaseDeclaration(); expect(decl.indexOf(' { - const { doc } = buildDeclaration( - { - prompt: '', - answers: [{ id: 'a1', value: ' Paris ', caseSensitive: false }], - expectedLength: 0, - }, - QuestionType.TEXT_ENTRY, - TEXT_ENTRY_MULTI_SCHEMA, - ); - const mapKey = doc.querySelector('qti-map-entry').getAttribute('map-key'); - expect(mapKey).toBe('Paris'); - }); }); describe('round-trip', () => { @@ -510,8 +496,9 @@ describe('buildTextEntryInteractionXML', () => { answers: [ { id: 'a1', value: 'Paris', caseSensitive: false }, { id: 'a2', value: 'Madrid', caseSensitive: true }, - // Padded and case-sensitive: false would also be the no-match fallback, so - // only a case-sensitive answer proves the padded value found its map entry. + // Padded, so this answer only keeps its flag if map-key is written trimmed — + // the parser reads text trimmed. Case-sensitive because false is + // also the no-match fallback, which would hide a missed lookup. { id: 'a3', value: ' Rome ', caseSensitive: true }, ], expectedLength: 0, 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 1652db05d8..9d8230baa4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -155,7 +155,7 @@ export function _extractAnswers(responseDeclarations) { value, // 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: isString && (caseSensitivity.get(value) ?? false), + caseSensitive: caseSensitivity?.get(value) ?? false, }; }); } catch (err) { From 140624455bbe9f8734c5db27b4f12fb43aaaa836 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 30 Jul 2026 17:13:03 -0700 Subject: [PATCH 6/8] test: fold case-sensitive lexical forms into one table-driven test The two mapping fixtures differed only in the xs:boolean lexical form and fed two tests with identical assertions. Parameterize the test over both forms with inline XML and drop the single-use fixture. --- .../qti/__tests__/declarations/fixtures.js | 18 ----------- .../__tests__/declarations/mapping.spec.js | 30 +++++++++---------- 2 files changed, 14 insertions(+), 34 deletions(-) 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 7c565597c1..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,24 +17,6 @@ export const MAPPING_WITH_BOUNDS_XML = ` `.trim(); -export const MAPPING_WITH_CI_XML = ` - - - - - - -`.trim(); - -export const MAPPING_WITH_NUMERIC_BOOLEANS_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 e6dd6b1b65..28fcad6750 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,12 +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, - MAPPING_WITH_NUMERIC_BOOLEANS_XML, - SIMPLE_MAPPING_XML, -} from './fixtures.js'; +import { MAPPING_WITH_BOUNDS_XML, SIMPLE_MAPPING_XML } from './fixtures.js'; function makeDeclaration() { return new QTIDeclaration({ @@ -98,16 +93,19 @@ describe('Mapping', () => { }); }); - it('parses explicit case-sensitive values 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('parses numeric xs:boolean lexical forms on entries', () => { - const node = parseMappingXml(MAPPING_WITH_NUMERIC_BOOLEANS_XML); + // case-sensitive is an xs:boolean, so 'false' and '0' are both false, 'true' and '1' both 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); From 78e7163434355e623468228814a1d8b0f08a682d Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Thu, 30 Jul 2026 17:26:58 -0700 Subject: [PATCH 7/8] refactor: trim case-sensitivity comments and collapse duplication Comments across the case-sensitivity path restated what the adjacent code already showed. Cut them back to the non-obvious parts: the QTI NULL coercion of an empty map-key, the XSD default for case-sensitive, and the child-ordering constraint on getXML. Alongside that, drop the buildCaseDeclaration wrapper in favour of default arguments on buildDeclaration, and read the case-sensitive attribute through a single lexical-form check. --- .../textEntry/__tests__/parse.spec.js | 48 +++++++------------ .../QTIEditor/interactions/textEntry/parse.js | 27 ++++------- .../__tests__/declarations/mapping.spec.js | 1 - .../serialization/qti/declarations/mapping.js | 22 ++++----- 4 files changed, 34 insertions(+), 64 deletions(-) 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 af783f0d53..8934befee7 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 @@ -154,9 +154,8 @@ describe('_extractAnswers', () => { }); it('matches a map entry for a blank answer value', () => { - // An empty map-key coerces to null (QTI NULL semantics) while the answer value - // is the empty string, so the two must still be matched up. The entry declares - // case-sensitive="true", which the no-match fallback would never produce. + // Empty map-key coerces to null (QTI NULL) while the value stays ''; 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 })]); }); @@ -362,24 +361,21 @@ describe('buildTextEntryInteractionXML', () => { { 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, schema) { + 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') }; } - /** The happy-path build, shared by the tests that assert on CASE_ANSWERS. */ - function buildCaseDeclaration() { - return buildDeclaration( - { prompt: '', answers: CASE_ANSWERS, expectedLength: 0 }, - QuestionType.TEXT_ENTRY, - TEXT_ENTRY_MULTI_SCHEMA, - ); - } - it('emits one qti-map-entry per string answer', () => { - const { doc } = buildCaseDeclaration(); + const { doc } = buildDeclaration(CASE_STATE); expect(doc.querySelectorAll('qti-mapping')).toHaveLength(1); const entries = [...doc.querySelectorAll('qti-map-entry')]; @@ -388,9 +384,8 @@ describe('buildTextEntryInteractionXML', () => { }); it('writes case-sensitive="true" only for case-sensitive answers', () => { - const entries = [...buildCaseDeclaration().doc.querySelectorAll('qti-map-entry')]; - // getAttribute is null when the attribute is absent — Paris is case-insensitive, - // which is the XSD default for the attribute and so is left unwritten. + 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']); }); @@ -411,25 +406,17 @@ describe('buildTextEntryInteractionXML', () => { }); it('emits no mapping for free response', () => { - const { doc } = buildDeclaration( - { prompt: '', answers: CASE_ANSWERS, expectedLength: 0 }, - QuestionType.FREE_RESPONSE, - FREE_SCHEMA, - ); + 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( - { prompt: '', answers: [], expectedLength: 0 }, - QuestionType.TEXT_ENTRY, - TEXT_ENTRY_MULTI_SCHEMA, - ); + 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 } = buildCaseDeclaration(); + const { decl } = buildDeclaration(CASE_STATE); expect(decl.indexOf(' { answers: [ { id: 'a1', value: 'Paris', caseSensitive: false }, { id: 'a2', value: 'Madrid', caseSensitive: true }, - // Padded, so this answer only keeps its flag if map-key is written trimmed — - // the parser reads text trimmed. Case-sensitive because false is - // also the no-match fallback, which would hide a missed lookup. + // 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, 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 9d8230baa4..f1c23fd244 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -73,10 +73,6 @@ function extractPromptHTML(bodyEl) { /** * Build a value → caseSensitive lookup from a declaration's . * - * Entries whose map-key matches no correct-response value are never looked up, and - * answers with no matching entry fall back to the schema default at the call site — - * neither case is an error. - * * @param {Element} declEl - The element * @returns {Map} */ @@ -85,20 +81,17 @@ function caseSensitivityByValue(declEl) { try { declaration = QTIDeclaration.fromXML(declEl); } catch (err) { - // QTIDeclaration validates the declaration more strictly than answer extraction - // needs — a missing identifier, say, makes it unmodellable. The correct-response - // values are still readable, so degrade to the schema default for case sensitivity - // rather than discarding the author's answers. + // QTIDeclaration validates more strictly than answer extraction needs (a missing + // identifier makes it unmodellable), so degrade rather than drop the answers. // eslint-disable-next-line no-console console.warn('[QTI Editor] Could not read case sensitivity:', err); return new Map(); } - const { mapping } = declaration; + // Key on the XML string form: map-key is coerced on parse (empty → null under QTI + // NULL semantics) while answer values stay raw text. return new Map( - (mapping?.entries ?? []).map(entry => [ - // Key on the XML string form: map-key is coerced on parse (an empty key becomes - // null under QTI NULL semantics) while answer values stay raw text. + (declaration.mapping?.entries ?? []).map(entry => [ declaration.formatValue(entry.mapKey), entry.caseSensitive, ]), @@ -253,18 +246,16 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch tag: 'qti-response-declaration', }); - // CorrectResponse must be constructed before Mapping: QTIDeclaration.getXML emits its - // children in capability insertion order, and the QTI schema requires - // to precede . + // 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-defined home for per-answer case sensitivity, which - // is a string-only concept. mapped-value is required by the schema; the authoring - // editor does not score responses, so every accepted answer maps to the same value. + // 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( { 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 28fcad6750..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 @@ -93,7 +93,6 @@ describe('Mapping', () => { }); }); - // case-sensitive is an xs:boolean, so 'false' and '0' are both false, 'true' and '1' both true. it.each([ ['word', 'false', 'true'], ['numeric', '0', '1'], 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 77dc795e66..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 @@ -61,17 +61,12 @@ export default class Mapping { static fromXML(xmlNode, declaration) { const bounds = parseScoringAttrs(xmlNode); - const entries = [...xmlNode.querySelectorAll('qti-map-entry')].map(entry => { - // Per the QTI 3.0 XSD (MapEntryDType), case-sensitive is an optional xs:boolean - // defaulting to false — it is true only when explicitly set to one of its true - // lexical forms ('true' or '1'). - const caseSensitiveAttr = entry.getAttribute('case-sensitive'); - return { - mapKey: declaration.coerceValue(entry.getAttribute('map-key')), - mappedValue: parseFloat(entry.getAttribute('mapped-value')), - caseSensitive: caseSensitiveAttr === 'true' || caseSensitiveAttr === '1', - }; - }); + const entries = [...xmlNode.querySelectorAll('qti-map-entry')].map(entry => ({ + mapKey: declaration.coerceValue(entry.getAttribute('map-key')), + mappedValue: parseFloat(entry.getAttribute('mapped-value')), + // 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); } @@ -99,9 +94,8 @@ export default class Mapping { 'map-key': this._declaration.formatValue(entry.mapKey), 'mapped-value': entry.mappedValue, }; - // Omit case-sensitive when false — it is the XSD default. Writing it only for - // true keeps the emitted value and the schema default in agreement, so a - // consumer that applies XSD attribute defaults reads back what was authored. + // 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 }); }); From a54f46b04e5dafe4b022adcdd00b6be99c98eb23 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 31 Jul 2026 07:14:11 -0700 Subject: [PATCH 8/8] refactor: read text-entry answers through QTIDeclaration.fromXML _extractAnswers parsed by hand while a separate helper built the case-sensitivity lookup via QTIDeclaration. Fold both into one parse: the declaration class now supplies the correct responses as well as the mapping. A declaration too malformed for QTIDeclaration to model (e.g. a missing identifier) now yields no answers rather than falling back to raw DOM reads, matching how an unsupported base-type is already handled. Co-Authored-By: Claude Opus 5 (1M context) --- .../textEntry/__tests__/parse.spec.js | 25 ++++---- .../QTIEditor/interactions/textEntry/parse.js | 64 ++++++------------- 2 files changed, 31 insertions(+), 58 deletions(-) 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 8934befee7..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 @@ -95,6 +95,10 @@ describe('_defaultState', () => { }); describe('_extractAnswers', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('returns [] when no declaration is provided', () => { expect(_extractAnswers([])).toEqual([]); }); @@ -128,11 +132,13 @@ describe('_extractAnswers', () => { expect(result[0].id).not.toBe(result[1].id); }); - describe('mapping-derived case sensitivity', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); + 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]); @@ -154,17 +160,12 @@ describe('_extractAnswers', () => { }); it('matches a map entry for a blank answer value', () => { - // Empty map-key coerces to null (QTI NULL) while the value stays ''; the entry is - // case-sensitive="true" so a missed lookup can't slip through the false fallback. + // 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 })]); }); - - it('keeps the answers when the declaration is too malformed to model', () => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); - const result = _extractAnswers([DECLARATION_WITHOUT_IDENTIFIER]); - expect(result).toEqual([expect.objectContaining({ value: 'Paris', caseSensitive: false })]); - }); }); }); 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 f1c23fd244..921bd47091 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -70,34 +70,6 @@ function extractPromptHTML(bodyEl) { return clone.innerHTML.trim(); } -/** - * Build a value → caseSensitive lookup from a declaration's . - * - * @param {Element} declEl - The element - * @returns {Map} - */ -function caseSensitivityByValue(declEl) { - let declaration; - try { - declaration = QTIDeclaration.fromXML(declEl); - } catch (err) { - // QTIDeclaration validates more strictly than answer extraction needs (a missing - // identifier makes it unmodellable), so degrade rather than drop the answers. - // eslint-disable-next-line no-console - console.warn('[QTI Editor] Could not read case sensitivity:', err); - return new Map(); - } - - // Key on the XML string form: map-key is coerced on parse (empty → null under QTI - // NULL semantics) while answer values stay raw text. - return new Map( - (declaration.mapping?.entries ?? []).map(entry => [ - declaration.formatValue(entry.mapKey), - entry.caseSensitive, - ]), - ); -} - /** * Extract correct answer values from the response declaration string. * Returns an array of `{ id, value, caseSensitive }` objects, or [] when no @@ -115,40 +87,40 @@ 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 []; - - const caseSensitivity = isString ? caseSensitivityByValue(declEl) : null; + // 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 valueEls.map(el => { - const value = el.textContent.trim(); + return correctResponse.map(value => { + const formatted = declaration.formatValue(value); return { id: generateRandomSlug('answer'), - value, + 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(value) ?? false, + caseSensitive: caseSensitivity.get(formatted) ?? false, }; }); } catch (err) {