diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js
index 7d46014754..aa6a475187 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useTextEntryInteraction.spec.js
@@ -3,11 +3,15 @@ import { useTextEntryInteraction } from '../useTextEntryInteraction';
import { QuestionType, ValidationError } from '../../constants';
function makeNumericBlock(answerValues = ['12']) {
- const values = answerValues.map(v => `${v}`).join('');
- const cardinality = answerValues.length > 1 ? 'multiple' : 'single';
+ const correct = answerValues.length ? `${answerValues[0]}` : '';
+ const mapping = answerValues.length
+ ? `${answerValues
+ .map(v => ``)
+ .join('')}`
+ : '';
const bodyXml = ``;
- const declaration = `${values}`;
+ const declaration = `${correct}${mapping}`;
return { bodyXml, responseDeclarations: [declaration] };
}
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
index 934189eea7..d07b7c820a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js
@@ -74,23 +74,15 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor {
/**
* Returns the response declaration schema for the given question type.
- * Cardinality is derived from answer count for NUMERIC and TEXT_ENTRY so it
- * stays in sync as answers are added or removed.
+ * Always single-cardinality: accepted answers are scored by a
+ * , not by a multiple-cardinality response.
*
* @param {string} questionType
- * @param {TextEntryState|null} [state]
* @returns {{ baseType: string, cardinality: string }}
*/
- getResponseDeclarationSchema(questionType, state = null) {
- if (questionType === QuestionType.FREE_RESPONSE) {
- return { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE };
- }
- const answerCount = state?.answers?.length ?? 0;
- const cardinality = answerCount > 1 ? Cardinality.MULTIPLE : Cardinality.SINGLE;
- if (questionType === QuestionType.TEXT_ENTRY) {
- return { baseType: BaseType.STRING, cardinality };
- }
- return { baseType: BaseType.FLOAT, cardinality };
+ getResponseDeclarationSchema(questionType) {
+ const baseType = questionType === QuestionType.NUMERIC ? BaseType.FLOAT : BaseType.STRING;
+ return { baseType, cardinality: Cardinality.SINGLE };
}
/**
@@ -111,7 +103,7 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor {
return buildTextEntryInteractionXML(
state,
questionType,
- this.getResponseDeclarationSchema(questionType, state),
+ this.getResponseDeclarationSchema(questionType),
);
}
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 d2399eadd7..2a9d4e7673 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
@@ -44,6 +44,19 @@ const TEXT_ENTRY_DECLARATION_WITH_MAPPING = `
`.trim();
+const SINGLE_TEXT_DECLARATION_WITH_MAPPING = `
+
+
+ Madrid
+
+
+
+
+
+
+
+`.trim();
+
const TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING = `
@@ -178,10 +191,44 @@ describe('_extractAnswers', () => {
expect(errorSpy).not.toHaveBeenCalled();
});
- it('falls back to caseSensitive false when there is no mapping', () => {
+ it('reads a single-cardinality text mapping in mapping order', () => {
+ const result = _extractAnswers([SINGLE_TEXT_DECLARATION_WITH_MAPPING]);
+ expect(result.map(a => [a.value, a.caseSensitive])).toEqual([
+ ['Madrid', true],
+ ['Paris', false],
+ ['Rome', false],
+ ]);
+ });
+
+ it('falls back to correct-response values when the mapping has no entries', () => {
+ const declXml = `
+
+ Paris
+
+
+ `.trim();
+ expect(_extractAnswers([declXml]).map(a => [a.value, a.caseSensitive])).toEqual([
+ ['Paris', true],
+ ]);
+ });
+
+ it('reads text answers as case-sensitive when there is no mapping', () => {
const result = _extractAnswers([TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING]);
expect(result).toHaveLength(1);
- expect(result[0].caseSensitive).toBe(false);
+ expect(result[0].caseSensitive).toBe(true);
+ });
+
+ it.each([
+ ['string', 'a', 'b', ['a', 'b']],
+ ['float', '1', '2', ['1', '2']],
+ ])('%s: keeps correct values missing from the mapping', (baseType, first, second, expected) => {
+ const declXml = `
+
+ ${first}${second}
+
+
+ `.trim();
+ expect(_extractAnswers([declXml]).map(a => a.value)).toEqual(expected);
});
it('reports numeric answers as never case-sensitive', () => {
@@ -249,6 +296,27 @@ describe('_extractAnswers', () => {
},
);
+ // The editor's own shape (correct response = first map-key) keeps repeats;
+ // any other dedupes map-keys by value against the answers already read.
+ it.each([
+ [['2'], ['2', '2'], ['2', '2']],
+ [['2'], ['2', '2.0'], ['2', '2.0']],
+ [['5'], ['5.0', '5'], ['5']],
+ [['1'], ['1.0', '1'], ['1']],
+ [['1'], ['1', '1.0'], ['1', '1.0']],
+ [[], ['1', '1'], ['1']],
+ ])('reads correct %j with map-keys %j as %j', (correct, mapKeys, expected) => {
+ const declXml = `
+
+ ${correct.length ? `${correct.map(v => `${v}`).join('')}` : ''}
+
+ ${mapKeys.map(key => ``).join('')}
+
+
+ `;
+ expect(_extractAnswers([declXml]).map(a => a.value)).toEqual(expected);
+ });
+
it('reads mapped-value as a leading number, as Mapping.fromXML does', () => {
const declXml = `
@@ -350,8 +418,7 @@ describe('parseTextEntryInteraction', () => {
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 };
+ const TEXT_ENTRY_SCHEMA = { baseType: BaseType.STRING, cardinality: Cardinality.SINGLE };
describe('bodyXml', () => {
it('produces a well-formed ', () => {
@@ -456,40 +523,6 @@ describe('buildTextEntryInteractionXML', () => {
expect(responseDeclarations[0]).toContain('base-type="float"');
});
- it('numeric with 2+ answers gets cardinality="multiple"', () => {
- const { responseDeclarations } = buildTextEntryInteractionXML(
- {
- prompt: '',
- answers: [
- { id: 'a1', value: '0.5' },
- { id: 'a2', value: '1.5' },
- ],
- expectedLength: 0,
- },
- QuestionType.NUMERIC,
- NUMERIC_MULTI_SCHEMA,
- );
- expect(responseDeclarations[0]).toContain('cardinality="multiple"');
- });
-
- it('numeric includes with each answer value', () => {
- const { responseDeclarations } = buildTextEntryInteractionXML(
- {
- prompt: '',
- answers: [
- { id: 'a1', value: '0.5' },
- { id: 'a2', value: '1.5' },
- ],
- expectedLength: 0,
- },
- QuestionType.NUMERIC,
- NUMERIC_MULTI_SCHEMA,
- );
- expect(responseDeclarations[0]).toContain('qti-correct-response');
- expect(responseDeclarations[0]).toContain('>0.5<');
- expect(responseDeclarations[0]).toContain('>1.5<');
- });
-
it('numeric with 0 answers omits (empty element is invalid per XSD)', () => {
const { responseDeclarations } = buildTextEntryInteractionXML(
{ prompt: '', answers: [], expectedLength: 0 },
@@ -512,7 +545,7 @@ describe('buildTextEntryInteractionXML', () => {
function buildDeclaration(
state,
questionType = QuestionType.TEXT_ENTRY,
- schema = TEXT_ENTRY_MULTI_SCHEMA,
+ schema = TEXT_ENTRY_SCHEMA,
) {
const { responseDeclarations } = buildTextEntryInteractionXML(state, questionType, schema);
const [decl] = responseDeclarations;
@@ -534,20 +567,21 @@ describe('buildTextEntryInteractionXML', () => {
expect(entries.map(e => e.getAttribute('case-sensitive'))).toEqual([null, 'true']);
});
- it('emits no mapping for numeric answers', () => {
+ it('keeps a blank numeric answer row as a blank map entry', () => {
const { doc } = buildDeclaration(
{
prompt: '',
answers: [
- { id: 'a1', value: '0.5' },
- { id: 'a2', value: '1.5' },
+ { id: 'a1', value: '1' },
+ { id: 'a2', value: '' },
],
expectedLength: 0,
},
QuestionType.NUMERIC,
- NUMERIC_MULTI_SCHEMA,
+ NUMERIC_SINGLE_SCHEMA,
);
- expect(doc.querySelector('qti-mapping')).toBeNull();
+ const entries = [...doc.querySelectorAll('qti-map-entry')];
+ expect(entries.map(e => e.getAttribute('map-key'))).toEqual(['1', '']);
});
it('emits no mapping for free response', () => {
@@ -604,7 +638,7 @@ describe('buildTextEntryInteractionXML', () => {
expect(parsed.expectedLength).toBe(DEFAULT_EXPECTED_LENGTH);
});
- it('multi-answer numeric: round-trip preserves all values', () => {
+ it('multi-answer numeric: round-trip preserves all values in order', () => {
const original = {
prompt: 'Q
',
answers: [
@@ -616,7 +650,7 @@ describe('buildTextEntryInteractionXML', () => {
const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML(
original,
QuestionType.NUMERIC,
- NUMERIC_MULTI_SCHEMA,
+ NUMERIC_SINGLE_SCHEMA,
);
const parsed = parseTextEntryInteraction(bodyXml, responseDeclarations);
expect(parsed.answers.map(a => a.value)).toEqual(['0.5', '1.5']);
@@ -701,12 +735,15 @@ describe('buildTextEntryInteractionXML', () => {
const { bodyXml, responseDeclarations } = buildTextEntryInteractionXML(
original,
QuestionType.TEXT_ENTRY,
- TEXT_ENTRY_MULTI_SCHEMA,
+ TEXT_ENTRY_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 });
+ expect(parsed.answers.map(a => [a.value, a.caseSensitive])).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 25bcf89cde..3b1b54352a 100644
--- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js
@@ -94,33 +94,43 @@ function extractNumericAnswers(declarationEl) {
const values = [...declarationEl.querySelectorAll(':scope > qti-correct-response qti-value')].map(
el => el.textContent.trim(),
);
- // Full-credit map-keys are answers too, as on the string path. One equal in value to an
- // answer already read (`5.0` for `5`) is that answer, so it is not added again.
+ // Full-credit map-keys are answers too, as on the string path.
+ const mapKeys = [...declarationEl.querySelectorAll(':scope > qti-mapping qti-map-entry')]
+ .filter(entry => parseFloat(entry.getAttribute('mapped-value')) >= 1)
+ .map(entry => (entry.getAttribute('map-key') ?? '').trim());
+ const toAnswers = answerValues =>
+ answerValues.map(value => ({
+ id: generateRandomSlug('answer'),
+ value,
+ caseSensitive: false,
+ }));
+ // The editor writes its first answer as the correct response and maps every answer,
+ // so its map-keys are the answers as authored, repeats included.
+ if (values.length === 1 && values[0] === mapKeys[0]) {
+ return toAnswers(mapKeys);
+ }
+ // Otherwise a map-key equal in value to an answer already read (`5.0` for `5`) is that
+ // answer, so it is not added again.
const keys = new Set(values.map(value => parseXsdDouble(value) ?? value));
- for (const entry of declarationEl.querySelectorAll(':scope > qti-mapping qti-map-entry')) {
- const value = (entry.getAttribute('map-key') ?? '').trim();
+ for (const value of mapKeys) {
const key = parseXsdDouble(value) ?? value;
- if (parseFloat(entry.getAttribute('mapped-value')) >= 1 && !keys.has(key)) {
+ if (!keys.has(key)) {
keys.add(key);
values.push(value);
}
}
- return values.map(value => ({
- id: generateRandomSlug('answer'),
- value,
- caseSensitive: false,
- }));
+ return toAnswers(values);
}
/**
- * 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).
+ * Extract accepted answers from the response declaration string.
+ * Returns an array of `{ id, value, caseSensitive }` objects, or [] when the
+ * declaration has none (i.e. free-response items).
*
* Supports both float (numeric) and string (textEntry) base-types.
* Answers are the correct response values plus any full-credit `map-key`s.
- * For string base-types `caseSensitive` comes from the declaration's
- * , matched by `map-key`; it is always false for float.
+ * For string base-types `caseSensitive` comes from the matching map entry, and
+ * is true when there is no ; it is always false for float.
*
* @param {string[]} responseDeclarations
* @returns {{ id: string, value: string, caseSensitive: boolean }[]}
@@ -164,9 +174,9 @@ export function _extractAnswers(responseDeclarations) {
return [...values].map(value => ({
id: generateRandomSlug('answer'),
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: caseSensitivity.get(value) ?? false,
+ // With no map entries, match_correct compares exactly; an answer missing from a
+ // mapping takes the XSD default, false.
+ caseSensitive: caseSensitivity.get(value) ?? !mapEntries.length,
}));
} catch (err) {
// eslint-disable-next-line no-console
@@ -264,29 +274,15 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch
// 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,
- );
- }
+ const isString = baseType === BaseType.STRING;
+ const entries = answers.map(a => ({
+ // Text keys are trimmed to match how _extractAnswers reads text back.
+ mapKey: isString ? a.value.trim() : a.value,
+ mappedValue: 1,
+ caseSensitive: isString && Boolean(a.caseSensitive),
+ }));
+ new CorrectResponse([answers[0].value], declaration);
+ new Mapping({ defaultValue: 0, lowerBound: null, upperBound: null, entries }, declaration);
}
const declarationXml = serializer.serializeToString(declaration.getXML());
diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/textEntryItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/textEntryItem.spec.js
new file mode 100644
index 0000000000..9bb5dfcff3
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/textEntryItem.spec.js
@@ -0,0 +1,199 @@
+// jest-dom's matchers reject the XML nodes this serialization produces.
+/* eslint-disable jest-dom/prefer-to-have-attribute, jest-dom/prefer-to-have-text-content */
+import { assembleItemXml } from '../assembleItem';
+import { parseItem } from '../parseItem';
+import { parseXML } from '../xml';
+import { textEntryInteractionDescriptor as descriptor } from '../../interactions/textEntry/Descriptor';
+import { QuestionType } from '../../constants';
+
+const answer = (value, caseSensitive = false) => ({
+ id: `answer_${value}`,
+ value,
+ caseSensitive,
+});
+
+const assemble = ({ bodyXml, responseDeclarations }) =>
+ assembleItemXml({
+ identifier: 'item_1',
+ title: 'Question',
+ language: 'en',
+ bodyXml,
+ responseDeclarations,
+ });
+
+const build = (questionType, answers) =>
+ assemble(descriptor.buildXML({ prompt: 'Q
', answers, expectedLength: 50 }, questionType));
+
+const read = xml => {
+ const [{ bodyXml, responseDeclarations }] = parseItem(xml).interactions;
+ return descriptor.parse(bodyXml, responseDeclarations);
+};
+
+const template = xml =>
+ parseXML(xml).querySelector('qti-response-processing').getAttribute('template');
+
+const summarize = ({ answers }) =>
+ answers.map(({ value, caseSensitive }) => [value, caseSensitive]);
+
+describe('a text entry item', () => {
+ describe('response processing', () => {
+ it.each([
+ [QuestionType.NUMERIC, [answer('1')]],
+ [QuestionType.TEXT_ENTRY, [answer('a')]],
+ [QuestionType.NUMERIC, [answer('1'), answer('2'), answer('3')]],
+ [QuestionType.TEXT_ENTRY, [answer('a'), answer('b'), answer('c')]],
+ ])('%s with %j is scored by map_response', (questionType, answers) => {
+ expect(template(build(questionType, answers))).toContain('rptemplates/map_response');
+ });
+
+ it.each([QuestionType.NUMERIC, QuestionType.TEXT_ENTRY])(
+ 'declares %s as single cardinality however many answers it has',
+ questionType => {
+ const doc = parseXML(build(questionType, [answer('1'), answer('2')]));
+ expect(doc.querySelector('qti-response-declaration').getAttribute('cardinality')).toBe(
+ 'single',
+ );
+ },
+ );
+ });
+
+ it.each([
+ [QuestionType.NUMERIC, [answer('1')]],
+ [QuestionType.TEXT_ENTRY, [answer('a'), answer('b')]],
+ ])('scores %s answers %j 1 each and anything else 0', (questionType, answers) => {
+ const doc = parseXML(build(questionType, answers));
+ expect(doc.querySelector('qti-mapping').getAttribute('default-value')).toBe('0');
+ const entries = [...doc.querySelectorAll('qti-map-entry')];
+ expect(entries.map(e => e.getAttribute('mapped-value'))).toEqual(answers.map(() => '1'));
+ });
+
+ describe('round trip', () => {
+ it.each([
+ [QuestionType.NUMERIC, [answer('1')]],
+ [QuestionType.NUMERIC, [answer('1'), answer('2'), answer('3')]],
+ [QuestionType.NUMERIC, [answer('2'), answer('2'), answer('2.0')]],
+ [QuestionType.TEXT_ENTRY, [answer('a')]],
+ [QuestionType.TEXT_ENTRY, [answer('a', true)]],
+ [QuestionType.TEXT_ENTRY, [answer('a', true), answer('b'), answer('c', true)]],
+ ])('%s keeps answers %j in order with their flags', (questionType, answers) => {
+ const once = read(build(questionType, answers));
+ const twice = read(build(questionType, once.answers));
+ expect(summarize(once)).toEqual(summarize({ answers }));
+ expect(summarize(twice)).toEqual(summarize(once));
+ });
+ });
+
+ it.each([
+ [QuestionType.NUMERIC, [answer('1'), answer('2'), answer('3')]],
+ [QuestionType.TEXT_ENTRY, [answer('a'), answer('b'), answer('c')]],
+ ])('holds only the first %s answer in the correct response', (questionType, answers) => {
+ const doc = parseXML(build(questionType, answers));
+ const values = [...doc.querySelectorAll('qti-correct-response qti-value')];
+ expect(values.map(v => v.textContent)).toEqual([answers[0].value]);
+ });
+
+ it('keeps Numeric answers as authored', () => {
+ const doc = parseXML(build(QuestionType.NUMERIC, [answer('1.0'), answer('.5'), answer('1e3')]));
+ const entries = [...doc.querySelectorAll('qti-map-entry')];
+ expect(entries.map(e => e.getAttribute('map-key'))).toEqual(['1.0', '.5', '1e3']);
+ expect(doc.querySelector('qti-correct-response qti-value').textContent).toBe('1.0');
+ });
+
+ it('writes the Text correct response as typed and its map key trimmed', () => {
+ const doc = parseXML(build(QuestionType.TEXT_ENTRY, [answer(' Paris ')]));
+ expect(doc.querySelector('qti-correct-response qti-value').textContent).toBe(' Paris ');
+ expect(doc.querySelector('qti-map-entry').getAttribute('map-key')).toBe('Paris');
+ });
+
+ it('writes the Numeric answer as typed', () => {
+ const doc = parseXML(build(QuestionType.NUMERIC, [answer(' 1 ')]));
+ expect(doc.querySelector('qti-correct-response qti-value').textContent).toBe(' 1 ');
+ expect(doc.querySelector('qti-map-entry').getAttribute('map-key')).toBe(' 1 ');
+ });
+
+ it('never marks a Numeric map entry case-sensitive', () => {
+ const doc = parseXML(build(QuestionType.NUMERIC, [answer('1', true)]));
+ expect(doc.querySelector('qti-map-entry').hasAttribute('case-sensitive')).toBe(false);
+ });
+
+ it('keeps invalid and repeated Numeric answers as authored', () => {
+ const xml = build(QuestionType.NUMERIC, [
+ answer('2'),
+ answer('1e'),
+ answer('0x10'),
+ answer('2.0'),
+ ]);
+ const entries = [...parseXML(xml).querySelectorAll('qti-map-entry')];
+ expect(entries.map(e => e.getAttribute('map-key'))).toEqual(['2', '1e', '0x10', '2.0']);
+ });
+
+ const item = declaration =>
+ '' +
+ declaration +
+ 'Q
' +
+ '';
+
+ describe('saved with cardinality="multiple"', () => {
+ const NUMERIC = item(
+ '' +
+ '123' +
+ '',
+ );
+ const TEXT = item(
+ '' +
+ 'abc' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '',
+ );
+
+ it('opens a Numeric item with every accepted answer', () => {
+ expect(summarize(read(NUMERIC))).toEqual([
+ ['1', false],
+ ['2', false],
+ ['3', false],
+ ]);
+ });
+
+ it('opens a Text item with every accepted answer and its case flag', () => {
+ expect(summarize(read(TEXT))).toEqual([
+ ['a', true],
+ ['b', false],
+ ['c', false],
+ ]);
+ });
+
+ it.each([
+ [QuestionType.NUMERIC, NUMERIC],
+ [QuestionType.TEXT_ENTRY, TEXT],
+ ])('rewrites a %s item as single cardinality scored by map_response', (questionType, xml) => {
+ const rewritten = assemble(
+ descriptor.buildXML({ ...read(xml), expectedLength: 50 }, questionType),
+ );
+ const doc = parseXML(rewritten);
+ expect(doc.querySelector('qti-response-declaration').getAttribute('cardinality')).toBe(
+ 'single',
+ );
+ expect(doc.querySelectorAll('qti-map-entry')).toHaveLength(3);
+ expect(template(rewritten)).toContain('rptemplates/map_response');
+ });
+ });
+
+ it('opens a Text item without a mapping as case-sensitive and rewrites it with one', () => {
+ const TEXT_WITHOUT_MAPPING = item(
+ '' +
+ 'a' +
+ '',
+ );
+ const state = read(TEXT_WITHOUT_MAPPING);
+ expect(summarize(state)).toEqual([['a', true]]);
+ const rewritten = assemble(
+ descriptor.buildXML({ ...state, expectedLength: 50 }, QuestionType.TEXT_ENTRY),
+ );
+ expect(template(rewritten)).toContain('rptemplates/map_response');
+ const entries = [...parseXML(rewritten).querySelectorAll('qti-map-entry')];
+ expect(entries.map(e => e.getAttribute('case-sensitive'))).toEqual(['true']);
+ });
+});