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 });
});