From c5ec0d246c638e644da7b9425f6577ac7f8d441a Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 2 Oct 2026 18:41:57 -0700 Subject: [PATCH 1/3] feat: read and format numbers in a language's conventions numerals.js vendors Kolibri's numeral normalization helpers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../utils/__tests__/localeNumbers.spec.js | 135 ++++++++++++++++++ .../views/QTIEditor/utils/localeNumbers.js | 98 +++++++++++++ .../shared/views/QTIEditor/utils/numerals.js | 93 ++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/localeNumbers.spec.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/localeNumbers.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/numerals.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/localeNumbers.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/localeNumbers.spec.js new file mode 100644 index 0000000000..9b67931b0e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/localeNumbers.spec.js @@ -0,0 +1,135 @@ +import { formatLocaleNumber, readLocaleNumber } from '../localeNumbers'; + +describe('readLocaleNumber', () => { + it('reads French separators', () => { + [ + ['1,5', '1.5'], + ['1,234', '1.234'], + ['1 234,5', '1234.5'], + ['1\u202F234,5', '1234.5'], + ['1\u00A0234,5', '1234.5'], + ['-0,5', '-0.5'], + ].forEach(([text, value]) => expect(readLocaleNumber(text, 'fr')).toBe(value)); + }); + + it('reads English separators', () => { + [ + ['1,234', '1234'], + ['1,234.5', '1234.5'], + ['1,234,567', '1234567'], + ['1.5e+3', '1.5e+3'], + ].forEach(([text, value]) => expect(readLocaleNumber(text, 'en')).toBe(value)); + }); + + it('reads Hindi grouping and digits', () => { + [ + ['12,34,567', '1234567'], + ['३०', '30'], + ].forEach(([text, value]) => expect(readLocaleNumber(text, 'hi')).toBe(value)); + }); + + it('reads Arabic-Indic digits', () => { + expect(readLocaleNumber('٣٠', 'ar')).toBe('30'); + }); + + it('reads Arabic separators in Arabic, whose conventions are Latin', () => { + [ + ['٣٫٥', '3.5'], + ['١٬٢٣٤٫٥', '1234.5'], + ].forEach(([text, value]) => expect(readLocaleNumber(text, 'ar')).toBe(value)); + }); + + it('reads xsd:double the language cannot read', () => { + [ + ['1.5', 'fr'], + ['3.5', 'ar-EG'], + ].forEach(([text, language]) => expect(readLocaleNumber(text, language)).toBe(text)); + }); + + it('rejects a decimal point where the language groups with it', () => { + [ + ['1.5', 'de'], + ['1.23', 'de'], + ['1.50', 'de'], + ['0.5', 'es'], + ['1.2345', 'it'], + ].forEach(([text, language]) => expect(readLocaleNumber(text, language)).toBeNull()); + expect(readLocaleNumber('1.500', 'de')).toBe('1500'); + }); + + it('rejects a leading group of zero', () => { + [ + ['0,500', 'en'], + ['0.500', 'de'], + ['00 500', 'fr'], + ].forEach(([text, language]) => expect(readLocaleNumber(text, language)).toBeNull()); + }); + + it('rejects group separators where the language does not place them', () => { + [ + ['1,23', 'en'], + ['12,345,67', 'en'], + ['1234,567', 'en'], + [',123', 'en'], + ['1,234,5', 'fr'], + ['1,234,567', 'hi'], + ['1,23,4', 'hi'], + ['1 234.5', 'fr'], + ['1.234,5', 'en'], + ].forEach(([text, language]) => expect(readLocaleNumber(text, language)).toBeNull()); + }); + + it('rejects text that is not a number', () => { + ['', '-', 'e', '1e', 'one', ',', '1.2.3', '1e400'].forEach(text => + expect(readLocaleNumber(text, 'fr')).toBeNull(), + ); + }); + + it('reads only xsd:double without a language', () => { + expect(readLocaleNumber('1.5', '')).toBe('1.5'); + expect(readLocaleNumber('1e+21', undefined)).toBe('1e+21'); + ['1,5', '1,234', '٣٠', '-'].forEach(text => expect(readLocaleNumber(text, '')).toBeNull()); + }); +}); + +describe('formatLocaleNumber', () => { + it('uses the language decimal separator without grouping', () => { + expect(formatLocaleNumber('1234.5', 'fr')).toBe('1234,5'); + expect(formatLocaleNumber('1234.5', 'en')).toBe('1234.5'); + expect(formatLocaleNumber('1234.5', 'hi')).toBe('1234.5'); + }); + + it('uses the language digits', () => { + expect(formatLocaleNumber('30', 'ar-EG')).toBe('٣٠'); + expect(formatLocaleNumber('30', 'hi')).toBe('30'); + }); + + it('returns the value unchanged without a language', () => { + expect(formatLocaleNumber('1234.5', '')).toBe('1234.5'); + }); + + it('leaves a stored value that is not an xsd:double unchanged', () => { + expect(formatLocaleNumber('1.2.3', 'ar-EG')).toBe('1.2.3'); + expect(formatLocaleNumber('1 234', 'fa')).toBe('1 234'); + }); + + it('reads back every valid stored form unchanged', () => { + ['en', 'fr', 'de', 'hi', 'ar', 'ar-EG', 'fa'].forEach(language => + ['0', '-0.5', '1234.5', '1e+21', '1e-7', '1.50', '+5', '.5', '5.', '007', '1.5E3'].forEach( + value => + expect(readLocaleNumber(formatLocaleNumber(value, language), language)).toBe(value), + ), + ); + }); + + it('keeps an invalid stored form the language cannot read', () => { + [ + ['1.2.3', 'ar-EG'], + ['1 234', 'fa'], + ['abc', 'fr'], + ['1,23', 'en'], + ].forEach(([value, language]) => + expect(readLocaleNumber(formatLocaleNumber(value, language), language)).toBeNull(), + ); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/localeNumbers.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/localeNumbers.js new file mode 100644 index 0000000000..9cb46df992 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/localeNumbers.js @@ -0,0 +1,98 @@ +import { parseXsdDouble } from './math'; +import { localizeNumerals, normalizeNumerals } from './numerals'; + +// Space, no-break space and narrow no-break space look alike, so authors can't tell them apart. +const spaceRegex = /[ \u00A0\u202F]/; + +const conventionsCache = new Map(); + +function numberConventions(language) { + if (!conventionsCache.has(language)) { + const parts = new Intl.NumberFormat(language).formatToParts(1234567890.5); + const group = parts.find(p => p.type === 'group')?.value; + const groupSizes = parts.filter(p => p.type === 'integer').map(p => p.value.length); + conventionsCache.set(language, { + decimal: parts.find(p => p.type === 'decimal').value, + group: spaceRegex.test(group) ? spaceRegex : group, + primarySize: groupSizes[groupSizes.length - 1], + secondarySize: groupSizes[groupSizes.length - 2], + }); + } + return conventionsCache.get(language); +} + +function ungroup(integer, { group, primarySize, secondarySize }) { + const groups = integer.split(group); + if (groups.length === 1) { + return /^\d*$/.test(integer) ? integer : null; + } + const first = groups[0]; + const middle = groups.slice(1, -1); + const last = groups[groups.length - 1]; + const grouped = + /^[1-9]\d*$/.test(first) && + groups.every(g => /^\d+$/.test(g)) && + first.length <= secondarySize && + middle.every(g => g.length === secondarySize) && + last.length === primarySize; + return grouped ? groups.join('') : null; +} + +// Arabic decimal and thousands separators, typed with Arabic-Indic digits even where `Intl` +// gives the language Latin conventions (`ar`). +function readArabicSeparators(text, { decimal, group }) { + return text + .replace(/\u066B/g, decimal) + .replace(/\u066C/g, typeof group === 'string' ? group : ' '); +} + +function readInLanguage(text, language) { + const conventions = numberConventions(language); + const [, sign, mantissa, exponent = ''] = /^([+-]?)([^eE]*)([eE].*)?$/.exec( + readArabicSeparators(normalizeNumerals(text.trim()), conventions), + ); + const [integer, ...fraction] = mantissa.split(conventions.decimal); + const digits = ungroup(integer, conventions); + if (digits === null || fraction.length > 1) { + return null; + } + return sign + digits + (fraction.length ? `.${fraction[0]}` : '') + exponent; +} + +function isXsdDouble(value) { + return value !== null && parseXsdDouble(value) !== null; +} + +/** + * Reads the canonical xsd:double out of `text` typed in `language`, or as xsd:double when + * the language can't read it and it has no group separator, so German `1.5` isn't read. + * @param {string} text + * @param {string} [language] - BCP 47 tag; falsy reads xsd:double only + * @returns {string|null} The canonical value, or null if it can't be read + */ +export function readLocaleNumber(text, language) { + const trimmed = text.trim(); + if (!language) { + return isXsdDouble(trimmed) ? trimmed : null; + } + const value = readInLanguage(text, language); + if (isXsdDouble(value)) { + return value; + } + const { group } = numberConventions(language); + const grouped = typeof group === 'string' ? trimmed.includes(group) : group.test(trimmed); + return !grouped && isXsdDouble(trimmed) ? trimmed : null; +} + +/** + * Formats a stored xsd:double for display in `language`. + * @param {string} value + * @param {string} [language] - BCP 47 tag; falsy returns `value` unchanged + * @returns {string} The formatted value; `value` unchanged if it is not an xsd:double + */ +export function formatLocaleNumber(value, language) { + if (!language || !isXsdDouble(value)) { + return value; + } + return localizeNumerals(value.replace('.', numberConventions(language).decimal), language); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/numerals.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/numerals.js new file mode 100644 index 0000000000..41a4c76fff --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/numerals.js @@ -0,0 +1,93 @@ +/** + * Taken from kolibri-common/utils/numeralNormalization.js + * (learningequality/kolibri@d74be371c). + */ + +// Matches any Unicode decimal digit that is NOT ASCII 0-9. +// \p{Nd} = Unicode "Decimal_Digit_Number" category (all scripts). +// [0-9] is excluded so we only process non-Western digits. +const nonWesternDigitRegex = /(?![0-9])\p{Nd}/gu; + +// Single-character test for any decimal digit (used in the base-finding loop). +const singleNdRegex = /\p{Nd}/u; + +/** + * Replace any non-Western digit character with its ASCII equivalent. + * + * Unicode guarantees that decimal digits 0-9 are contiguous in every + * script. We find the block's "zero" by walking backwards (at most 9 + * steps), then subtract to get the digit value. + * @param {string} str - String that may contain non-Western digits + * @returns {string} The string with non-Western digits converted to ASCII + */ +function normalizeNumerals(str) { + if (typeof str !== 'string') { + return str; + } + return str.replace(nonWesternDigitRegex, char => { + const code = char.codePointAt(0); + // Walk backwards to find the first character in this digit block + // (i.e., the script's "zero"). At most 9 steps. + let base = code; + while (base > 0 && singleNdRegex.test(String.fromCodePoint(base - 1))) { + base--; + } + return String(code - base); + }); +} + +// Cache for getLocalizedDigits — keyed by locale string. +const _digitCache = {}; + +/** + * Get the localized digits 0-9 for a locale using Intl.NumberFormat. + * Returns null if the locale's digits are identical to ASCII 0-9 + * (meaning no character remapping is needed for display or input). + * Otherwise returns an array of 10 strings representing digits 0-9. + * Results are cached per locale. + * @param {string} locale - BCP 47 locale code + * @returns {string[]|null} Locale digits 0-9; null for ASCII + */ +function getLocalizedDigits(locale) { + if (!locale) { + return null; + } + if (locale in _digitCache) { + return _digitCache[locale]; + } + try { + const formatter = new Intl.NumberFormat(locale, { useGrouping: false }); + const digits = []; + for (let i = 0; i < 10; i++) { + digits.push(formatter.format(i)); + } + // If all digits match ASCII, no remapping is needed + const result = digits.every((d, i) => d === String(i)) ? null : digits; + _digitCache[locale] = result; + return result; + } catch (e) { + _digitCache[locale] = null; + return null; + } +} + +/** + * Convert ASCII digits in a string to localized digits for a given locale. + * The reverse of normalizeNumerals: "42" → "٤٢" for Arabic. + * Returns the string unchanged if the locale's digits match ASCII. + * @param {string} str - String whose ASCII digits to localize + * @param {string} locale - BCP 47 locale code + * @returns {string} Localized string + */ +function localizeNumerals(str, locale) { + if (typeof str !== 'string') { + return str; + } + const digits = getLocalizedDigits(locale); + if (!digits) { + return str; + } + return str.replace(/[0-9]/g, d => digits[Number(d)]); +} + +export { normalizeNumerals, localizeNumerals }; From 18e65c2d3d2afe98adb4ffb94307108a2792927d Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 2 Oct 2026 18:42:00 -0700 Subject: [PATCH 2/3] feat: read and write Numeric answers in the exercise language Display uses the language's separators and digits; the XML keeps canonical xsd:double. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../QTIEditor/__tests__/validateItem.spec.js | 67 ++++++++++ .../__tests__/useInteraction.spec.js | 8 +- .../QTIEditor/composables/useInteraction.js | 8 +- .../composables/useTextEntryInteraction.js | 64 +++++++++- .../interactions/InteractionDescriptor.js | 3 + .../interactions/textEntry/Descriptor.js | 14 ++- .../interactions/textEntry/Editor.vue | 19 ++- .../textEntry/__tests__/Editor.spec.js | 117 ++++++++++++++++++ .../textEntry/__tests__/parse.spec.js | 52 ++++++++ .../textEntry/__tests__/validation.spec.js | 25 ++++ .../QTIEditor/interactions/textEntry/parse.js | 35 ++++-- .../interactions/textEntry/validation.js | 10 +- .../views/QTIEditor/qtiEditorStrings.js | 5 +- 13 files changed, 397 insertions(+), 30 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js index a3c397f44b..0de5da2202 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/__tests__/validateItem.spec.js @@ -181,6 +181,73 @@ describe('validateQtiItem', () => { }); }); +describe('validateQtiItem on numeric answers typed in a language', () => { + it.each([ + ['fr', '1,5'], + ['fr', '1,234'], + ['fr', '1 234,5'], + ['fr', '1\u202F234,5'], + ['en', '1,234'], + ['en', '1,234.5'], + ['hi', '12,34,567'], + ['hi', '३०'], + ['ar', '٣٠'], + ['fr', '1.5'], + ['ar-EG', '3.5'], + ])('reports no error for an answer typed in %s as %s, as the editor does', (language, value) => { + const state = { + prompt: '

How much?

', + answers: [{ id: 'a1', value }], + expectedLength: 50, + }; + const { bodyXml, responseDeclarations } = textEntryInteractionDescriptor.buildXML( + state, + QuestionType.NUMERIC, + { language }, + ); + const rawData = assembleItemXml({ + identifier: 'item', + title: 'Question', + language, + bodyXml, + responseDeclarations, + }); + expect(validateQtiItem(rawData)).toEqual([]); + expect( + textEntryInteractionDescriptor.validate(state, QuestionType.NUMERIC, { language }), + ).toEqual([]); + }); + + it.each([ + ['en', '1,23'], + ['fr', '1,2,3'], + ['fr', '1 234.5'], + ])('reports an answer typed in %s as %s invalid, as the editor does', (language, value) => { + const state = { + prompt: '

How much?

', + answers: [{ id: 'a1', value }], + expectedLength: 50, + }; + const { bodyXml, responseDeclarations } = textEntryInteractionDescriptor.buildXML( + state, + QuestionType.NUMERIC, + { language }, + ); + const rawData = assembleItemXml({ + identifier: 'item', + title: 'Question', + language, + bodyXml, + responseDeclarations, + }); + const invalid = ValidationError.INVALID_NUMERIC_VALUE; + expect( + codesOf(textEntryInteractionDescriptor.validate(state, QuestionType.NUMERIC, { language })), + ).toContain(invalid); + expect(codesOf(validateQtiItem(rawData))).toContain(invalid); + }); +}); + // What the editor asks about an item it is already showing, which is everything an // interaction cannot answer for itself. describe('validateItemShape', () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js index 4b768951fe..b656a75a69 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/__tests__/useInteraction.spec.js @@ -20,7 +20,7 @@ describe('useInteraction', () => { useInteraction(descriptor, block, questionType); expect(descriptor.parse).toHaveBeenCalledTimes(1); - expect(descriptor.parse).toHaveBeenCalledWith(block.bodyXml, block.responseDeclarations); + expect(descriptor.parse).toHaveBeenCalledWith(block.bodyXml, block.responseDeclarations, {}); }); it('exposes initial parsed state as a reactive ref', () => { @@ -95,7 +95,7 @@ describe('useInteraction', () => { questionType.value = 'multiSelect'; runValidation(); - expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'multiSelect'); + expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'multiSelect', {}); }); it('bodyXml recomputes when state changes', () => { @@ -135,7 +135,7 @@ describe('useInteraction', () => { bodyXml.value; // trigger recompute expect(descriptor.buildXML).toHaveBeenCalledTimes(2); - expect(descriptor.buildXML).toHaveBeenLastCalledWith(expect.anything(), 'multiSelect'); + expect(descriptor.buildXML).toHaveBeenLastCalledWith(expect.anything(), 'multiSelect', {}); }); it('automatically runs validation when state changes', async () => { @@ -156,7 +156,7 @@ describe('useInteraction', () => { state.value = { prompt: 'updated' }; await nextTick(); - expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'singleSelect'); + expect(descriptor.validate).toHaveBeenCalledWith({ prompt: 'updated' }, 'singleSelect', {}); expect(errors.value).toEqual([{ code: 'UPDATED_ERROR' }]); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js index 316ffe0b9f..234ed02a28 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useInteraction.js @@ -14,6 +14,7 @@ import { ref, computed, watch } from 'vue'; * @param {import('../interactions/InteractionDescriptor').InteractionDescriptor} descriptor * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock * @param {import('vue').Ref} questionType + * @param {object} [options] - Passed to the descriptor's parse, buildXML and validate * @returns {{ * state: import('vue').Ref, * bodyXml: import('vue').ComputedRef, @@ -22,10 +23,11 @@ import { ref, computed, watch } from 'vue'; * runValidation: () => void, * }} */ -export function useInteraction(descriptor, interactionBlock, questionType) { +export function useInteraction(descriptor, interactionBlock, questionType, options = {}) { const initialState = descriptor.parse( interactionBlock.bodyXml, interactionBlock.responseDeclarations, + options, ); const state = ref(initialState); @@ -33,7 +35,7 @@ export function useInteraction(descriptor, interactionBlock, questionType) { // Rebuild XML whenever state or questionType changes. const interaction = computed(() => { if (!questionType.value) return { bodyXml: '', responseDeclarations: [] }; - return descriptor.buildXML(state.value, questionType.value); + return descriptor.buildXML(state.value, questionType.value, options); }); const bodyXml = computed(() => interaction.value.bodyXml); @@ -43,7 +45,7 @@ export function useInteraction(descriptor, interactionBlock, questionType) { /** Validates and updates errors. Exposed for explicit triggers (e.g. close). */ function runValidation() { - errors.value = descriptor.validate(state.value, questionType.value); + errors.value = descriptor.validate(state.value, questionType.value, options); } watch([state, questionType], runValidation, { deep: true, immediate: true }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js index 43d9546312..2406911ec4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/composables/useTextEntryInteraction.js @@ -1,4 +1,6 @@ -import { readonly } from 'vue'; +import { computed, readonly, watch } from 'vue'; +import { QuestionType, ValidationError } from '../constants'; +import { parseXsdDouble } from '../utils/math'; import { generateRandomSlug } from '../utils/generateRandomSlug'; import { textEntryInteractionDescriptor } from '../interactions/textEntry/Descriptor'; import { useInteraction } from './useInteraction'; @@ -12,11 +14,65 @@ import { useInteraction } from './useInteraction'; * * @param {{ bodyXml: string, responseDeclarations: string[] }} interactionBlock * @param {import('vue').Ref} questionType + * @param {{ language?: string }} [options] - The language numeric answers are written in */ -export function useTextEntryInteraction(interactionBlock, questionType) { - const base = useInteraction(textEntryInteractionDescriptor, interactionBlock, questionType); +export function useTextEntryInteraction(interactionBlock, questionType, options = {}) { + const base = useInteraction(textEntryInteractionDescriptor, interactionBlock, questionType, { + ...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), + }); const { state } = base; + // 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( + interactionBlock.bodyXml, + interactionBlock.responseDeclarations, + ).answers; + const storedById = new Map( + state.value.answers.map((a, i) => [a.id, { shown: a.value, stored: storedAnswers[i].value }]), + ); + function swapStoredAnswers(from, to) { + state.value = { + ...state.value, + answers: state.value.answers.map(a => + storedById.get(a.id)?.[from] === a.value ? { ...a, value: storedById.get(a.id)[to] } : a, + ), + }; + } + watch( + questionType, + (newType, oldType) => { + if (oldType === QuestionType.NUMERIC && newType !== QuestionType.NUMERIC) { + swapStoredAnswers('shown', 'stored'); + } else if (oldType !== QuestionType.NUMERIC && newType === QuestionType.NUMERIC) { + swapStoredAnswers('stored', 'shown'); + } + }, + { flush: 'sync' }, + ); + + // Publishing validates the stored XML, so a stored answer that isn't xsd:double stays + // invalid until the author edits it, even when the exercise language reads it. + const uneditedIds = new Set(state.value.answers.map(a => a.id)); + const errors = computed(() => { + if (questionType.value !== QuestionType.NUMERIC) return base.errors.value; + const storedErrors = state.value.answers + .filter( + a => + uneditedIds.has(a.id) && + parseXsdDouble(storedById.get(a.id).stored.trim()) === null && + !base.errors.value.some( + e => e.code === ValidationError.INVALID_NUMERIC_VALUE && e.id === a.id, + ), + ) + .map(a => ({ code: ValidationError.INVALID_NUMERIC_VALUE, id: a.id })); + return [...base.errors.value, ...storedErrors]; + }); + function setPrompt(html) { state.value = { ...state.value, prompt: html }; } @@ -55,6 +111,7 @@ export function useTextEntryInteraction(interactionBlock, questionType) { * @param {string} value */ function updateAnswerValue(id, value) { + uneditedIds.delete(id); state.value = { ...state.value, answers: state.value.answers.map(a => (a.id === id ? { ...a, value } : a)), @@ -78,6 +135,7 @@ export function useTextEntryInteraction(interactionBlock, questionType) { return { ...base, state: readonly(state), + errors, setPrompt, addAnswer, removeAnswer, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js index 271e0b5ead..2c02d515ca 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/InteractionDescriptor.js @@ -15,6 +15,9 @@ import { Placement } from '../constants'; /** * Methods a subclass has to implement. `matches` and `getTypeOptions` are not listed * because this class provides usable defaults for them. + * + * `parse`, `buildXML` and `validate` take a trailing `{ language }`: the exercise language + * answers are written in, for descriptors whose answers depend on it. */ const REQUIRED_METHODS = [ 'getQuestionType', diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js index 934189eea7..4971cb3a8d 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Descriptor.js @@ -96,32 +96,36 @@ class TextEntryInteractionDescriptor extends InteractionDescriptor { /** * @param {string} bodyXml - Full `` XML string * @param {string[]} responseDeclarations + * @param {{ language?: string }} [options] * @returns {TextEntryState} */ - parse(bodyXml, responseDeclarations) { - return parseTextEntryInteraction(bodyXml, responseDeclarations); + parse(bodyXml, responseDeclarations, options) { + return parseTextEntryInteraction(bodyXml, responseDeclarations, options); } /** * @param {TextEntryState} state * @param {string} questionType + * @param {{ language?: string }} [options] * @returns {{ bodyXml: string, responseDeclarations: string[] }} */ - buildXML(state, questionType) { + buildXML(state, questionType, options) { return buildTextEntryInteractionXML( state, questionType, this.getResponseDeclarationSchema(questionType, state), + options, ); } /** * @param {TextEntryState} state * @param {string} questionType + * @param {{ language?: string }} [options] * @returns {Array<{ code: string, id?: string }>} */ - validate(state, questionType) { - return validateTextEntryInteraction(state, questionType); + validate(state, questionType, options) { + return validateTextEntryInteraction(state, questionType, options); } } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue index b75b4d8da4..b51ffd770f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/Editor.vue @@ -150,7 +150,7 @@ v-if="isNumeric && answerHasError(answer.id, ValidationError.INVALID_NUMERIC_VALUE)" class="answer-validation-message" > - {{ errorInvalidNumericValue$() }} + {{ errorInvalidNumericValue$(numberExamples) }} props.questionType === QuestionType.NUMERIC); + const numberExamples = { + integer: formatLocaleNumber('12', props.language), + decimal: formatLocaleNumber('0.5', props.language), + negative: formatLocaleNumber('-3.14', props.language), + }; + const showAnswerSection = computed( () => props.questionType === QuestionType.NUMERIC || @@ -360,6 +369,7 @@ state, windowIsSmall, isNumeric, + numberExamples, showAnswerSection, isPromptOpen, questionHasError, @@ -416,6 +426,11 @@ type: Boolean, default: false, }, + /** The exercise's language, which numeric answers are read and shown in */ + language: { + type: String, + default: '', + }, }, emits: ['update:interaction', 'update:errors'], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js index b59a05df54..f9fc2787df 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/Editor.spec.js @@ -161,6 +161,29 @@ describe('TextEntryEditor — numeric', () => { expect(screen.getByRole('alert')).toBeInTheDocument(); }); + it('gives example numbers in the exercise language', async () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + language: 'fr', + }); + await fireEvent.input(answerInputs()[0], { target: { value: 'abc' } }); + + expect(screen.getByRole('alert')).toHaveTextContent('0,5'); + }); + + it('rejects a stored answer that is not xsd:double until it is edited', async () => { + renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL.replace('42', '1,5')), + questionType: QuestionType.NUMERIC, + language: 'fr', + }); + expect(screen.getByRole('alert')).toBeInTheDocument(); + + await fireEvent.input(answerInputs()[0], { target: { value: '1,5' } }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + it('shows validation errors as soon as the state changes', async () => { renderEditor({ interaction: block(TEXT_ENTRY_BODY_XML), @@ -240,6 +263,100 @@ describe('TextEntryEditor — emits', () => { expect(Array.isArray(payload.responseDeclarations)).toBe(true); }); + it('stores a numeric answer typed in the exercise language as xsd:double', async () => { + const { emitted } = renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + language: 'fr', + }); + const input = screen.getByRole('textbox', { name: tr.$tr('answerValuePlaceholder') }); + await fireEvent.input(input, { target: { value: '1,5' } }); + + const [latest] = emitted()['update:interaction'].at(-1); + expect(latest.responseDeclarations[0]).toContain('1.5'); + }); + + it.each([ + ['de', '1,2'], + ['en', '1,234'], + ['fr', '1.50'], + ])('emits a stored numeric answer unchanged when opened in %s (%s)', (language, stored) => { + const { emitted } = renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL.replace('42', stored)), + questionType: QuestionType.NUMERIC, + language, + }); + + const [latest] = emitted()['update:interaction'].at(-1); + expect(latest.responseDeclarations[0]).toContain(`${stored}`); + }); + + it.each([ + ['fr', '1234.5', '1234.5'], + ['ar-EG', '30', '30'], + ['fr', '1.2.3', '1.2.3'], + ])( + 'keeps stored numeric answers as stored when switched to text entry in %s (%s)', + async (language, stored, expected) => { + const { emitted, updateProps } = renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL.replace('42', stored)), + questionType: QuestionType.NUMERIC, + language, + }); + await updateProps({ questionType: QuestionType.TEXT_ENTRY }); + + const [latest] = emitted()['update:interaction'].at(-1); + expect(latest.responseDeclarations[0]).toContain(`${expected}`); + }, + ); + + it.each([ + ['de', '1.5', '1,5'], + ['es', '1234.5', '1234,5'], + ['fr', '1234.5', '1234,5'], + ['ar-EG', '30', '٣٠'], + ])( + 'shows a stored answer in %s again after switching to text entry and back (%s)', + async (language, stored, shown) => { + const { emitted, updateProps } = renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL.replace('42', stored)), + questionType: QuestionType.NUMERIC, + language, + }); + await updateProps({ questionType: QuestionType.TEXT_ENTRY }); + await updateProps({ questionType: QuestionType.NUMERIC }); + + expect(screen.getByRole('textbox', { name: tr.$tr('answerValuePlaceholder') })).toHaveValue( + shown, + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + const [latest] = emitted()['update:interaction'].at(-1); + expect(latest.responseDeclarations[0]).toContain(`${stored}`); + }, + ); + + it.each([ + ['fr', '1,5'], + ['en', '1,234'], + ['de', '1.500'], + ['', ' 5 '], + ])( + 'keeps a typed numeric answer as typed when switched to text entry in "%s" (%s)', + async (language, typed) => { + const { emitted, updateProps } = renderEditor({ + interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), + questionType: QuestionType.NUMERIC, + language, + }); + const input = screen.getByRole('textbox', { name: tr.$tr('answerValuePlaceholder') }); + await fireEvent.input(input, { target: { value: typed } }); + await updateProps({ questionType: QuestionType.TEXT_ENTRY }); + + const [latest] = emitted()['update:interaction'].at(-1); + expect(latest.responseDeclarations[0]).toContain(`${typed}`); + }, + ); + it('emits update:interaction after adding an answer row', async () => { const { emitted } = renderEditor({ interaction: blockWithDecl(TEXT_ENTRY_BODY_XML, NUMERIC_DECL), diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js index d2399eadd7..345c3a45c7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/parse.spec.js @@ -344,6 +344,26 @@ describe('parseTextEntryInteraction', () => { expect(state.answers).toHaveLength(2); expect(state.answers.map(a => a.value)).toEqual(['0.5', '1.5']); }); + + it.each([ + ['1234.5', 'fr', '1234,5'], + ['1234.5', 'en', '1234.5'], + ['1234.5', 'hi', '1234.5'], + ['30', 'ar-EG', '٣٠'], + ])('displays stored %s in %s as %s', (stored, language, displayed) => { + const declaration = SINGLE_NUMERIC_DECLARATION.replace('12', stored); + const state = parseTextEntryInteraction(makeBodyXml(), [declaration], { language }); + expect(state.answers[0].value).toBe(displayed); + }); + + it('leaves text-entry answers as stored in any language', () => { + const state = parseTextEntryInteraction( + makeBodyXml(), + [TEXT_ENTRY_DECLARATION_WITHOUT_MAPPING.replace('Paris', '1.5')], + { language: 'fr' }, + ); + expect(state.answers[0].value).toBe('1.5'); + }); }); }); @@ -490,6 +510,38 @@ describe('buildTextEntryInteractionXML', () => { expect(responseDeclarations[0]).toContain('>1.5<'); }); + describe('in a language', () => { + const storedValues = (questionType, schema, value, language) => { + const { responseDeclarations } = buildTextEntryInteractionXML( + { prompt: '', answers: [{ id: 'a1', value }], expectedLength: 0 }, + questionType, + schema, + { language }, + ); + return [...responseDeclarations[0].matchAll(/(.*?)<\/qti-value>/g)].map( + ([, v]) => v, + ); + }; + + it('stores a numeric answer as the number it reads as', () => { + expect(storedValues(QuestionType.NUMERIC, NUMERIC_SINGLE_SCHEMA, '1 234,5', 'fr')).toEqual([ + '1234.5', + ]); + }); + + it('stores a numeric answer it cannot read as typed', () => { + expect(storedValues(QuestionType.NUMERIC, NUMERIC_SINGLE_SCHEMA, '1,23', 'en')).toEqual([ + '1,23', + ]); + }); + + it('stores a text-entry answer as typed', () => { + expect(storedValues(QuestionType.TEXT_ENTRY, TEXT_ENTRY_MULTI_SCHEMA, '1,5', 'fr')).toEqual( + ['1,5'], + ); + }); + }); + it('numeric with 0 answers omits (empty element is invalid per XSD)', () => { const { responseDeclarations } = buildTextEntryInteractionXML( { prompt: '', answers: [], expectedLength: 0 }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js index 96fd5874a3..75ae36a06c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/__tests__/validation.spec.js @@ -154,6 +154,31 @@ describe('validateTextEntryInteraction', () => { expect(invalids.map(e => e.id)).toEqual(['a1', 'a2']); }); + describe('in a language', () => { + const numericErrors = (values, language) => + validateTextEntryInteraction( + { + ...VALID_NUMERIC_STATE, + answers: values.map((value, i) => ({ id: `a${i}`, value })), + }, + QuestionType.NUMERIC, + { language }, + ); + + it('rejects a decimal comma with no language', () => { + expect(numericErrors(['1,5'])).toEqual([ + { code: ValidationError.INVALID_NUMERIC_VALUE, id: 'a0' }, + ]); + }); + + it('compares answers by the number they read as', () => { + expect(numericErrors(['1 234,5', '1234,50'], 'fr')).toEqual([ + { code: ValidationError.DUPLICATE_ANSWER_CONTENT, id: 'a0' }, + { code: ValidationError.DUPLICATE_ANSWER_CONTENT, id: 'a1' }, + ]); + }); + }); + it('does not flag INVALID_NUMERIC_VALUE for freeResponse', () => { const errors = validateTextEntryInteraction( { ...VALID_FREE_STATE, answers: [{ id: 'a1', value: 'abc' }] }, diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js index 25bcf89cde..40ff881ad6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/parse.js @@ -4,6 +4,7 @@ import CorrectResponse from '../../serialization/qti/declarations/correctRespons import Mapping from '../../serialization/qti/declarations/mapping'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; import { parseXsdDouble } from '../../utils/math'; +import { formatLocaleNumber, readLocaleNumber } from '../../utils/localeNumbers'; import { BaseType, QuestionType, RESPONSE_IDENTIFIER } from '../../constants'; const serializer = new XMLSerializer(); @@ -76,9 +77,10 @@ function extractPromptHTML(bodyEl) { * every answer) or truncate it (`1.2.3` → 1.2), hiding it from validation. * * @param {Element} declarationEl - A float `` + * @param {string} [language] - Valid answers are shown in this language * @returns {TextEntryAnswer[]} */ -function extractNumericAnswers(declarationEl) { +function extractNumericAnswers(declarationEl, language) { // Built only to validate: throws on a bad identifier or cardinality, as fromXML does. new QTIDeclaration({ identifier: declarationEl.getAttribute('identifier'), @@ -107,7 +109,7 @@ function extractNumericAnswers(declarationEl) { } return values.map(value => ({ id: generateRandomSlug('answer'), - value, + value: formatLocaleNumber(value, language), caseSensitive: false, })); } @@ -123,16 +125,17 @@ function extractNumericAnswers(declarationEl) { * , matched by `map-key`; it is always false for float. * * @param {string[]} responseDeclarations + * @param {{ language?: string }} [options] * @returns {{ id: string, value: string, caseSensitive: boolean }[]} */ -export function _extractAnswers(responseDeclarations) { +export function _extractAnswers(responseDeclarations, { language } = {}) { const [declXml] = responseDeclarations || []; if (!declXml) return []; try { const declarationEl = parseXML(declXml).documentElement; if (declarationEl.getAttribute('base-type') === BaseType.FLOAT) { - return extractNumericAnswers(declarationEl); + return extractNumericAnswers(declarationEl, language); } const declaration = QTIDeclaration.fromXML(declarationEl); @@ -184,9 +187,11 @@ export function _extractAnswers(responseDeclarations) { * * @param {string} bodyXml - Serialized `` element * @param {string[]} responseDeclarations + * @param {{ language?: string }} [options] - Valid numeric answers are formatted for display + * in `language`; with none they stay as stored * @returns {TextEntryState} */ -export function parseTextEntryInteraction(bodyXml, responseDeclarations) { +export function parseTextEntryInteraction(bodyXml, responseDeclarations, options = {}) { if (!bodyXml) return _defaultState(); let bodyEl; @@ -207,7 +212,7 @@ export function parseTextEntryInteraction(bodyXml, responseDeclarations) { 10, ); const prompt = extractPromptHTML(bodyEl); - const answers = _extractAnswers(responseDeclarations); + const answers = _extractAnswers(responseDeclarations, options); return { prompt, answers, expectedLength }; } @@ -218,9 +223,17 @@ export function parseTextEntryInteraction(bodyXml, responseDeclarations) { * @param {TextEntryState} state * @param {string} questionType - One of QuestionType.NUMERIC, TEXT_ENTRY, FREE_RESPONSE * @param {{ baseType: string, cardinality: string }} declarationSchema + * @param {{ language?: string, storedValue?: (id: string) => string|undefined }} [options] - + * Numeric answers are read in `language`; with none they are taken as xsd:double. One + * `storedValue` returns a value for is written as that value instead. * @returns {{ bodyXml: string, responseDeclarations: string[] }} */ -export function buildTextEntryInteractionXML(state, questionType, declarationSchema) { +export function buildTextEntryInteractionXML( + state, + questionType, + declarationSchema, + { language, storedValue } = {}, +) { const { prompt, answers, expectedLength } = state; const { baseType, cardinality } = declarationSchema; @@ -265,7 +278,13 @@ export function buildTextEntryInteractionXML(state, questionType, declarationSch // order, and the schema requires to precede . if (questionType !== QuestionType.FREE_RESPONSE && answers.length !== 0) { new CorrectResponse( - answers.map(a => a.value), + answers.map(a => + // 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) + : a.value, + ), declaration, ); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js index ef0b583439..a6ca745a4c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/textEntry/validation.js @@ -1,19 +1,22 @@ import { QuestionType, ValidationError } from '../../constants'; import { parseXsdDouble } from '../../utils/math'; +import { readLocaleNumber } from '../../utils/localeNumbers'; import { hasRichTextContent } from '../../utils/richText'; /** * Validate TextEntryState → ValidationError[]. * - * - numeric: prompt required + at least one answer + each value a finite xsd:double + * - numeric: prompt required + at least one answer + each value a finite number in `language` * - textEntry: prompt required + at least one answer (any non-blank string) * - freeResponse: prompt required only * * @param {TextEntryState} state * @param {string} questionType + * @param {{ language?: string }} [options] - Numeric answers are read in `language`; with + * none they must be xsd:double * @returns {Array<{ code: string, id?: string }>} */ -export function validateTextEntryInteraction(state, questionType) { +export function validateTextEntryInteraction(state, questionType, { language } = {}) { const errors = []; const { prompt, answers } = state; @@ -35,7 +38,8 @@ export function validateTextEntryInteraction(state, questionType) { let lookupKey; if (questionType === QuestionType.NUMERIC) { - const number = parseXsdDouble(val); + const canonical = readLocaleNumber(val, language); + const number = canonical === null ? null : parseXsdDouble(canonical); if (number === null) { errors.push({ code: ValidationError.INVALID_NUMERIC_VALUE, id: answer.id }); } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js index 45d78c4b00..9f43de4862 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/qtiEditorStrings.js @@ -447,8 +447,9 @@ export const qtiEditorStrings = createTranslator('QTIEditorStrings', { context: 'Placeholder inside a text-entry answer input field', }, 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})', + 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', }, errorParsingQuestion: { message: 'This question could not be loaded', From 9848e69c191d0ef992b349fe3aed9d02c44c589d Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Fri, 2 Oct 2026 18:42:10 -0700 Subject: [PATCH 3/3] feat: pass the exercise language to the QTI editor Falls back to the channel language, then the UI language. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../AssessmentTab/AssessmentTab.vue | 12 +++++- .../channelEdit/components/ResourcePanel.vue | 7 ++++ .../__tests__/useAssessmentItems.spec.js | 41 +++++++++++++++++- .../composables/useAssessmentItems.js | 26 ++++++++++-- .../components/InteractionSection/index.vue | 11 ++++- .../__tests__/QTIItemEditor.spec.js | 42 +++++++++++++++++++ .../components/QTIItemEditor/index.vue | 9 ++++ .../frontend/shared/views/QTIEditor/index.vue | 9 ++++ .../views/QTIEditor/utils/testingFixtures.js | 19 +++++++++ 9 files changed, 167 insertions(+), 9 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue index e2511272c0..752fd664c9 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentTab/AssessmentTab.vue @@ -19,6 +19,7 @@ @@ -42,14 +43,21 @@ setup(props) { const { nodeId } = toRefs(props); const { windowIsSmall } = useKResponsiveWindow(); - const { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate } = + const { assessmentItems, invalidItemsCount, allowFreeResponse, language, applyUpdate } = useAssessmentItems(nodeId); const bannerStyle = computed(() => windowIsSmall.value ? {} : { maxWidth: '1200px', margin: '0 auto' }, ); - return { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate, bannerStyle }; + return { + assessmentItems, + invalidItemsCount, + allowFreeResponse, + language, + applyUpdate, + bannerStyle, + }; }, props: { nodeId: { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue index 0750653f47..e00cfb7744 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/ResourcePanel.vue @@ -146,6 +146,7 @@ :total="assessmentItems.length" mode="view" :allowFreeResponse="allowFreeResponse" + :language="language" :showAnswers="showAnswers" class="question-preview" /> @@ -499,6 +500,7 @@ AccessibilityCategories, ContentModalities, } from '../../shared/constants'; + import { exerciseLanguage } from '../composables/useAssessmentItems'; import ContentNodeValidator from './ContentNodeValidator'; import { @@ -570,6 +572,7 @@ ]), ...mapGetters('file', ['getContentNodeFiles', 'contentNodesTotalSize']), ...mapGetters('assessmentItem', ['getAssessmentItems', 'getInvalidAssessmentItemsCount']), + ...mapGetters('channel', ['getChannel']), node() { return this.getContentNode(this.nodeId); }, @@ -620,6 +623,10 @@ allowFreeResponse() { return this.node?.extra_fields?.options?.modality === ContentModalities.SURVEY; }, + language() { + // The Clipboard previews nodes from other channels. + return exerciseLanguage(this.node, this.getChannel(this.node?.channel_id)); + }, fileSize() { return this.contentNodesTotalSize([this.nodeId]); }, diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js b/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js index 66dced531c..22ab298d4e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/__tests__/useAssessmentItems.spec.js @@ -3,6 +3,7 @@ import VueRouter from 'vue-router'; import { render } from '@testing-library/vue'; import useAssessmentItems from '../useAssessmentItems'; import { AssessmentItemTypes, ContentModalities } from 'shared/constants'; +import { currentLanguage } from 'shared/i18n'; const NODE_ID = 'node-1'; @@ -18,7 +19,7 @@ const item = (assessment_id, order, raw_data = `${assessment_id}`) => * Renders a component that does nothing but run the composable, and returns it alongside * the actions the composable dispatched, in the order it dispatched them. */ -function setup(storedItems, { modality = null } = {}) { +function setup(storedItems, { modality = null, nodeLanguage = null, channelLanguage = null } = {}) { const dispatched = []; const record = name => (context, payload) => dispatched.push([name, payload]); @@ -27,7 +28,16 @@ function setup(storedItems, { modality = null } = {}) { contentNode: { namespaced: true, getters: { - getContentNode: () => () => ({ extra_fields: { options: { modality } } }), + getContentNode: () => () => ({ + language: nodeLanguage, + extra_fields: { options: { modality } }, + }), + }, + }, + currentChannel: { + namespaced: true, + getters: { + currentChannel: () => ({ language: channelLanguage }), }, }, assessmentItem: { @@ -75,6 +85,33 @@ describe('useAssessmentItems', () => { }); }); + describe('language', () => { + it("is the exercise's own language when it has one", () => { + const { composable } = setup([], { nodeLanguage: 'fr', channelLanguage: 'hi' }); + + expect(composable.language.value).toBe('fr'); + }); + + it("falls back to the channel's language", () => { + const { composable } = setup([], { channelLanguage: 'hi' }); + + expect(composable.language.value).toBe('hi'); + }); + + it("falls back to the author's interface language when neither has one", () => { + const { composable } = setup([]); + + expect(composable.language.value).toBe(currentLanguage); + }); + + it('is empty for a language with no number conventions in this browser', () => { + // `qaa` is reserved for private use, so no browser has conventions for it. + const { composable } = setup([], { nodeLanguage: 'qaa', channelLanguage: 'hi' }); + + expect(composable.language.value).toBe(''); + }); + }); + it('dispatches nothing when the list is unchanged', async () => { const items = [item('a', 0), item('b', 1)]; const { composable, dispatched } = setup(items); diff --git a/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js index 08c418439c..dfb9d2b21e 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js +++ b/contentcuration/contentcuration/frontend/channelEdit/composables/useAssessmentItems.js @@ -1,6 +1,7 @@ import { computed, unref } from 'vue'; import useStore from 'shared/composables/useStore'; import { ContentModalities } from 'shared/constants'; +import { currentLanguage } from 'shared/i18n'; /** * Work out what changed between the list Studio holds and the list the editor produced. @@ -43,6 +44,19 @@ function diffAssessmentItems(prevItems, nextItems) { return { orders, added, updated, deleted }; } +/** + * The exercise's language, else the channel's, else the author's UI language. Empty when + * this browser has no number conventions for it, as `Intl` would use the browser's own. + * + * @param {object} [node] + * @param {object} [channel] + * @returns {string} + */ +export function exerciseLanguage(node, channel) { + const language = node?.language || channel?.language || currentLanguage; + return Intl.NumberFormat.supportedLocalesOf([language]).length ? language : ''; +} + /** * Everything the questions tab needs about one content node's assessment items: the * ordered list to render, how many of them are incomplete, and a way to save an edited @@ -57,13 +71,17 @@ export default function useAssessmentItems(nodeId) { store.getters['assessmentItem/getAssessmentItems'](unref(nodeId)), ); + const node = computed(() => store.getters['contentNode/getContentNode'](unref(nodeId))); + /** * Currently free responses are only allowed in surveys */ const allowFreeResponse = computed( - () => - store.getters['contentNode/getContentNode'](unref(nodeId))?.extra_fields?.options - ?.modality === ContentModalities.SURVEY, + () => node.value?.extra_fields?.options?.modality === ContentModalities.SURVEY, + ); + + const language = computed(() => + exerciseLanguage(node.value, store.getters['currentChannel/currentChannel']), ); const invalidItemsCount = computed(() => @@ -107,5 +125,5 @@ export default function useAssessmentItems(nodeId) { } } - return { assessmentItems, invalidItemsCount, allowFreeResponse, applyUpdate }; + return { assessmentItems, invalidItemsCount, allowFreeResponse, language, applyUpdate }; } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue index 0762cf6780..3dca6a450f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/InteractionSection/index.vue @@ -18,12 +18,13 @@ @@ -125,6 +126,14 @@ type: Boolean, default: true, }, + /** + * The exercise's language, which numeric answers are read and shown in. Empty means + * they are read and shown as stored. + */ + language: { + type: String, + default: '', + }, }, emits: ['update:questionType', 'update:interaction', 'update:errors'], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js index 419f873f3f..f8c3d860d2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/__tests__/QTIItemEditor.spec.js @@ -9,6 +9,7 @@ import { CHOICE_ITEM_DOCUMENT_NO_CORRECT_ANSWER, ORDERING_ITEM_DOCUMENT_NO_PROMPT, FREE_RESPONSE_ITEM_DOCUMENT, + NUMERIC_ITEM_DOCUMENT, NO_INTERACTION_ITEM_DOCUMENT, CHOICE_ITEM_DOCUMENT_WITH_HINTS, VALID_ASSOCIATE_ITEM_DOCUMENT, @@ -219,6 +220,29 @@ describe('QTIItemEditor', () => { }); }); + test('shows a numeric answer in the language it is given', async () => { + renderComponent({ + item: { ...defaultProps.item, raw_data: NUMERIC_ITEM_DOCUMENT }, + showAnswers: true, + language: 'fr', + }); + expect(await screen.findByDisplayValue('1234,5')).toBeInTheDocument(); + }); + + test('reads a numeric answer in the language it is given after a change', async () => { + const { emitted, updateProps } = renderComponent({ + item: { ...defaultProps.item, raw_data: NUMERIC_ITEM_DOCUMENT }, + mode: 'edit', + language: 'fr', + }); + await screen.findByDisplayValue('1234,5'); + await updateProps({ language: 'en' }); + const input = await screen.findByDisplayValue('1234.5'); + await fireEvent.input(input, { target: { value: '1,234' } }); + + expect(emitted()['update:rawData'].at(-1)[0]).toContain('1234'); + }); + describe('items this editor cannot edit', () => { test('shows a read-only message for an item authored elsewhere', () => { renderComponent({ @@ -315,6 +339,24 @@ describe('QTIItemEditor', () => { expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); }); + // Publishing reads the stored XML, where these are not xsd:double. + test.each([ + ['fr', '1,5'], + ['fr', '1 234'], + ['de', '1.234,5'], + ['en', '1,234'], + ])('is shown in %s for a stored numeric answer %s', async (language, stored) => { + renderComponent({ + item: { + ...defaultProps.item, + raw_data: NUMERIC_ITEM_DOCUMENT.replace('1234.5', stored), + }, + language, + }); + await nextTick(); + expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); + }); + test('is shown for an unsupported item with no interaction, which blocks publishing', async () => { await renderAndValidate(NO_INTERACTION_ITEM_DOCUMENT); expect(screen.getByText(incompleteItemIndicatorLabel$())).toBeInTheDocument(); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue index edb1017d5a..7a6ef80f10 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/QTIItemEditor/index.vue @@ -67,6 +67,7 @@ :mode="mode" :showAnswers="showAnswers" :allowFreeResponse="allowFreeResponse" + :language="language" @update:questionType="type => (currentQuestionType = type)" @update:interaction="onUpdateInteraction" @update:errors="onUpdateErrors" @@ -398,6 +399,14 @@ type: Boolean, default: true, }, + /** + * The exercise's language, which numeric answers are read and shown in. Empty means + * they are read and shown as stored. + */ + language: { + type: String, + default: '', + }, }, emits: ['open', 'close', 'update:rawData'], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue index 8ab224eb09..3751ed03bb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/index.vue @@ -27,6 +27,7 @@ :total="items.length" :mode="activeId === item.assessment_id ? 'edit' : 'view'" :allowFreeResponse="allowFreeResponse" + :language="language" :showAnswers="showAnswers" data-testid="item" @open="openItem(item.assessment_id)" @@ -224,6 +225,14 @@ type: Boolean, default: true, }, + /** + * The exercise's language, which numeric answers are read and shown in. Empty means + * they are read and shown as stored. + */ + language: { + type: String, + default: '', + }, }, emits: ['update'], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js index ccf4bfc825..b9071e60b2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/testingFixtures.js @@ -241,6 +241,25 @@ export const FREE_RESPONSE_ITEM_DOCUMENT = ` `; +export const NUMERIC_ITEM_DOCUMENT = ` + + + 1234.5 + + + +

Combien ?

+

+
+
`; + export const NO_INTERACTION_ITEM_DOCUMENT = `