Repository navigation
[QTI] Numeric answer validation doesn't match xsd:double and differs between inline and headless validation #6150
Description
❌ This issue is not open for contribution. Visit Contributing guidelines to learn about the contributing process and how to find suitable issues.
Overview
The QTI editor validates Numeric answers as typed with a loose regex. Headless validation (validateQtiItem) validates them only after parseFloat normalization, so the two disagree. 21 and 21.0 together pass inline but count as a duplicate headlessly, so the exercise shows 1 incomplete question with no visible error. Neither path matches xsd:double: inline accepts e, - and 1e2e3, and rejects 1e-5 and 1E5.
Complexity: Low
Target branch: unstable
Context
- Reported by @LianaHarris360 in Replace the legacy assessment editor with the QTI editor #6095 (comment): 1 incomplete question stayed after every question was filled in, and went away once the numeric answer
21.0was removed. Recording: https://github.com/user-attachments/assets/623d1f6b-1566-4fc0-9538-2c369df8bc82 - Inline validation tests the typed string against
floatOrIntRegexand compares duplicates as strings:andLines 24 to 56 in 8cd2daf
if (questionType === QuestionType.NUMERIC || questionType === QuestionType.TEXT_ENTRY) { if (answers.length === 0) { errors.push({ code: ValidationError.NO_CORRECT_ANSWER }); } const seen = new Set(); for (const answer of answers) { const val = answer.value.trim(); if (questionType === QuestionType.NUMERIC) { if (!floatOrIntRegex.test(val)) { errors.push({ code: ValidationError.INVALID_NUMERIC_VALUE, id: answer.id }); } } else { if (!val) { errors.push({ code: ValidationError.EMPTY_ANSWER_CONTENT, id: answer.id }); } } 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 }); } seen.add(lookupKey); } studio/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/math.js
Line 8 in 8cd2daf
export const floatOrIntRegex = /^(?=.)([+-]?([0-9e]*)(\.([0-9e]+))?)$/; - Headless validation re-parses the saved XML.
QTIDeclaration.coerceValuerunsparseFloatonfloatvalues, so21.0comes back as21, and a value that doesn't parse (e,-) makes_extractAnswersreturn[], which is reported asNO_CORRECT_ANSWER:andLines 168 to 174 in 8cd2daf
case BaseType.FLOAT: { const n = parseFloat(raw); if (Number.isNaN(n)) { throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to float`); } return n; } Lines 85 to 131 in 8cd2daf
export function _extractAnswers(responseDeclarations) { const [declXml] = responseDeclarations || []; if (!declXml) return []; try { const declaration = QTIDeclaration.fromXML(parseXML(declXml).documentElement); const { baseType, correctResponse } = declaration; if (baseType !== BaseType.FLOAT && baseType !== BaseType.STRING) { // eslint-disable-next-line no-console console.error(`[QTI Editor] Unsupported text-entry base-type: ${baseType}`); return []; } 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 []; } // 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); return []; } }
The Change
- Inline and headless validation should both accept a Numeric answer only when it's a valid
xsd:double, excludingINF,-INFandNaN. - Both should compare Numeric answers by value when checking for duplicates, so
21and21.0count as duplicates on both paths.
How to Get There
- On
unstable, open an exercise in the QTI editor and add a Numeric question with a prompt and the accepted answers21and21.0. No inline error appears. - Close the editor. The exercise shows 1 incomplete question.
- Reopen it and change one answer to
e. No inline error appears, and the exercise still shows 1 incomplete question after closing.
Out of Scope
- The legacy converter's number handling ([QTI] Legacy input question conversion produces wrong or unanswerable QTI #6196).
- Multi-answer serialization (QTI editor emits cardinality="multiple" and match_correct for multi-answer Numeric and Text entry questions #6187).
Acceptance Criteria
General
- Inline and headless validation both accept
5,-5,+5,5.,.5,1e-5,1E5and2.3e+10. - Inline and headless validation both report
INVALID_NUMERIC_VALUEfore,-,+,1e,1e2e3,1.2.3,1,234,INFandNaN. -
21together with21.0reportsDUPLICATE_ANSWER_CONTENTon both paths. - For every value above, the exercise's incomplete question count agrees with the inline errors.
Testing
- Tests run each value above through both inline validation and
validateQtiItem.
Testing
pnpm test contentcuration/contentcuration/frontend/shared/views/QTIEditor
AI usage
I decided the accepted number format and that 21 and 21.0 count as duplicates. Claude Code traced both validation paths from the original report and drafted this rewrite section by section.

Activity
[-][QTI] Numeric question type doesn't have the same results for inline errors vs headless validation[/-][+][QTI] Numeric answer validation doesn't match xsd:double and differs between inline and headless validation[/+]rtibblesbot commentedon Sep 28, 2026
Watching for resolution on behalf of #6198.
rtibblesbot commentedon Oct 1, 2026
Plan: Inline and headless validation accept the same Numeric answers (
xsd:double, finite) and both detect duplicates by value (#6150).Assumptions — the issue did not settle these; say so if one is wrong.
1e400(overflows toInfinity) is rejected; if not, drop theNumber.isFinitecheck.21.0,1e-5), no longer normalized; if normalization is wanted, format valid values on parse.e+eduplicate,e+-not); if wrong, skip the duplicate check for invalid answers.identifierkeeps its answers instead of reportingNO_CORRECT_ANSWER; if that matters, validate attributes before reading values.@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?