Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { useTextEntryInteraction } from '../useTextEntryInteraction';
import { QuestionType, ValidationError } from '../../constants';

function makeNumericBlock(answerValues = ['12']) {
const values = answerValues.map(v => `<qti-value>${v}</qti-value>`).join('');
const cardinality = answerValues.length > 1 ? 'multiple' : 'single';
const correct = answerValues.length ? `<qti-value>${answerValues[0]}</qti-value>` : '';
const mapping = answerValues.length
? `<qti-mapping default-value="0">${answerValues
.map(v => `<qti-map-entry map-key="${v}" mapped-value="1"/>`)
.join('')}</qti-mapping>`
: '';

const bodyXml = `<qti-item-body><div><p>What is 3 \xd7 4?</p><p><qti-text-entry-interaction response-identifier="RESPONSE"/></p></div></qti-item-body>`;
const declaration = `<qti-response-declaration identifier="RESPONSE" cardinality="${cardinality}" base-type="float"><qti-correct-response>${values}</qti-correct-response></qti-response-declaration>`;
const declaration = `<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="float"><qti-correct-response>${correct}</qti-correct-response>${mapping}</qti-response-declaration>`;

return { bodyXml, responseDeclarations: [declaration] };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <qti-mapping>, 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 };
}

/**
Expand All @@ -111,7 +103,7 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor {
return buildTextEntryInteractionXML(
state,
questionType,
this.getResponseDeclarationSchema(questionType, state),
this.getResponseDeclarationSchema(questionType),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ const TEXT_ENTRY_DECLARATION_WITH_MAPPING = `
</qti-response-declaration>
`.trim();

const SINGLE_TEXT_DECLARATION_WITH_MAPPING = `
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
<qti-correct-response>
<qti-value>Madrid</qti-value>
</qti-correct-response>
<qti-mapping default-value="0">
<qti-map-entry map-key="Madrid" mapped-value="1" case-sensitive="true"/>
<qti-map-entry map-key="Paris" mapped-value="1"/>
<qti-map-entry map-key="Rome" mapped-value="1"/>
</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>
Expand Down Expand Up @@ -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 = `
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
<qti-correct-response><qti-value>Paris</qti-value></qti-correct-response>
<qti-mapping default-value="0"/>
</qti-response-declaration>
`.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 = `
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="${baseType}">
<qti-correct-response><qti-value>${first}</qti-value><qti-value>${second}</qti-value></qti-correct-response>
<qti-mapping default-value="0"><qti-map-entry map-key="${first}" mapped-value="1"/></qti-mapping>
</qti-response-declaration>
`.trim();
expect(_extractAnswers([declXml]).map(a => a.value)).toEqual(expected);
});

it('reports numeric answers as never case-sensitive', () => {
Expand Down Expand Up @@ -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 = `
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="float">
${correct.length ? `<qti-correct-response>${correct.map(v => `<qti-value>${v}</qti-value>`).join('')}</qti-correct-response>` : ''}
<qti-mapping default-value="0">
${mapKeys.map(key => `<qti-map-entry map-key="${key}" mapped-value="1"/>`).join('')}
</qti-mapping>
</qti-response-declaration>
`;
expect(_extractAnswers([declXml]).map(a => a.value)).toEqual(expected);
});

it('reads mapped-value as a leading number, as Mapping.fromXML does', () => {
const declXml = `
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="float">
Expand Down Expand Up @@ -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 <qti-item-body>', () => {
Expand Down Expand Up @@ -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 <qti-correct-response> 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 <qti-correct-response> (empty element is invalid per XSD)', () => {
const { responseDeclarations } = buildTextEntryInteractionXML(
{ prompt: '', answers: [], expectedLength: 0 },
Expand All @@ -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;
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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: '<p>Q</p>',
answers: [
Expand All @@ -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']);
Expand Down Expand Up @@ -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],
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <qti-mapping>, 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 <qti-mapping>; it is always false for float.
*
* @param {string[]} responseDeclarations
* @returns {{ id: string, value: string, caseSensitive: boolean }[]}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -264,29 +274,15 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch
// 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,
);
}
const isString = baseType === BaseType.STRING;
const entries = answers.map(a => ({
// Text keys are trimmed to match how _extractAnswers reads <qti-value> 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());
Expand Down
Loading
Loading