Repository navigation
Read and show Numeric answers in the exercise language - #6282
rtibblesbot wants to merge 3 commits into
Conversation
e5dd3d7 to
9848e69
Compare
| ...options, | ||
| // An unedited answer goes back as stored, so opening it in a language that reads it | ||
| // differently (German `1,2`) doesn't rewrite it. | ||
| storedValue: id => (uneditedIds.has(id) ? storedById.get(id).stored : undefined), |
There was a problem hiding this comment.
blocking: storedValue treats every answer as stored, including ones stored under a string declaration. Repro in a French exercise: make a Text entry question with the answer 1,5 and save it. Then switch it to Numeric. The editor shows 1,5 with "Must be a valid number (e.g. 12; 0,5; -3,14)". The message contradicts itself, since its own example has the same shape. buildXML also writes <qti-value>1,5</qti-value> into the float declaration.
The cause: uneditedIds includes the text answer, so storedValue returns the raw 1,5 and skips readLocaleNumber. The storedErrors check at line 67 then flags it because it isn't an xsd:double.
The reasoning in the comment at line 58 holds for answers already stored as numbers. A Text entry answer was never a stored number, though. Could you limit uneditedIds and storedErrors to answers parsed from a base-type="float" declaration? Then a switched answer is read in the exercise language like a typed one, and 1,5 stores 1.5.
There was a problem hiding this comment.
Let's drop all this storedValue overhead. Users must always see the actual stored value; e.g., if the user wrote "123.456,78" on blur, they should see "123456,78"; also, on language change, change from text entry to numeric, etc.
There was a problem hiding this comment.
Dropped storedValue, storedErrors and the stored/shown swap (0c2d8cd). A Numeric answer now shows its stored value in the exercise language:
- On open: the stored value, formatted.
- On switching to Numeric and on leaving an answer: read in the language, then formatted (
123.456,78→123456,78in German). - On a language change: the editor already remounts and re-parses the stored XML.
Your French repro now shows 1,5, no error, and stores 1.5 (captures). uneditedIds/storedById/storedValue were the only stored-vs-shown special cases; all removed.
One consequence: a legacy float answer stored as 1,5 reads fine in French, so its card in view mode no longer shows Incomplete; opening it rewrites it as 1.5. I dropped the four QTIItemEditor tests asserting that indicator. Should view mode still flag stored answers that aren't xsd:double?
| // Text that can't be read is stored as typed. Headless validation passes it when it is an | ||
| // xsd:double (German `1.5`), though the editor rejects it (#6150). | ||
| baseType === BaseType.FLOAT | ||
| ? (storedValue?.(a.id) ?? readLocaleNumber(a.value, language) ?? a.value) |
There was a problem hiding this comment.
suggestion: You list this as an open question, so here's what I found. In German, 1.5 gets INVALID_NUMERIC_VALUE in the editor. It's written as 1.5, though, so validateQtiItem passes it and the question doesn't count as incomplete. That's the same split #6150 (and now #6257) closed. When the question is reopened, parse formats the stored 1.5 as 1,5, and the error is gone.
The error only lasts until the editor re-parses, and the stored value is 1.5 either way. Would it be simpler to accept it, as French already does? Dropping the !grouped condition in readLocaleNumber would do that. 1.234 would still read as grouped in German, and 1.5 would read as 1.5. Then the editor, headless validation and the reopened view all agree.
There was a problem hiding this comment.
Done (b74a1bf): readLocaleNumber falls back to xsd:double whenever the language can't read the text, so German 1.5 reads as 1.5 and 1.234 as 1234. Searched localeNumbers.js, validation.js and parse.js for other spots where the editor and headless validation disagree on an xsd:double; the !grouped check was the only one. The German, Spanish and Italian cases moved to the "reads xsd:double" test, and de 1.5 joined the headless-agreement table in validateItem.spec.js.
| */ | ||
| export function exerciseLanguage(node, channel) { | ||
| const language = node?.language || channel?.language || currentLanguage; | ||
| return Intl.NumberFormat.supportedLocalesOf([language]).length ? language : ''; |
There was a problem hiding this comment.
suggestion: When the exercise's own language isn't supported, this returns '' without trying the channel's language. In Node's full ICU, 94 of the 286 ids in Languages.js aren't supported (ach, nv, dty, …). So a Navajo exercise in a French channel reads answers as xsd:double only, not French. The qaa test locks this in. Was that intended? If not, you could take the first supported candidate:
const candidates = [node?.language, channel?.language, currentLanguage].filter(Boolean);
return candidates.find(l => Intl.NumberFormat.supportedLocalesOf([l]).length) ?? '';There was a problem hiding this comment.
Not intended. Taken as suggested (8ab5361). The qaa test now expects the channel language, plus a case where both fall through to the UI language. exerciseLanguage is the only place the order is decided; ResourcePanel and useAssessmentItems both call it.
| // Stored numeric answers are shown formatted in the exercise language. Leaving Numeric puts | ||
| // the ones still shown that way back as stored, and returning formats them again; answers | ||
| // the author typed stay as typed. | ||
| const storedAnswers = textEntryInteractionDescriptor.parse( |
There was a problem hiding this comment.
suggestion: Two fragile spots here:
- The
storedValueclosure on line 24 readsuneditedIdsandstoredById, but both are declared later, at lines 35 and 60. It only works becauseuseInteractionbuilds the XML in a lazycomputed. Anything that readsbodyXmlduring setup would throw a TDZReferenceError. - The declarations are parsed twice, once with
languageand once without, and the results are paired by index.
Could parse stay language-free? This composable could parse once and set shown = formatLocaleNumber(stored, language) per answer, and the answer ids would line up for free. It would also drop the language option from parse/_extractAnswers/extractNumericAnswers. Display formatting is a concern of the editor, not of reading the XML.
There was a problem hiding this comment.
Done (0c2d8cd). parse, _extractAnswers and extractNumericAnswers no longer take a language; useInteraction passes options to buildXML and validate only. The composable formats the parsed answers once, so there's no second parse, no index pairing, and no closure over later declarations.
| errorInvalidNumericValue: { | ||
| message: 'Must be a valid number (e.g. 12, 0.5, -3.14)', | ||
| context: 'Validation error shown when an answer value is not a valid number', | ||
| message: 'Must be a valid number (e.g. {integer}; {decimal}; {negative})', |
There was a problem hiding this comment.
nitpick: Switching to ; avoids clashing with comma decimals, but the English source now reads "e.g. 12; 0.5; -3.14". Quoting each example avoids the clash and keeps normal commas:
errorInvalidNumericValue: {
message: 'Must be a valid number (e.g. "{integer}", "{decimal}", "{negative}")',
context:
'Validation error shown when an answer value is not a valid number. The placeholders are example numbers written the way the exercise language writes them, and may contain commas or periods. Keep each one inside quotation marks, using the quotation marks your language normally uses.',
},Use double quotes, not single quotes: in ICU ' is the escape character, so '{integer}' would print the literal placeholder.
There was a problem hiding this comment.
Taken as suggested (0c2d8cd). It's the only string with number placeholders.
9848e69 to
8ab5361
Compare
|
French exercise, live editor after 8ab5361:
fr-blur.webm |
ddc82ec to
3cf6bf9
Compare
3cf6bf9 to
25a1c22
Compare
c396c9d to
b9fe289
Compare
rtibbles
left a comment
There was a problem hiding this comment.
Slight change of approach to limit the amount of code we need to maintain ourselves.
| @@ -0,0 +1,93 @@ | |||
| import { parseXsdDouble } from './math'; | |||
There was a problem hiding this comment.
I think we can probably not have to implement this all ourselves here. This package: https://react-aria.adobe.com/internationalized/number/NumberParser provides most of the number parsing that we require (and can even do some additional number formatting too for display). I think we should lean on this, and drop the requirements that it can't meet (like the very strict separator parsing).
This is a well maintained and active library, so I feel confident this will be easier for us to use going forwards. We should also flag this for similar things on the Kolibri side, so that it can be using the same library and reduce internal code needs.
There was a problem hiding this comment.
Done in 87d076b: localeNumbers.js now wraps NumberParser / NumberFormatter, and the vendored numerals.js is gone. I searched the branch for other hand-written parsing and found those 2 files; both changed. parseXsdDouble stays, since it checks the stored format.
Dropped requirements:
- Group separators are accepted anywhere: English
1,23stores123, German1.5stores15. - Digits: the language's own plus the 6 systems
NumberParserdetects, instead of every Unicode\p{Nd}. - Answers read in a language store
String(number), so French1,50stores1.5. A plain xsd:double the language can't read (French1.5) is still stored as typed.
I drafted an edit to #6198 with these changes, and a Kolibri follow-up for the QTI text entry. It will be linked here once filed.
There was a problem hiding this comment.
Tracked in learningequality/kolibri#15387, which already covers this.
b9fe289 to
6416711
Compare
|
Updated #6198 as the review asked:
Written by rtibblesbot, an LLM-based coding agent. |
a90c89b to
408b88f
Compare
| watch( | ||
| () => options.language, | ||
| (_, oldLanguage) => { | ||
| // Answers shown or typed since parsing are written in the old language; the rest as stored. | ||
| mapAnswerValues(({ id, value, stored }) => | ||
| untouchedIds.has(id) | ||
| ? formatLocaleNumber(stored, options.language) | ||
| : showStored(value, oldLanguage), | ||
| ); |
There was a problem hiding this comment.
Why did we bring back all the stored, untouchedIds, etc. logic that we had previously dropped?
There was a problem hiding this comment.
Dropped untouchedIds in b7d2778. A language change now reads each answer as shown in the old language and reshows it in the new one.
stored stays. It only keeps an unedited answer's stored form in buildXML. Without it, opening 1.50, 1E3 or 0012 in French saves 1.5, 1000 or 12, because opening an item syncs any change. The does not save a numeric answer stored as xsd:double on opening tests cover this.
Searched the QTIEditor tree for other per-answer tracking (untouched, stored). The only other match is numericMapKey, the stored use above.
0c604dd to
bf8d594
Compare
4394240 to
7a16cf1
Compare
Parsing and formatting come from @internationalized/number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Display uses the language's separators and digits; the XML keeps canonical xsd:double. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Falls back to the channel language, then the UI language. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
7a16cf1 to
263f0de
Compare




Summary
xsd:double۷٫۵+1stores7.51)References
Fixes #6198. See #6150. Vendored from: kolibri
numeralNormalization.jsReviewer guidance
fr, stored1234.5and30ar-EG, same itemfr, typing1,5(stores1.5)Seeded through
/api/sync/:xsd:doubleanswer sends one change; a valid one, none.qti-mapping.Open risks:
.before exactly three digits as grouping: German1.234→1234.1,2).xsd:doubleanswers stay flagged until opened.QA team: Numeric answers use the exercise language's number format. Arabic exercises and language changes have the most risk.
1.50and+1,5. Press Tab. The answers show1,50and1,5. Open the exercise again. The answers do not change.1.50. In Details, set Language to Français (fr). The answer shows1,50.-0.5and0.5. All answers align left, with the minus on the left. Set Response type to Text entry. The answers keep their digits.7.5. Type1at the end on an ASCII keyboard. Press Tab. The answer shows۷٫۵۱. No alert shows. Do the same in a বাংলা (bn) exercise. The answer shows৭.৫১.Look out for:
In Chrome, العربية (ar) exercises show Latin digits.
Evidence
Numeric answers in an Arabic (ar-EG) exercise
0.5added: minus on the left, all fields left-alignedEdit Numeric answers with ASCII digits in native-digit exercises
1appended:۷٫۵۱, no alert1prepended:۱۷٫۵1appended:৭.৫১1prepended:১৭.৫1٣: invalid-number alertMore captures (9)
1appended:٧٫٥١1prepended:١٧٫٥۱۷٫۵0.5added1,50,0.51,50,-0,0,0000001Unchanged: Text entry, unset and French language.
AI usage
Claude Code planned and implemented the change from the issue's rules. Verified with the QTIEditor and channelEdit Jest suites, pre-commit, and screenshots of the editor in a headless browser.
🤖 Generated with Claude Code
Deviations from the issue spec
Intl's default digits:ar-EGshows٣٠, but Chrome'sarandurshow Latin digitsWritten by rtibblesbot, an LLM-based coding agent.
🟡 Waiting for feedback
Last updated: 2026-10-09 03:43 UTC