Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,18 @@ export const TEXT_ENTRY_ITEM_XML = `<?xml version="1.0" encoding="UTF-8"?>
base-type="string"
>
<qti-correct-response>
<qti-value case-sensitive="true">H2O</qti-value>
<qti-value>H2O</qti-value>
<qti-value>h2o</qti-value>
<qti-value>H2o</qti-value>
</qti-correct-response>

<!-- Per-answer case sensitivity lives here; entries are case-insensitive by
default, so only the case-sensitive answer carries the attribute. -->
<qti-mapping default-value="0">
<qti-map-entry map-key="H2O" mapped-value="1" case-sensitive="true"/>
<qti-map-entry map-key="h2o" mapped-value="1"/>
<qti-map-entry map-key="H2o" mapped-value="1"/>
</qti-mapping>
</qti-response-declaration>

<qti-item-body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,48 @@ const MULTI_NUMERIC_DECLARATION = `
</qti-response-declaration>
`.trim();

const TEXT_ENTRY_DECLARATION_WITH_MAPPING = `
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="string">
<qti-correct-response>
<qti-value>Paris</qti-value>
<qti-value>Madrid</qti-value>
</qti-correct-response>
<qti-mapping default-value="0">
<qti-map-entry map-key="Paris" mapped-value="1" case-sensitive="false"/>
<qti-map-entry map-key="Madrid" mapped-value="1" case-sensitive="true"/>
<qti-map-entry map-key="Lisbon" mapped-value="1" case-sensitive="true"/>
</qti-mapping>
</qti-response-declaration>
`.trim();

const TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING = `
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
<qti-correct-response>
<qti-value>Paris</qti-value>
</qti-correct-response>
</qti-response-declaration>
`.trim();

const BLANK_VALUE_DECLARATION = `
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
<qti-correct-response>
<qti-value></qti-value>
</qti-correct-response>
<qti-mapping default-value="0">
<qti-map-entry map-key="" mapped-value="1" case-sensitive="true"/>
</qti-mapping>
</qti-response-declaration>
`.trim();

/** `identifier` is required by the QTI schema — QTIDeclaration refuses to model this. */
const DECLARATION_WITHOUT_IDENTIFIER = `
<qti-response-declaration cardinality="single" base-type="string">
<qti-correct-response>
<qti-value>Paris</qti-value>
</qti-correct-response>
</qti-response-declaration>
`.trim();

/** Build a minimal <qti-item-body> with the given prompt div and the interaction. */
function makeBodyXml({ promptHtml = '', expectedLength = null } = {}) {
const interactionAttrs = `response-identifier="RESPONSE"${expectedLength ? ` expected-length="${expectedLength}"` : ''}`;
Expand All @@ -53,6 +95,10 @@ describe('_defaultState', () => {
});

describe('_extractAnswers', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('returns [] when no declaration is provided', () => {
expect(_extractAnswers([])).toEqual([]);
});
Expand Down Expand Up @@ -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 <qti-value>, 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 <qti-value> 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', () => {
Expand Down Expand Up @@ -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 <qti-item-body>', () => {
Expand Down Expand Up @@ -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('<qti-correct-response')).toBeLessThan(decl.indexOf('<qti-mapping'));
});
});

describe('round-trip', () => {
it('numeric: parse → buildXML → parse yields equivalent state', () => {
const original = {
Expand Down Expand Up @@ -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: '<p>Name a capital city.</p>',
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 });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
* <qti-mapping>, matched by `map-key`; it is always false for float.
*
* @param {string[]} responseDeclarations
* @returns {{ id: string, value: string, caseSensitive: boolean }[]}
Expand All @@ -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 <qti-correct-response> 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);
Expand Down Expand Up @@ -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 <qti-correct-response> to precede <qti-mapping>.
if (questionType !== QuestionType.FREE_RESPONSE && answers.length !== 0) {
new CorrectResponse(
answers.map(a => a.value),
declaration,
);

// <qti-mapping> 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 <qti-value> text back.
mapKey: a.value.trim(),
mappedValue: 1,
caseSensitive: Boolean(a.caseSensitive),
})),
},
declaration,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,6 @@ export const MAPPING_WITH_BOUNDS_XML = `
</qti-response-declaration>
`.trim();

export const MAPPING_WITH_CI_XML = `
<qti-response-declaration identifier="RESPONSE" base-type="string" cardinality="single">
<qti-mapping default-value="0">
<qti-map-entry map-key="hello" mapped-value="1" case-sensitive="false"/>
<qti-map-entry map-key="world" mapped-value="1"/>
</qti-mapping>
</qti-response-declaration>
`.trim();

export const AREA_MAPPING_XML = `
<qti-response-declaration identifier="RESPONSE" base-type="point" cardinality="single">
<qti-area-mapping default-value="0">
Expand Down
Loading
Loading