diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx index e9ca9e6709..897ff07b01 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 validateAnswerRange = (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 = validateAnswerRange(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..63254cffd7 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -57,6 +57,46 @@ export const responseKeys = [ 'choicetextresponse', ]; +/** + * 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 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) + * [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) + * (1,) + * (,1) + * [1 5] + * {1,5} + * [--5,10] + * [] + */ +export const answerRangeFormatRegex = /^[([]\s*-?(?:\d+(?:\.\d+)?|\d+\/\d+)\s*,\s*-?(?:\d+(?:\.\d+)?|\d+\/\d+)\s*[)\]]$/m; + export const stripNonTextTags = ({ input, tag }) => { const stripedTags = {}; Object.entries(input).forEach(([key, value]) => { diff --git a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js index 5f2d8ca9f7..d32abeac88 100644 --- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js +++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.js @@ -407,23 +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 numer - lowerBoundInt = Number(rawLowerBound.replace(/[^0-9-.]/gm, '')); + // 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?.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 numer - upperBoundInt = Number(rawUpperBound.replace(/[^0-9-.]/gm, '')); + // these regex replaces remove everything that is not a decimal or positive/negative number + 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 416fafdcd8..9a4bad892d 100644 --- a/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js +++ b/src/editors/containers/ProblemEditor/data/ReactStateOLXParser.test.js @@ -18,6 +18,12 @@ import { numericInputWithAnswerRange, textInputWithFeedbackAndHintsWithMultipleAnswers, numberParseTest, + numericInputWithFractionBounds, + numericInputWithEmptyUpperBound, + numericInputWithSwappedBounds, + numericInputWithMissingLowerBound, + numericInputWithNegativeBounds, + numericInputWithSameBounds, } from './mockData/editorTestData'; import ReactStateOLXParser from './ReactStateOLXParser'; @@ -147,4 +153,61 @@ 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: 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 a96230c46e..989a926b7a 100644 --- a/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js +++ b/src/editors/containers/ProblemEditor/data/mockData/editorTestData.js @@ -130,3 +130,75 @@ 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 numericInputWithMissingLowerBound = { + answers: [ + { + id: 'a1', + title: '[,2.5]', + correct: true, + }, + ], + 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: {}, +};