fix: answer range format validation in numerical input problems (#2426)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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] === ']' ? '[' : '(';
|
||||
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user