fix: answer range format validation in numerical input problems (#2426)

This commit is contained in:
Ihor Romaniuk
2025-10-15 20:33:31 +02:00
committed by GitHub
parent 411d4f053c
commit 4a26a86c90
6 changed files with 202 additions and 12 deletions

View File

@@ -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 (
<div>
<Form.Group isInvalid={!isValidValue}>
<Form.Control
as="textarea"
className="answer-option-textarea text-gray-500 small"
@@ -88,10 +95,15 @@ const AnswerOption = ({
onChange={setAnswerTitle}
placeholder={intl.formatMessage(messages.answerRangeTextboxPlaceholder)}
/>
{!isValidValue && (
<Form.Control.Feedback type="invalid">
<FormattedMessage {...messages.answerRangeErrorText} />
</Form.Control.Feedback>
)}
<div className="pgn__form-switch-helper-text">
<FormattedMessage {...messages.answerRangeHelperText} />
</div>
</div>
</Form.Group>
);
};

View File

@@ -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;

View File

@@ -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]) => {

View File

@@ -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] === ']' ? '[' : '(';

View File

@@ -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]');
});
});
});

View File

@@ -130,3 +130,75 @@ export const numberParseTest = {
hints: [],
question: '<p>What is the content of the register x2 after executing the following three lines of instructions?</p>',
};
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: {},
};