Skip to content

Commit 0412db3

Browse files
authored
Merge pull request learningequality#6057 from rtibblesbot/issue-6055-a45a64
Round-trip text-entry case sensitivity through qti-mapping
2 parents a9d178a + a54f46b commit 0412db3

6 files changed

Lines changed: 259 additions & 49 deletions

File tree

contentcuration/contentcuration/frontend/channelEdit/pages/qtiDemoData.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,18 @@ export const TEXT_ENTRY_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
126126
base-type="string"
127127
>
128128
<qti-correct-response>
129-
<qti-value case-sensitive="true">H2O</qti-value>
129+
<qti-value>H2O</qti-value>
130130
<qti-value>h2o</qti-value>
131131
<qti-value>H2o</qti-value>
132132
</qti-correct-response>
133+
134+
<!-- Per-answer case sensitivity lives here; entries are case-insensitive by
135+
default, so only the case-sensitive answer carries the attribute. -->
136+
<qti-mapping default-value="0">
137+
<qti-map-entry map-key="H2O" mapped-value="1" case-sensitive="true"/>
138+
<qti-map-entry map-key="h2o" mapped-value="1"/>
139+
<qti-map-entry map-key="H2o" mapped-value="1"/>
140+
</qti-mapping>
133141
</qti-response-declaration>
134142
135143
<qti-item-body>

contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,48 @@ const MULTI_NUMERIC_DECLARATION = `
2828
</qti-response-declaration>
2929
`.trim();
3030

31+
const TEXT_ENTRY_DECLARATION_WITH_MAPPING = `
32+
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="string">
33+
<qti-correct-response>
34+
<qti-value>Paris</qti-value>
35+
<qti-value>Madrid</qti-value>
36+
</qti-correct-response>
37+
<qti-mapping default-value="0">
38+
<qti-map-entry map-key="Paris" mapped-value="1" case-sensitive="false"/>
39+
<qti-map-entry map-key="Madrid" mapped-value="1" case-sensitive="true"/>
40+
<qti-map-entry map-key="Lisbon" mapped-value="1" case-sensitive="true"/>
41+
</qti-mapping>
42+
</qti-response-declaration>
43+
`.trim();
44+
45+
const TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING = `
46+
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
47+
<qti-correct-response>
48+
<qti-value>Paris</qti-value>
49+
</qti-correct-response>
50+
</qti-response-declaration>
51+
`.trim();
52+
53+
const BLANK_VALUE_DECLARATION = `
54+
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
55+
<qti-correct-response>
56+
<qti-value></qti-value>
57+
</qti-correct-response>
58+
<qti-mapping default-value="0">
59+
<qti-map-entry map-key="" mapped-value="1" case-sensitive="true"/>
60+
</qti-mapping>
61+
</qti-response-declaration>
62+
`.trim();
63+
64+
/** `identifier` is required by the QTI schema — QTIDeclaration refuses to model this. */
65+
const DECLARATION_WITHOUT_IDENTIFIER = `
66+
<qti-response-declaration cardinality="single" base-type="string">
67+
<qti-correct-response>
68+
<qti-value>Paris</qti-value>
69+
</qti-correct-response>
70+
</qti-response-declaration>
71+
`.trim();
72+
3173
/** Build a minimal <qti-item-body> with the given prompt div and the interaction. */
3274
function makeBodyXml({ promptHtml = '', expectedLength = null } = {}) {
3375
const interactionAttrs = `response-identifier="RESPONSE"${expectedLength ? ` expected-length="${expectedLength}"` : ''}`;
@@ -53,6 +95,10 @@ describe('_defaultState', () => {
5395
});
5496

5597
describe('_extractAnswers', () => {
98+
afterEach(() => {
99+
jest.restoreAllMocks();
100+
});
101+
56102
it('returns [] when no declaration is provided', () => {
57103
expect(_extractAnswers([])).toEqual([]);
58104
});
@@ -85,6 +131,42 @@ describe('_extractAnswers', () => {
85131
const result = _extractAnswers([MULTI_NUMERIC_DECLARATION]);
86132
expect(result[0].id).not.toBe(result[1].id);
87133
});
134+
135+
it('returns [] when the declaration is too malformed to model', () => {
136+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
137+
expect(_extractAnswers([DECLARATION_WITHOUT_IDENTIFIER])).toEqual([]);
138+
expect(errorSpy).toHaveBeenCalled();
139+
});
140+
141+
describe('mapping-derived case sensitivity', () => {
142+
it('reads caseSensitive by map-key, silently ignoring unmatched entries', () => {
143+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
144+
const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITH_MAPPING]);
145+
// The Lisbon entry has no <qti-value>, so it contributes no answer and no error.
146+
const byValue = Object.fromEntries(result.map(a => [a.value, a.caseSensitive]));
147+
expect(byValue).toEqual({ Paris: false, Madrid: true });
148+
expect(errorSpy).not.toHaveBeenCalled();
149+
});
150+
151+
it('falls back to caseSensitive false when there is no mapping', () => {
152+
const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING]);
153+
expect(result).toHaveLength(1);
154+
expect(result[0].caseSensitive).toBe(false);
155+
});
156+
157+
it('reports numeric answers as never case-sensitive', () => {
158+
const result = _extractAnswers([SINGLE_NUMERIC_DECLARATION]);
159+
expect(result[0].caseSensitive).toBe(false);
160+
});
161+
162+
it('matches a map entry for a blank answer value', () => {
163+
// Both the empty map-key and the empty <qti-value> coerce to null (QTI NULL) and
164+
// format back to ''; the entry is case-sensitive="true" so a missed lookup can't
165+
// slip through the false fallback.
166+
const result = _extractAnswers([BLANK_VALUE_DECLARATION]);
167+
expect(result).toEqual([expect.objectContaining({ value: '', caseSensitive: true })]);
168+
});
169+
});
88170
});
89171

90172
describe('parseTextEntryInteraction', () => {
@@ -147,6 +229,7 @@ describe('buildTextEntryInteractionXML', () => {
147229
const FREE_SCHEMA = { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE };
148230
const NUMERIC_SINGLE_SCHEMA = { baseType: BaseType.FLOAT, cardinality: Cardinality.SINGLE };
149231
const NUMERIC_MULTI_SCHEMA = { baseType: BaseType.FLOAT, cardinality: Cardinality.MULTIPLE };
232+
const TEXT_ENTRY_MULTI_SCHEMA = { baseType: BaseType.STRING, cardinality: Cardinality.MULTIPLE };
150233

151234
describe('bodyXml', () => {
152235
it('produces a well-formed <qti-item-body>', () => {
@@ -273,6 +356,72 @@ describe('buildTextEntryInteractionXML', () => {
273356
});
274357
});
275358

359+
describe('mapping', () => {
360+
const CASE_ANSWERS = [
361+
{ id: 'a1', value: 'Paris', caseSensitive: false },
362+
{ id: 'a2', value: 'Madrid', caseSensitive: true },
363+
];
364+
365+
const CASE_STATE = { prompt: '', answers: CASE_ANSWERS, expectedLength: 0 };
366+
367+
/** Build the declaration for the given state and return it as both string and DOM. */
368+
function buildDeclaration(
369+
state,
370+
questionType = QuestionType.TEXT_ENTRY,
371+
schema = TEXT_ENTRY_MULTI_SCHEMA,
372+
) {
373+
const { responseDeclarations } = buildTextEntryInteractionXML(state, questionType, schema);
374+
const [decl] = responseDeclarations;
375+
return { decl, doc: new DOMParser().parseFromString(decl, 'text/xml') };
376+
}
377+
378+
it('emits one qti-map-entry per string answer', () => {
379+
const { doc } = buildDeclaration(CASE_STATE);
380+
expect(doc.querySelectorAll('qti-mapping')).toHaveLength(1);
381+
382+
const entries = [...doc.querySelectorAll('qti-map-entry')];
383+
expect(entries.map(e => e.getAttribute('map-key'))).toEqual(['Paris', 'Madrid']);
384+
expect(entries.map(e => e.getAttribute('mapped-value'))).toEqual(['1', '1']);
385+
});
386+
387+
it('writes case-sensitive="true" only for case-sensitive answers', () => {
388+
const entries = [...buildDeclaration(CASE_STATE).doc.querySelectorAll('qti-map-entry')];
389+
// null = attribute absent; false is the XSD default and so is left unwritten.
390+
expect(entries.map(e => e.getAttribute('case-sensitive'))).toEqual([null, 'true']);
391+
});
392+
393+
it('emits no mapping for numeric answers', () => {
394+
const { doc } = buildDeclaration(
395+
{
396+
prompt: '',
397+
answers: [
398+
{ id: 'a1', value: '0.5' },
399+
{ id: 'a2', value: '1.5' },
400+
],
401+
expectedLength: 0,
402+
},
403+
QuestionType.NUMERIC,
404+
NUMERIC_MULTI_SCHEMA,
405+
);
406+
expect(doc.querySelector('qti-mapping')).toBeNull();
407+
});
408+
409+
it('emits no mapping for free response', () => {
410+
const { doc } = buildDeclaration(CASE_STATE, QuestionType.FREE_RESPONSE, FREE_SCHEMA);
411+
expect(doc.querySelector('qti-mapping')).toBeNull();
412+
});
413+
414+
it('emits no mapping when there are zero answers', () => {
415+
const { doc } = buildDeclaration({ ...CASE_STATE, answers: [] });
416+
expect(doc.querySelector('qti-mapping')).toBeNull();
417+
});
418+
419+
it('emits qti-mapping after qti-correct-response per the XSD sequence', () => {
420+
const { decl } = buildDeclaration(CASE_STATE);
421+
expect(decl.indexOf('<qti-correct-response')).toBeLessThan(decl.indexOf('<qti-mapping'));
422+
});
423+
});
424+
276425
describe('round-trip', () => {
277426
it('numeric: parse → buildXML → parse yields equivalent state', () => {
278427
const original = {
@@ -328,5 +477,28 @@ describe('buildTextEntryInteractionXML', () => {
328477
const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations);
329478
expect(parsed.answers.map(a => a.value)).toEqual(['0.5', '1.5']);
330479
});
480+
481+
it('textEntry: round-trip preserves per-answer caseSensitive', () => {
482+
const original = {
483+
prompt: '<p>Name a capital city.</p>',
484+
answers: [
485+
{ id: 'a1', value: 'Paris', caseSensitive: false },
486+
{ id: 'a2', value: 'Madrid', caseSensitive: true },
487+
// Padded: keeps its flag only if map-key is written trimmed. Case-sensitive
488+
// because false is also the no-match fallback, which would hide a miss.
489+
{ id: 'a3', value: ' Rome ', caseSensitive: true },
490+
],
491+
expectedLength: 0,
492+
};
493+
const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML(
494+
original,
495+
QuestionType.TEXT_ENTRY,
496+
TEXT_ENTRY_MULTI_SCHEMA,
497+
);
498+
const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations);
499+
500+
const byValue = Object.fromEntries(parsed.answers.map(a => [a.value, a.caseSensitive]));
501+
expect(byValue).toEqual({ Paris: false, Madrid: true, Rome: true });
502+
});
331503
});
332504
});

contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration';
22
import { parseXML } from '../../serialization/parseItem';
33
import { buildXmlNode } from '../../serialization/assembleItem';
44
import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
5+
import Mapping from '../../serialization/qti/declarations/mapping';
56
import { generateRandomSlug } from '../../utils/generateRandomSlug';
67
import { BaseType, QuestionType, RESPONSE_IDENTIFIER } from '../../constants';
78

@@ -75,7 +76,8 @@ function extractPromptHTML(bodyEl) {
7576
* correct response is declared (i.e. free-response items).
7677
*
7778
* Supports both float (numeric) and string (textEntry) base-types.
78-
* The `caseSensitive` field is only meaningful for string base-type answers.
79+
* For string base-types `caseSensitive` comes from the declaration's
80+
* <qti-mapping>, matched by `map-key`; it is always false for float.
7981
*
8082
* @param {string[]} responseDeclarations
8183
* @returns {{ id: string, value: string, caseSensitive: boolean }[]}
@@ -85,35 +87,42 @@ export function _extractAnswers(responseDeclarations) {
8587
if (!declXml) return [];
8688

8789
try {
88-
const declEl = parseXML(declXml).documentElement;
89-
const isFloat = declEl.getAttribute('base-type') === BaseType.FLOAT;
90-
const isString = declEl.getAttribute('base-type') === BaseType.STRING;
90+
const declaration = QTIDeclaration.fromXML(parseXML(declXml).documentElement);
91+
const { baseType, correctResponse } = declaration;
9192

92-
if (!isFloat && !isString) {
93+
if (baseType !== BaseType.FLOAT && baseType !== BaseType.STRING) {
9394
// eslint-disable-next-line no-console
94-
console.error(
95-
`[QTI Editor] Unsupported text-entry base-type: ${declEl.getAttribute('base-type')}`,
96-
);
95+
console.error(`[QTI Editor] Unsupported text-entry base-type: ${baseType}`);
9796
return [];
9897
}
9998

100-
const correctResponseEl = declEl.querySelector('qti-correct-response');
101-
if (!correctResponseEl) {
102-
if (isFloat) {
99+
if (correctResponse === null) {
100+
if (baseType === BaseType.FLOAT) {
103101
// eslint-disable-next-line no-console
104102
console.error('[QTI Editor] Missing <qti-correct-response> for numeric interaction');
105103
}
106104
return [];
107105
}
108106

109-
const valueEls = [...correctResponseEl.querySelectorAll('qti-value')];
110-
if (valueEls.length === 0) return [];
111-
112-
return valueEls.map(el => ({
113-
id: generateRandomSlug('answer'),
114-
value: el.textContent.trim(),
115-
caseSensitive: isString && el.getAttribute('case-sensitive') === 'true',
116-
}));
107+
// Case sensitivity is a string-only concept, so numeric answers never read the mapping.
108+
const mapEntries = baseType === BaseType.STRING ? (declaration.mapping?.entries ?? []) : [];
109+
// Key on the XML string form: both map-key and correct-response values are coerced
110+
// on parse (empty → null under QTI NULL semantics), so formatting both back matches
111+
// them on equal terms.
112+
const caseSensitivity = new Map(
113+
mapEntries.map(entry => [declaration.formatValue(entry.mapKey), entry.caseSensitive]),
114+
);
115+
116+
return correctResponse.map(value => {
117+
const formatted = declaration.formatValue(value);
118+
return {
119+
id: generateRandomSlug('answer'),
120+
value: formatted,
121+
// An answer with no matching qti-map-entry — including every answer in an
122+
// item authored before mappings were written — takes the XSD default, false.
123+
caseSensitive: caseSensitivity.get(formatted) ?? false,
124+
};
125+
});
117126
} catch (err) {
118127
// eslint-disable-next-line no-console
119128
console.error('[QTI Editor] Failed to parse text-entry response declaration:', err);
@@ -209,10 +218,29 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch
209218
tag: 'qti-response-declaration',
210219
});
211220

212-
if (questionType !== QuestionType.FREE_RESPONSE) {
213-
if (answers.length !== 0) {
214-
new CorrectResponse(
215-
answers.map(a => a.value),
221+
// CorrectResponse before Mapping: getXML emits children in capability insertion
222+
// order, and the schema requires <qti-correct-response> to precede <qti-mapping>.
223+
if (questionType !== QuestionType.FREE_RESPONSE && answers.length !== 0) {
224+
new CorrectResponse(
225+
answers.map(a => a.value),
226+
declaration,
227+
);
228+
229+
// <qti-mapping> is the spec's home for per-answer case sensitivity (string-only).
230+
// mapped-value is schema-required but unused: the editor does not score responses.
231+
if (baseType === BaseType.STRING) {
232+
new Mapping(
233+
{
234+
defaultValue: 0,
235+
lowerBound: null,
236+
upperBound: null,
237+
entries: answers.map(a => ({
238+
// Trimmed to match how _extractAnswers reads <qti-value> text back.
239+
mapKey: a.value.trim(),
240+
mappedValue: 1,
241+
caseSensitive: Boolean(a.caseSensitive),
242+
})),
243+
},
216244
declaration,
217245
);
218246
}

contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/qti/__tests__/declarations/fixtures.js

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,6 @@ export const MAPPING_WITH_BOUNDS_XML = `
1717
</qti-response-declaration>
1818
`.trim();
1919

20-
export const MAPPING_WITH_CI_XML = `
21-
<qti-response-declaration identifier="RESPONSE" base-type="string" cardinality="single">
22-
<qti-mapping default-value="0">
23-
<qti-map-entry map-key="hello" mapped-value="1" case-sensitive="false"/>
24-
<qti-map-entry map-key="world" mapped-value="1"/>
25-
</qti-mapping>
26-
</qti-response-declaration>
27-
`.trim();
28-
2920
export const AREA_MAPPING_XML = `
3021
<qti-response-declaration identifier="RESPONSE" base-type="point" cardinality="single">
3122
<qti-area-mapping default-value="0">

0 commit comments

Comments
 (0)