Skip to content

[QTI] Numeric answer validation doesn't match xsd:double and differs between inline and headless validation #6150

Description

@AlexVelezLl

❌ 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.0 was removed. Recording: https://github.com/user-attachments/assets/623d1f6b-1566-4fc0-9538-2c369df8bc82
  • Inline validation tests the typed string against floatOrIntRegex and compares duplicates as strings:
    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);
    }
    and
    export const floatOrIntRegex = /^(?=.)([+-]?([0-9e]*)(\.([0-9e]+))?)$/;
  • Headless validation re-parses the saved XML. QTIDeclaration.coerceValue runs parseFloat on float values, so 21.0 comes back as 21, and a value that doesn't parse (e, -) makes _extractAnswers return [], which is reported as NO_CORRECT_ANSWER:
    case BaseType.FLOAT: {
    const n = parseFloat(raw);
    if (Number.isNaN(n)) {
    throw new TypeError(`QTIDeclaration: cannot coerce "${raw}" to float`);
    }
    return n;
    }
    and
    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, excluding INF, -INF and NaN.
  • Both should compare Numeric answers by value when checking for duplicates, so 21 and 21.0 count as duplicates on both paths.

How to Get There

  1. On unstable, open an exercise in the QTI editor and add a Numeric question with a prompt and the accepted answers 21 and 21.0. No inline error appears.
  2. Close the editor. The exercise shows 1 incomplete question.
  3. 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

Acceptance Criteria

General

  • Inline and headless validation both accept 5, -5, +5, 5., .5, 1e-5, 1E5 and 2.3e+10.
  • Inline and headless validation both report INVALID_NUMERIC_VALUE for e, -, +, 1e, 1e2e3, 1.2.3, 1,234, INF and NaN.
  • 21 together with 21.0 reports DUPLICATE_ANSWER_CONTENT on 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions