Skip to content

Validate QTI numeric answers the same way inline and headlessly - #6258

Merged
AlexVelezLl merged 3 commits into
learningequality:unstablefrom
rtibblesbot:issue-6150-4c7ff5
Oct 2, 2026
Merged

AlexVelezLl merged 3 commits into
learningequality:unstablefrom
rtibblesbot:issue-6150-4c7ff5

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Inline and headless validation accept only finite xsd:double Numeric answers, compared by value (21 duplicates 21.0).
  • Incomplete count matches inline errors.
  • Numeric and Text entry flag every repeated answer, the first included.

References

Fixes #6150. First seen: #6095 (comment)

Reviewer guidance

  • Answers reopen as authored (21.0), not normalized. Reformat on load?

QA steps

Setup: pnpm devsetup, admin login. New question: "Published Channel" → "Topic 2" → "Add" → "New exercise" → "Questions" → "New question", plus a prompt. QTI demo: /channels/<channel-id>/#/qti-demo.

  1. New question, Numeric: 5, -5, +5, 5., .5, 1e-5, 1E5, 2.3e+10. Only 5, +5, 5. show "Duplicate answers are not allowed".
  2. New question, Numeric: e, -, +, 1e, 1e2e3, 1.2.3, 1,234, INF, NaN, 1e400. Each shows "Must be a valid number (e.g. 12, 0.5, -3.14)"; "Close" shows "Incomplete".
  3. New question, Numeric: 21, 21.0, 1e2, 100. All show "Duplicate answers are not allowed".
  4. New question, Numeric: e, -: no duplicate error. Change - to e: both show "Duplicate answers are not allowed".
  5. New question, Numeric: 21.0, 1e-5; "Close", "Edit", edit the prompt, "Close", "Edit": still 21.0 and 1e-5.
  6. New question, Text entry: Paris, paris, "Case-sensitive" unticked: both show "Duplicate answers are not allowed"; ticking "Case-sensitive" on both clears them.
  7. QTI demo, "Question 3 of 8 — Numeric" → "Edit": 21, 21.0, then "Add acceptable answer" for 2.1e1, 5, e, e. All but 5 show "Duplicate answers are not allowed". "Delete answer 3": 21, 21.0 stay flagged; "Delete answer 2": 21 clears. "Close": Question 3 shows "Incomplete"; "Edit": both e flagged. Delete one e, set the other to 7, "Close": no "Incomplete".
  8. QTI demo, "Question 4 of 8 — Text entry" → "Edit": Paris, paris, PARIS unticked; "Add acceptable answer" twice for Paris, "Case-sensitive" ticked. All five show "Duplicate answers are not allowed". Tick "Case-sensitive" on paris: only it clears. Tick it on the first Paris: PARIS clears. "Close": only Question 4 shows "Incomplete"; "Edit": the three Paris flagged. "Delete answer 5", "Delete answer 4", "Close": no "Incomplete".

Evidence

Numeric repeated answers

Step Screenshot
Closed: "Incomplete" Incomplete
Fixed: no "Incomplete" Cleared
numeric-duplicates-flow.webm

Text entry repeated answers

Step Screenshot
All five flagged All five flagged
paris clears paris clears
PARIS clears PARIS clears
Only Question 4 "Incomplete" Question 4 Incomplete
Fixed: no "Incomplete" Cleared
More captures (4)
Step Screenshot
Numeric: all but 5 flagged All but 5 flagged
Numeric fixed: 21, 5, 7 Fixed
Text entry on open On open
Text entry reopened Reopened

AI usage

Claude Code wrote the fix and tests; verified with Jest and pre-commit.

Deviations from the issue spec

Criterion Why not
Accept every valid xsd:double 1e400 overflows to Infinity and is rejected like INF

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-10-01 22:02 UTC

@rtibblesbot
rtibblesbot marked this pull request as ready for review October 1, 2026 06:19
@rtibblesbot
rtibblesbot requested review from a team and rtibbles October 1, 2026 06:19
@rtibblesbot
rtibblesbot force-pushed the issue-6150-4c7ff5 branch 2 times, most recently from 640b7be to 11627c0 Compare October 1, 2026 06:53
@AlexVelezLl AlexVelezLl self-assigned this Oct 1, 2026

if (val) {
if (seen.has(lookupKey)) {
errors.push({ code: ValidationError.DUPLICATE_ANSWER_CONTENT, id: answer.id });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you fix the error here that only flags the latter value as a duplicate instead of flagging all of them?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c820430: every answer in a repeated group is now flagged, matching choice/validation.js.

  • Searched every DUPLICATE_* producer under QTIEditor/interactions/: 5 matched. Choice and ordering already flag all copies by id; associate and match flag by text. Text entry was the only one flagging just the later copy.
  • Tests now expect both ids (numeric and text entry), plus the headless validateItem cases.

checked.querySelectorAll(':scope > qti-correct-response, :scope > qti-mapping').forEach(el => {
el.remove();
});
QTIDeclaration.fromXML(checked);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's very difficult to understand what this is doing, and I'm not completely sure yet. Could you add more comments, please? Isn't there a better way to do it? Given that the QTIDeclaration.fromXML response isn't being stored anywhere, it makes me think this is rather asserting something indirectly, which is why the intention here is not clear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It asserts that the declaration parses. The answers are left out because fromXML coerces floats, which would throw on or truncate an invalid answer. The rest is still checked: identifier, cardinality and default value. A malformed float declaration then yields [], as on the string path.

Moved it into a named helper, assertDeclarationParses, with a docstring saying this (54f1f81).

Constructing QTIDeclaration from the attributes alone would be more direct. It would skip the default-value check, though, and the validateItem test "reports a non-numeric default value as no correct answer" fails without it. The other way is a raw-read mode in QTIDeclaration, which would change shared serialization code for one caller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the helper with the direct checks in 8baa10b: extractNumericAnswers constructs QTIDeclaration from the attributes (identifier, cardinality), then rejects a non-numeric default value explicitly. No fromXML call on the float path now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now in 2b0f97a: the default value goes through QTIDeclaration.coerceValue(…, BaseType.FLOAT), the same check fromXML applies, so a default like 1,234 that Kolibri accepts no longer drops the answers.

- Replace the loose regex, which accepted `e`, `-`, `1e2e3` and rejected `1e-5`, `1E5`
- Compare numeric answers by value, so `21` and `21.0` are duplicates

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rtibblesbot
rtibblesbot force-pushed the issue-6150-4c7ff5 branch 2 times, most recently from c820430 to e26c7ea Compare October 1, 2026 16:04
rtibblesbot and others added 2 commits October 1, 2026 09:32
Float coercion dropped every answer on an invalid value and merged `21`
with `21.0`, so headless validation reported an incomplete question the
editor showed no error for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Outside this PR's scope — tracked under #6103:


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code changes make sense, and manual QA checks out! LGTM!

@AlexVelezLl
AlexVelezLl merged commit 3332334 into learningequality:unstable Oct 2, 2026
18 checks passed
@rtibblesbot
rtibblesbot deleted the issue-6150-4c7ff5 branch October 2, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants