From 12b8f54b3dfe1be72ef8080e1c0101ef14cf48d8 Mon Sep 17 00:00:00 2001 From: ihor-romaniuk Date: Mon, 9 Dec 2024 16:13:11 +0100 Subject: [PATCH 1/5] fix: answer range format validation and error message in Numerical input --- .../AnswerWidget/AnswerOption.jsx | 16 ++++++++++++++-- .../EditProblemView/AnswerWidget/messages.js | 5 +++++ .../containers/ProblemEditor/data/OLXParser.js | 4 +++- .../ProblemEditor/data/ReactStateOLXParser.js | 8 +++++--- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx index e9ca9e6709..1231ffad9b 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx @@ -18,6 +18,7 @@ import { FeedbackBox } from './components/Feedback'; import * as hooks from './hooks'; import { ProblemTypeKeys } from '../../../../../data/constants/problem'; import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea'; +import { answerRangeFormatRegex } from '../../../data/OLXParser'; const AnswerOption = ({ answer, @@ -48,6 +49,11 @@ const AnswerOption = ({ ? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/` : undefined; + const validateAnswerTitle = (value) => { + const cleanedValue = value.replace(/^\s+|\s+$/g, ''); + return !cleanedValue.length || answerRangeFormatRegex.test(cleanedValue); + }; + const getInputArea = () => { if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) { return ( @@ -77,8 +83,9 @@ const AnswerOption = ({ ); } // Return Answer Range View + const isValidValue = validateAnswerTitle(answer.title); return ( -
+ + {!isValidValue && ( + + + + )}
-
+ ); }; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js index 5f58109137..d69bc876d7 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js @@ -77,6 +77,11 @@ const messages = defineMessages({ defaultMessage: 'Enter min and max values separated by a comma. Use a bracket to include the number next to it in the range, or a parenthesis to exclude the number. For example, to identify the correct answers as 5, 6, or 7, but not 8, specify [5,8).', description: 'Helper text describing usage of answer ranges', }, + answerRangeErrorText: { + id: 'authoring.answerwidget.answer.answerRangeErrorText', + defaultMessage: 'Error: Invalid range format. Use brackets or parentheses with values separated by a comma.', + description: 'Error text describing wrong format of answer ranges', + }, }); export default messages; diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js index 4a390e68cc..b59f57efde 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -57,6 +57,8 @@ export const responseKeys = [ 'choicetextresponse', ]; +export const answerRangeFormatRegex = /^[([]\s*\d+(\.\d+)?\s*,\s*\d+(\.\d+)?\s*[)\]]$/m; + export const stripNonTextTags = ({ input, tag }) => { const stripedTags = {}; Object.entries(input).forEach(([key, value]) => { @@ -432,7 +434,7 @@ export class OLXParser { [type]: defaultValue, }; } - const isAnswerRange = /[([]\s*\d*,\s*\d*\s*[)\]]/gm.test(numericalresponse['@_answer']); + const isAnswerRange = answerRangeFormatRegex.test(numericalresponse['@_answer']); answers.push({ id: indexToLetterMap[answers.length], title: numericalresponse['@_answer'], diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js index 5f2d8ca9f7..279294750a 100644 --- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js +++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js @@ -413,16 +413,18 @@ class ReactStateOLXParser { const lowerBoundFloat = Number(numerator) / Number(denominator); lowerBoundInt = lowerBoundFloat; } else { - // these regex replaces remove everything that is not a decimal or positive/negative numer + // these regex replaces remove everything that is not a decimal or positive/negative number lowerBoundInt = Number(rawLowerBound.replace(/[^0-9-.]/gm, '')); } - if (rawUpperBound.includes('/')) { + if (!rawUpperBound) { + upperBoundInt = lowerBoundInt; + } else if (rawUpperBound.includes('/')) { upperBoundFraction = rawUpperBound.replace(/[^0-9-/]/gm, ''); const [numerator, denominator] = upperBoundFraction.split('/'); const upperBoundFloat = Number(numerator) / Number(denominator); upperBoundInt = upperBoundFloat; } else { - // these regex replaces remove everything that is not a decimal or positive/negative numer + // these regex replaces remove everything that is not a decimal or positive/negative number upperBoundInt = Number(rawUpperBound.replace(/[^0-9-.]/gm, '')); } if (lowerBoundInt > upperBoundInt) { From f451eda9651eddfec979b96bfd9cf5388e9ba810 Mon Sep 17 00:00:00 2001 From: ihor-romaniuk Date: Thu, 26 Jun 2025 10:27:32 +0200 Subject: [PATCH 2/5] test: update test --- .../data/ReactStateOLXParser.test.js | 41 ++++++++++++++++ .../data/mockData/editorTestData.js | 48 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js index 416fafdcd8..7b852ca64b 100644 --- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js +++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js @@ -18,6 +18,10 @@ import { numericInputWithAnswerRange, textInputWithFeedbackAndHintsWithMultipleAnswers, numberParseTest, + numericInputWithFractionBounds, + numericInputWithEmptyUpperBound, + numericInputWithSwappedBounds, + numericInputWithMissingUpperBound, } from './mockData/editorTestData'; import ReactStateOLXParser from './ReactStateOLXParser'; @@ -147,4 +151,41 @@ describe('Check React State OLXParser problem', () => { ); }); }); + describe('ReactStateOLXParser numerical response range parsing', () => { + test('handles empty upper bound as same as lower', () => { + const parser = new ReactStateOLXParser({ + problem: numericInputWithEmptyUpperBound, + editorObject: numericInputWithEmptyUpperBound, + }); + const result = parser.buildNumericalResponse(); + expect(result[':@']['@_answer']).toBe('[0,1.5]'); + }); + + test('handles swapped bounds and corrects order', () => { + const parser = new ReactStateOLXParser({ + problem: numericInputWithSwappedBounds, + editorObject: numericInputWithSwappedBounds, + }); + const result = parser.buildNumericalResponse(); + expect(result[':@']['@_answer']).toBe('[2,5]'); + }); + + test('fixes swapped fraction bounds and preserves brackets', () => { + const parser = new ReactStateOLXParser({ + problem: numericInputWithFractionBounds, + editorObject: numericInputWithFractionBounds, + }); + const result = parser.buildNumericalResponse(); + expect(result[':@']['@_answer']).toBe('(1/2,3/2)'); + }); + + test('sets upper bound = lower bound if upper bound missing', () => { + const parser = new ReactStateOLXParser({ + problem: numericInputWithMissingUpperBound, + editorObject: numericInputWithMissingUpperBound, + }); + const result = parser.buildNumericalResponse(); + expect(result[':@']['@_answer']).toBe('[,2.5]'); + }); + }); }); diff --git a/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js b/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js index a96230c46e..a33d947e41 100644 --- a/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js +++ b/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js @@ -130,3 +130,51 @@ export const numberParseTest = { hints: [], question: '

What is the content of the register x2 after executing the following three lines of instructions?

', }; + +export const numericInputWithEmptyUpperBound = { + answers: [ + { + id: 'a1', + title: '[1.5,]', + correct: true, + }, + ], + problemType: 'numericalresponse', + settings: {}, +}; + +export const numericInputWithSwappedBounds = { + answers: [ + { + id: 'a1', + title: '[5,2]', + correct: true, + }, + ], + problemType: 'numericalresponse', + settings: {}, +}; + +export const numericInputWithFractionBounds = { + answers: [ + { + id: 'a1', + title: '(3/2,1/2)', + correct: true, + }, + ], + problemType: 'numericalresponse', + settings: {}, +}; + +export const numericInputWithMissingUpperBound = { + answers: [ + { + id: 'a1', + title: '[,2.5]', + correct: true, + }, + ], + problemType: 'numericalresponse', + settings: {}, +}; From 863a06f68cd1d479cdd344853be8d6cffb345e4e Mon Sep 17 00:00:00 2001 From: ihor-romaniuk Date: Fri, 5 Sep 2025 11:04:16 +0200 Subject: [PATCH 3/5] fix: after review --- .../AnswerWidget/AnswerOption.jsx | 4 ++-- .../ProblemEditor/data/OLXParser.js | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx index 1231ffad9b..897ff07b01 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx @@ -49,7 +49,7 @@ const AnswerOption = ({ ? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/` : undefined; - const validateAnswerTitle = (value) => { + const validateAnswerRange = (value) => { const cleanedValue = value.replace(/^\s+|\s+$/g, ''); return !cleanedValue.length || answerRangeFormatRegex.test(cleanedValue); }; @@ -83,7 +83,7 @@ const AnswerOption = ({ ); } // Return Answer Range View - const isValidValue = validateAnswerTitle(answer.title); + const isValidValue = validateAnswerRange(answer.title); return ( Max + * (1, ) // Missing max value + * [1 5] // Missing comma + * {1, 5} // Invalid brackets + */ export const answerRangeFormatRegex = /^[([]\s*\d+(\.\d+)?\s*,\s*\d+(\.\d+)?\s*[)\]]$/m; export const stripNonTextTags = ({ input, tag }) => { From 9792794b0b7b706adab8219f83b463d914bca5e1 Mon Sep 17 00:00:00 2001 From: ihor-romaniuk Date: Tue, 14 Oct 2025 22:15:44 +0200 Subject: [PATCH 4/5] fix: update regexp and tests --- .../ProblemEditor/data/OLXParser.js | 29 +++++++++++-------- .../ProblemEditor/data/ReactStateOLXParser.js | 16 ++++------ .../data/ReactStateOLXParser.test.js | 28 ++++++++++++++++-- .../data/mockData/editorTestData.js | 26 ++++++++++++++++- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js index 7a4921d6d3..b8b97f4312 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -60,27 +60,32 @@ export const responseKeys = [ /** * Regular expression to validate numeric answer ranges in OLX format. * Matches ranges in the form of (min, max) or [min, max] where: - * - min and max are numbers (integers or decimals) + * - Both min and max are required numbers (integers or decimals, positive or negative) * - Whitespace around numbers and comma is optional * - Parentheses () indicate exclusive bounds * - Square brackets [] indicate inclusive bounds * * @example * // Valid patterns: - * (1, 5) // Numbers 2, 3, 4 - * [1, 5] // Numbers 1, 2, 3, 4, 5 - * (1.5, 5.5) // Decimal numbers between 1.5 and 5.5 - * [0, 100] // Numbers 0 through 100 (inclusive) - * ( 0 , 1 ) // With spaces + * (1, 5) // Numbers 2, 3, 4 + * [1, 5] // Numbers 1 through 5 + * (1.5, 5.5) // Decimal numbers between 1.5 and 5.5 + * [-5, 10] // Negative to positive range + * (-3.5, 7) // Negative decimal range + * (-1,1) // No spaces + * (-1,1] // Excludes min + * [-1,1) // Excludes max * * @example * // Invalid patterns: - * (5, 1) // Min > Max - * (1, ) // Missing max value - * [1 5] // Missing comma - * {1, 5} // Invalid brackets + * (5,1) // Min > Max + * (1,) // Missing max + * (,1) // Missing min + * [1 5] // Missing comma + * {1,5} // Invalid brackets + * [--5,10] // Invalid negative format */ -export const answerRangeFormatRegex = /^[([]\s*\d+(\.\d+)?\s*,\s*\d+(\.\d+)?\s*[)\]]$/m; +export const answerRangeFormatRegex = /^[([]\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*[)\]]$/m; export const stripNonTextTags = ({ input, tag }) => { const stripedTags = {}; @@ -457,7 +462,7 @@ export class OLXParser { [type]: defaultValue, }; } - const isAnswerRange = answerRangeFormatRegex.test(numericalresponse['@_answer']); + const isAnswerRange = /[([]\s*\d*,\s*\d*\s*[)\]]/gm.test(numericalresponse['@_answer']); answers.push({ id: indexToLetterMap[answers.length], title: numericalresponse['@_answer'], diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js index 279294750a..d32abeac88 100644 --- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js +++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js @@ -407,25 +407,21 @@ class ReactStateOLXParser { let lowerBoundFraction; let upperBoundInt; let upperBoundFraction; - if (rawLowerBound.includes('/')) { + if (rawLowerBound?.includes('/')) { lowerBoundFraction = rawLowerBound.replace(/[^0-9-/]/gm, ''); const [numerator, denominator] = lowerBoundFraction.split('/'); - const lowerBoundFloat = Number(numerator) / Number(denominator); - lowerBoundInt = lowerBoundFloat; + lowerBoundInt = Number(numerator) / Number(denominator); } else { // these regex replaces remove everything that is not a decimal or positive/negative number - lowerBoundInt = Number(rawLowerBound.replace(/[^0-9-.]/gm, '')); + lowerBoundInt = Number(rawLowerBound?.replace(/[^0-9-.]/gm, '')); } - if (!rawUpperBound) { - upperBoundInt = lowerBoundInt; - } else if (rawUpperBound.includes('/')) { + if (rawUpperBound?.includes('/')) { upperBoundFraction = rawUpperBound.replace(/[^0-9-/]/gm, ''); const [numerator, denominator] = upperBoundFraction.split('/'); - const upperBoundFloat = Number(numerator) / Number(denominator); - upperBoundInt = upperBoundFloat; + upperBoundInt = Number(numerator) / Number(denominator); } else { // these regex replaces remove everything that is not a decimal or positive/negative number - upperBoundInt = Number(rawUpperBound.replace(/[^0-9-.]/gm, '')); + upperBoundInt = Number(rawUpperBound?.replace(/[^0-9-.]/gm, '')); } if (lowerBoundInt > upperBoundInt) { const lowerBoundChar = rawUpperBound[rawUpperBound.length - 1] === ']' ? '[' : '('; diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js index 7b852ca64b..9a4bad892d 100644 --- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js +++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js @@ -21,7 +21,9 @@ import { numericInputWithFractionBounds, numericInputWithEmptyUpperBound, numericInputWithSwappedBounds, - numericInputWithMissingUpperBound, + numericInputWithMissingLowerBound, + numericInputWithNegativeBounds, + numericInputWithSameBounds, } from './mockData/editorTestData'; import ReactStateOLXParser from './ReactStateOLXParser'; @@ -181,11 +183,31 @@ describe('Check React State OLXParser problem', () => { test('sets upper bound = lower bound if upper bound missing', () => { const parser = new ReactStateOLXParser({ - problem: numericInputWithMissingUpperBound, - editorObject: numericInputWithMissingUpperBound, + problem: numericInputWithMissingLowerBound, + editorObject: numericInputWithMissingLowerBound, }); const result = parser.buildNumericalResponse(); expect(result[':@']['@_answer']).toBe('[,2.5]'); }); + + test('handles negative number ranges correctly', () => { + const parser = new ReactStateOLXParser({ + problem: numericInputWithNegativeBounds, + editorObject: numericInputWithNegativeBounds, + }); + + const result = parser.buildNumericalResponse(); + expect(result[':@']['@_answer']).toBe('(-5.5,-1)'); + }); + + test('handles same numbers in ranges correctly', () => { + const parser = new ReactStateOLXParser({ + problem: numericInputWithSameBounds, + editorObject: numericInputWithSameBounds, + }); + + const result = parser.buildNumericalResponse(); + expect(result[':@']['@_answer']).toBe('[10,10]'); + }); }); }); diff --git a/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js b/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js index a33d947e41..989a926b7a 100644 --- a/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js +++ b/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js @@ -167,7 +167,7 @@ export const numericInputWithFractionBounds = { settings: {}, }; -export const numericInputWithMissingUpperBound = { +export const numericInputWithMissingLowerBound = { answers: [ { id: 'a1', @@ -178,3 +178,27 @@ export const numericInputWithMissingUpperBound = { problemType: 'numericalresponse', settings: {}, }; + +export const numericInputWithNegativeBounds = { + answers: [ + { + id: 'a1', + title: '(-5.5,-1)', + correct: true, + }, + ], + problemType: 'numericalresponse', + settings: {}, +}; + +export const numericInputWithSameBounds = { + answers: [ + { + id: 'a1', + title: '[10,10]', + correct: true, + }, + ], + problemType: 'numericalresponse', + settings: {}, +}; From b25619496d1df7c42462b57a6544d9f669e6102e Mon Sep 17 00:00:00 2001 From: ihor-romaniuk Date: Wed, 15 Oct 2025 08:52:15 +0200 Subject: [PATCH 5/5] fix: add fractions numbers support --- .../ProblemEditor/data/OLXParser.js | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js index b8b97f4312..63254cffd7 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -60,32 +60,42 @@ export const responseKeys = [ /** * Regular expression to validate numeric answer ranges in OLX format. * Matches ranges in the form of (min, max) or [min, max] where: - * - Both min and max are required numbers (integers or decimals, positive or negative) + * - Both min and max are required and can be: + * - integers (e.g. 1, -5) + * - decimals (e.g. 1.5, -0.25) + * - fractions (e.g. 1/2, -3/4) * - Whitespace around numbers and comma is optional * - Parentheses () indicate exclusive bounds * - Square brackets [] indicate inclusive bounds * * @example * // Valid patterns: - * (1, 5) // Numbers 2, 3, 4 - * [1, 5] // Numbers 1 through 5 - * (1.5, 5.5) // Decimal numbers between 1.5 and 5.5 - * [-5, 10] // Negative to positive range - * (-3.5, 7) // Negative decimal range - * (-1,1) // No spaces - * (-1,1] // Excludes min - * [-1,1) // Excludes max + * (1, 5) + * [1, 5] + * (1.5, 5.5) + * [-5, 10] + * (-3.5, 7) + * (-1,1) + * (-1,1] + * [-1,1) + * [1,1/2] + * [1/2, 2] + * [1/4, 1/2] + * (1,1/2] + * [1/2, 2) + * (1/4, 1/2) * * @example * // Invalid patterns: - * (5,1) // Min > Max - * (1,) // Missing max - * (,1) // Missing min - * [1 5] // Missing comma - * {1,5} // Invalid brackets - * [--5,10] // Invalid negative format + * (5,1) + * (1,) + * (,1) + * [1 5] + * {1,5} + * [--5,10] + * [] */ -export const answerRangeFormatRegex = /^[([]\s*-?\d+(?:\.\d+)?\s*,\s*-?\d+(?:\.\d+)?\s*[)\]]$/m; +export const answerRangeFormatRegex = /^[([]\s*-?(?:\d+(?:\.\d+)?|\d+\/\d+)\s*,\s*-?(?:\d+(?:\.\d+)?|\d+\/\d+)\s*[)\]]$/m; export const stripNonTextTags = ({ input, tag }) => { const stripedTags = {};