Skip to content

Commit 0a162bc

Browse files
rtibblesclaude
andcommitted
Localize restored answer state to native numeral system
When restoring saved answer state into widgets, convert ASCII digits back to the content locale's native numeral system. This ensures users who typed ٤٢ (stored as "42" by Phase 1 normalization) see ٤٢ when returning to an exercise, not the ASCII representation. Adds localizeNumerals and localizeUserInput as inverses of the existing normalize functions, with a roundtrip test confirming they are proper inverses. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eeb133b commit 0a162bc

File tree

3 files changed

+151
-1
lines changed

3 files changed

+151
-1
lines changed

kolibri/plugins/perseus_viewer/frontend/__tests__/numeralNormalization.spec.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
normalizeUserInput,
44
getLocalizedDigits,
55
localizeKeypadDigits,
6+
localizeNumerals,
7+
localizeUserInput,
68
} from '../numeralNormalization';
79

810
describe('normalizeNumerals', () => {
@@ -225,3 +227,109 @@ describe('localizeKeypadDigits', () => {
225227
expect(localizeKeypadDigits(container, null)).toBeNull();
226228
});
227229
});
230+
231+
describe('localizeNumerals', () => {
232+
it('converts ASCII digits to Eastern Arabic for ar-EG', () => {
233+
expect(localizeNumerals('42', 'ar-EG')).toBe('٤٢');
234+
});
235+
236+
it('converts all 10 digits for Arabic locale', () => {
237+
expect(localizeNumerals('0123456789', 'ar-EG')).toBe('٠١٢٣٤٥٦٧٨٩');
238+
});
239+
240+
it('leaves non-digit characters unchanged', () => {
241+
expect(localizeNumerals('x+y', 'ar-EG')).toBe('x+y');
242+
});
243+
244+
it('handles mixed digits and text', () => {
245+
expect(localizeNumerals('2x+3', 'ar-EG')).toBe('٢x+٣');
246+
});
247+
248+
it('handles decimal numbers', () => {
249+
expect(localizeNumerals('3.14', 'ar-EG')).toBe('٣.١٤');
250+
});
251+
252+
it('returns string unchanged for English locale', () => {
253+
expect(localizeNumerals('42', 'en')).toBe('42');
254+
});
255+
256+
it('returns string unchanged for null locale', () => {
257+
expect(localizeNumerals('42', null)).toBe('42');
258+
});
259+
260+
it('returns non-string values unchanged', () => {
261+
expect(localizeNumerals(42, 'ar-EG')).toBe(42);
262+
expect(localizeNumerals(null, 'ar-EG')).toBe(null);
263+
expect(localizeNumerals(undefined, 'ar-EG')).toBe(undefined);
264+
});
265+
266+
it('returns empty string unchanged', () => {
267+
expect(localizeNumerals('', 'ar-EG')).toBe('');
268+
});
269+
});
270+
271+
describe('localizeUserInput', () => {
272+
it('localizes numeric-input widget state', () => {
273+
const input = {
274+
'numeric-input 1': { currentValue: '42' },
275+
};
276+
expect(localizeUserInput(input, 'ar-EG')).toEqual({
277+
'numeric-input 1': { currentValue: '٤٢' },
278+
});
279+
});
280+
281+
it('localizes expression widget state (plain string)', () => {
282+
const input = {
283+
'expression 1': '2x+3',
284+
};
285+
expect(localizeUserInput(input, 'ar-EG')).toEqual({
286+
'expression 1': '٢x+٣',
287+
});
288+
});
289+
290+
it('leaves dropdown widget state unchanged (numeric value)', () => {
291+
const input = {
292+
'dropdown 1': { value: 2 },
293+
};
294+
expect(localizeUserInput(input, 'ar-EG')).toEqual({
295+
'dropdown 1': { value: 2 },
296+
});
297+
});
298+
299+
it('handles multiple widgets', () => {
300+
const input = {
301+
'numeric-input 1': { currentValue: '21' },
302+
'numeric-input 2': { currentValue: '7' },
303+
'expression 1': '5x',
304+
};
305+
expect(localizeUserInput(input, 'ar-EG')).toEqual({
306+
'numeric-input 1': { currentValue: '٢١' },
307+
'numeric-input 2': { currentValue: '٧' },
308+
'expression 1': '٥x',
309+
});
310+
});
311+
312+
it('returns input unchanged for English locale', () => {
313+
const input = { 'numeric-input 1': { currentValue: '42' } };
314+
expect(localizeUserInput(input, 'en')).toEqual(input);
315+
});
316+
317+
it('handles null and undefined gracefully', () => {
318+
expect(localizeUserInput(null, 'ar-EG')).toBe(null);
319+
expect(localizeUserInput(undefined, 'ar-EG')).toBe(undefined);
320+
});
321+
322+
it('handles nested arrays', () => {
323+
const input = ['4', ['2', '3']];
324+
expect(localizeUserInput(input, 'ar-EG')).toEqual(['٤', ['٢', '٣']]);
325+
});
326+
327+
it('is the inverse of normalizeUserInput for Arabic', () => {
328+
const original = {
329+
'numeric-input 1': { currentValue: '42' },
330+
'expression 1': '2x+3',
331+
};
332+
const localized = localizeUserInput(original, 'ar-EG');
333+
expect(normalizeUserInput(localized)).toEqual(original);
334+
});
335+
});

kolibri/plugins/perseus_viewer/frontend/numeralNormalization.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,42 @@ function localizeKeypadDigits(keypadContainer, locale) {
170170
return observer;
171171
}
172172

173+
/**
174+
* Convert ASCII digits in a string to localized digits for a given locale.
175+
* The reverse of normalizeNumerals: "42" → "٤٢" for Arabic.
176+
* Returns the string unchanged if the locale uses Western digits.
177+
*/
178+
function localizeNumerals(str, locale) {
179+
if (typeof str !== 'string') {
180+
return str;
181+
}
182+
const digits = getLocalizedDigits(locale);
183+
if (!digits) {
184+
return str;
185+
}
186+
return str.replace(/[0-9]/g, d => digits[Number(d)]);
187+
}
188+
189+
/**
190+
* Recursively localize all string values in a user input object.
191+
* The reverse of normalizeUserInput: converts ASCII digits back to
192+
* the locale's native numeral system for display in widgets.
193+
*
194+
* Used when restoring saved answer state so that users see their
195+
* answers in their native numeral format, not as ASCII digits.
196+
*/
197+
function localizeUserInput(input, locale) {
198+
if (!getLocalizedDigits(locale)) {
199+
return input;
200+
}
201+
return deepMapStrings(input, s => localizeNumerals(s, locale));
202+
}
203+
173204
export {
174205
normalizeNumerals,
175206
normalizeUserInput,
176207
getLocalizedDigits,
177208
localizeKeypadDigits,
209+
localizeNumerals,
210+
localizeUserInput,
178211
};

kolibri/plugins/perseus_viewer/frontend/views/PerseusRendererIndex.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@
5858
import perseusTranslator from '../translator';
5959
import { wrapPerseusMessages } from '../translationUtils';
6060
import widgetSolver from '../widgetSolver';
61-
import { normalizeUserInput, localizeKeypadDigits } from '../numeralNormalization';
61+
import {
62+
normalizeUserInput,
63+
localizeKeypadDigits,
64+
localizeUserInput,
65+
} from '../numeralNormalization';
6266
import imageMissing from './image_missing.svg';
6367
import TeX from './Tex';
6468
@@ -585,6 +589,11 @@
585589
if (userInput) {
586590
// Restore image URLs from placeholders to blob URLs
587591
userInput = JSON.parse(replaceImageUrls(JSON.stringify(userInput), this.perseusFileUrl));
592+
// Localize ASCII digits back to the content locale's numeral system
593+
// so users see their saved answers in their native format.
594+
// (Phase 1 normalized input to ASCII for scoring/storage.)
595+
const locale = this.lang && this.lang.id;
596+
userInput = localizeUserInput(userInput, locale);
588597
// Restore each widget's user input via the Renderer's handleUserInput callback
589598
const widgetIds = this.itemRenderer.getWidgetIds();
590599
for (const id of widgetIds) {

0 commit comments

Comments
 (0)