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
@@ -1,5 +1,8 @@
import { validateItemShape, validateQtiItem } from '../validateItem';
import { QuestionType, ValidationError } from '../constants';
import { assembleItemXml } from '../serialization/assembleItem';
import { textEntryInteractionDescriptor } from '../interactions/textEntry/Descriptor';
import { validateTextEntryInteraction } from '../interactions/textEntry/validation';
import {
VALID_CHOICE_ITEM_DOCUMENT,
CHOICE_ITEM_DOCUMENT_NO_PROMPT,
Expand Down Expand Up @@ -64,6 +67,85 @@ describe('validateQtiItem', () => {
});
});

describe('numeric answers', () => {
const { INVALID_NUMERIC_VALUE, DUPLICATE_ANSWER_CONTENT } = ValidationError;

it.each([
...['5', '-5', '+5', '5.', '.5', '1e-5', '1E5', '2.3e+10'].map(value => [[value], []]),
...[
'e',
'-',
'+',
'1e',
'1e2e3',
'1.2.3',
'1,234',
'INF',
'-INF',
'NaN',
'1e400',
'0x10',
'Infinity',
'',
].map(value => [[value], [INVALID_NUMERIC_VALUE]]),
[
['21', '21.0'],
[DUPLICATE_ANSWER_CONTENT, DUPLICATE_ANSWER_CONTENT],
],
[
['5', '5'],
[DUPLICATE_ANSWER_CONTENT, DUPLICATE_ANSWER_CONTENT],
],
[
['e', 'e'],
[
INVALID_NUMERIC_VALUE,
INVALID_NUMERIC_VALUE,
DUPLICATE_ANSWER_CONTENT,
DUPLICATE_ANSWER_CONTENT,
],
],
[
['e', '-'],
[INVALID_NUMERIC_VALUE, INVALID_NUMERIC_VALUE],
],
])('reports %j as %j, the same as the editor', (values, expected) => {
const state = {
prompt: '<p>Enter a number</p>',
answers: values.map((value, i) => ({ id: `a${i}`, value, caseSensitive: false })),
expectedLength: 0,
};
const { bodyXml, responseDeclarations } = textEntryInteractionDescriptor.buildXML(
state,
QuestionType.NUMERIC,
);
const xml = assembleItemXml({ identifier: 'item', title: '', bodyXml, responseDeclarations });

expect(codesOf(validateTextEntryInteraction(state, QuestionType.NUMERIC))).toEqual(expected);
expect(codesOf(validateQtiItem(xml))).toEqual(expected);
});

it('reports no correct answer past a non-numeric default value', () => {
const { bodyXml } = textEntryInteractionDescriptor.buildXML(
{ prompt: '<p>Enter a number</p>', answers: [], expectedLength: 0 },
QuestionType.NUMERIC,
);
const declaration =
'<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="float">' +
'<qti-default-value><qti-value>x</qti-value></qti-default-value>' +
'<qti-correct-response><qti-value>5</qti-value></qti-correct-response>' +
'</qti-response-declaration>';
const xml = assembleItemXml({
identifier: 'item',
title: '',
bodyXml,
responseDeclarations: [declaration],
});

expect(codesOf(validateQtiItem(xml))).toEqual([ValidationError.NO_CORRECT_ANSWER]);
});
});

it('reports unparseable XML', () => {
expect(validateQtiItem('<qti-assessment-item><oops>')).toEqual([
{ code: ValidationError.PARSE_ERROR },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,84 @@ describe('_extractAnswers', () => {
expect(result).toEqual([expect.objectContaining({ value: '', caseSensitive: true })]);
});
});

describe('numeric values', () => {
it.each(['21.0', '1e-5', 'e', '1e2e3', '1,234'])(
'reads numeric value %s as authored from <qti-value> and full-credit map-key',
value => {
const declXml = `
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="float">
<qti-correct-response>
<qti-value>${value}</qti-value>
</qti-correct-response>
<qti-mapping default-value="0">
<qti-map-entry map-key="${value}" mapped-value="1"/>
<qti-map-entry map-key="${value}9" mapped-value="1"/>
<qti-map-entry map-key="${value}8" mapped-value="0.5"/>
</qti-mapping>
</qti-response-declaration>
`;
expect(_extractAnswers([declXml]).map(a => a.value)).toEqual([value, `${value}9`]);
},
);

it.each(['5.0', ' 5 '])(
'reads full-credit map-key %j equal to a <qti-value> as one answer',
mapKey => {
const declXml = `
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="float">
<qti-correct-response>
<qti-value>5</qti-value>
</qti-correct-response>
<qti-mapping default-value="0">
<qti-map-entry map-key="${mapKey}" mapped-value="1"/>
</qti-mapping>
</qti-response-declaration>
`;
expect(_extractAnswers([declXml]).map(a => a.value)).toEqual(['5']);
},
);

it('reads mapped-value as a leading number, as Mapping.fromXML does', () => {
const declXml = `
<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="float">
<qti-correct-response>
<qti-value>1</qti-value>
</qti-correct-response>
<qti-mapping default-value="0">
<qti-map-entry map-key="2" mapped-value="1pt"/>
</qti-mapping>
</qti-response-declaration>
`;
expect(_extractAnswers([declXml]).map(a => a.value)).toEqual(['1', '2']);
});

it('returns [] for a float declaration with record cardinality', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const declXml = `
<qti-response-declaration identifier="RESPONSE" cardinality="record" base-type="float">
<qti-correct-response>
<qti-value>5</qti-value>
</qti-correct-response>
</qti-response-declaration>
`;
expect(_extractAnswers([declXml])).toEqual([]);
expect(errorSpy).toHaveBeenCalled();
});

it.each(['1,234', '1.2.3', '1e400', 'Infinity', '0x10', '', 'NULL'])(
'reads answers past default value %j, which fromXML accepts',
defaultValue => {
const declXml = `
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="float">
<qti-default-value><qti-value>${defaultValue}</qti-value></qti-default-value>
<qti-correct-response><qti-value>5</qti-value></qti-correct-response>
</qti-response-declaration>
`;
expect(_extractAnswers([declXml]).map(a => a.value)).toEqual(['5']);
},
);
});
});

describe('parseTextEntryInteraction', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,40 @@ describe('validateTextEntryInteraction', () => {
});
});

const duplicateIds = (values, questionType = QuestionType.NUMERIC) =>
validateTextEntryInteraction(
{
...VALID_NUMERIC_STATE,
answers: values.map((value, i) => ({ id: `a${i}`, value, caseSensitive: false })),
},
questionType,
)
.filter(e => e.code === ValidationError.DUPLICATE_ANSWER_CONTENT)
.map(e => e.id);

describe('DUPLICATE_ANSWER_CONTENT (numeric)', () => {
it('flags 1e-5 and 0.00001 as duplicates', () => {
expect(duplicateIds(['1e-5', '0.00001'])).toEqual(['a0', 'a1']);
});

it('flags every answer equal in value to another', () => {
expect(duplicateIds(['5', '21', '5.0', '+5'])).toEqual(['a0', 'a2', 'a3']);
});

it('does not flag 21 and 21.5 as duplicates', () => {
expect(duplicateIds(['21', '21.5'])).toEqual([]);
});
});

describe('DUPLICATE_ANSWER_CONTENT (textEntry)', () => {
it('flags every case-insensitive answer equal to another', () => {
expect(duplicateIds(['Paris', 'Rome', 'paris'], QuestionType.TEXT_ENTRY)).toEqual([
'a0',
'a2',
]);
});
});

describe('valid states return empty array', () => {
it('returns [] for a valid numeric state', () => {
expect(validateTextEntryInteraction(VALID_NUMERIC_STATE, QuestionType.NUMERIC)).toEqual([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ import { buildXmlNode, parseXML } from '../../serialization/xml';
import CorrectResponse from '../../serialization/qti/declarations/correctResponse';
import Mapping from '../../serialization/qti/declarations/mapping';
import { generateRandomSlug } from '../../utils/generateRandomSlug';
import { parseXsdDouble } from '../../utils/math';
import { BaseType, QuestionType, RESPONSE_IDENTIFIER } from '../../constants';

const serializer = new XMLSerializer();

/**
* @typedef {object} TextEntryAnswer
* @property {string} id - Client-side slug (not serialized to XML)
* @property {string} value - The answer value as a string. For numeric this is a
* float/int string (e.g. "12", "0.5"); for textEntry it
* is a free-form string (e.g. "Paris").
* @property {string} value - The answer value as a string. For numeric this is the
* authored text, valid or not (e.g. "12", "1e-5");
* for textEntry it is a free-form string (e.g. "Paris").
* @property {boolean} caseSensitive - textEntry only. When true, "H2O" ≠ "h2o".
* Always false for numeric answers.
*/
Expand Down Expand Up @@ -69,6 +70,48 @@ function extractPromptHTML(bodyEl) {
return clone.innerHTML.trim();
}

/**
* Numeric answers as authored, read from the XML rather than through
* `QTIDeclaration.fromXML`: its float coercion would throw on an invalid value (dropping
* every answer) or truncate it (`1.2.3` → 1.2), hiding it from validation.
*
* @param {Element} declarationEl - A float `<qti-response-declaration>`
* @returns {TextEntryAnswer[]}
*/
function extractNumericAnswers(declarationEl) {
// Built only to validate: throws on a bad identifier or cardinality, as fromXML does.
new QTIDeclaration({
identifier: declarationEl.getAttribute('identifier'),
baseType: BaseType.FLOAT,
cardinality: declarationEl.getAttribute('cardinality') ?? undefined,
});
// Run only to throw: fromXML rejects a non-numeric default value, dropping every answer.
for (const el of declarationEl.querySelectorAll(':scope > qti-default-value qti-value')) {
QTIDeclaration.coerceValue(el.textContent.trim(), BaseType.FLOAT);
}

// Repeats stay, so validation flags them as it does in the editor.
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.
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();
const key = parseXsdDouble(value) ?? value;
if (parseFloat(entry.getAttribute('mapped-value')) >= 1 && !keys.has(key)) {
keys.add(key);
values.push(value);
}
}
return values.map(value => ({
id: generateRandomSlug('answer'),
value,
caseSensitive: false,
}));
}

/**
* Extract correct answer values from the response declaration string.
* Returns an array of `{ id, value, caseSensitive }` objects, or [] when no
Expand All @@ -87,10 +130,15 @@ export function _extractAnswers(responseDeclarations) {
if (!declXml) return [];

try {
const declaration = QTIDeclaration.fromXML(parseXML(declXml).documentElement);
const declarationEl = parseXML(declXml).documentElement;
if (declarationEl.getAttribute('base-type') === BaseType.FLOAT) {
return extractNumericAnswers(declarationEl);
}

const declaration = QTIDeclaration.fromXML(declarationEl);
const { baseType, correctResponse } = declaration;

if (baseType !== BaseType.FLOAT && baseType !== BaseType.STRING) {
if (baseType !== BaseType.STRING) {
// eslint-disable-next-line no-console
console.error(`[QTI Editor] Unsupported text-entry base-type: ${baseType}`);
return [];
Expand Down Expand Up @@ -118,8 +166,7 @@ export function _extractAnswers(responseDeclarations) {
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.
// Case sensitivity is a string-only concept, so numeric answers never read it.
caseSensitive: baseType === BaseType.STRING && (caseSensitivity.get(value) ?? false),
caseSensitive: caseSensitivity.get(value) ?? false,
}));
} catch (err) {
// eslint-disable-next-line no-console
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { QuestionType, ValidationError } from '../../constants';
import { floatOrIntRegex } from '../../utils/math';
import { parseXsdDouble } from '../../utils/math';
import { hasRichTextContent } from '../../utils/richText';

/**
* Validate TextEntryState → ValidationError[].
*
* - numeric: prompt required + at least one answer + each value must be a valid number
* - numeric: prompt required + at least one answer + each value a finite xsd:double
* - textEntry: prompt required + at least one answer (any non-blank string)
* - freeResponse: prompt required only
*
Expand All @@ -26,35 +26,42 @@ export function validateTextEntryInteraction(state, questionType) {
errors.push({ code: ValidationError.NO_CORRECT_ANSWER });
}

const seen = new Set();
// A later match flags the first answer too.
const firstSeenId = new Map();
const duplicateIds = new Set();

for (const answer of answers) {
const val = answer.value.trim();
let lookupKey;

if (questionType === QuestionType.NUMERIC) {
if (!floatOrIntRegex.test(val)) {
const number = parseXsdDouble(val);
if (number === null) {
errors.push({ code: ValidationError.INVALID_NUMERIC_VALUE, id: answer.id });
}
// Invalid answers keep their text as key so two different ones don't collide.
lookupKey = number ?? val;
} else {
if (!val) {
errors.push({ code: ValidationError.EMPTY_ANSWER_CONTENT, id: answer.id });
}
const normalizedVal = answer.caseSensitive ? val : val.toLowerCase();
lookupKey = `${normalizedVal}|${answer.caseSensitive}`;
}

const normalizedVal =
questionType === QuestionType.TEXT_ENTRY && !answer.caseSensitive ? val.toLowerCase() : val;
const lookupKey =
questionType === QuestionType.TEXT_ENTRY
? `${normalizedVal}|${answer.caseSensitive}`
: normalizedVal;

if (val) {
if (seen.has(lookupKey)) {
errors.push({ code: ValidationError.DUPLICATE_ANSWER_CONTENT, id: answer.id });
if (firstSeenId.has(lookupKey)) {
duplicateIds.add(firstSeenId.get(lookupKey));
duplicateIds.add(answer.id);
} else {
firstSeenId.set(lookupKey, answer.id);
}
seen.add(lookupKey);
}
}

for (const duplicateId of duplicateIds) {
errors.push({ code: ValidationError.DUPLICATE_ANSWER_CONTENT, id: duplicateId });
}
}

return errors;
Expand Down
Loading
Loading