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: {},
+};