@coffeeandfun/maths-captcha
Version:
Maths-Captcha is a simple Node.js module that generates random math questions to verify human interaction. It creates math problems like addition, subtraction, multiplication, and division, and checks if the user's answer is correct. Ideal for adding a qu
505 lines (422 loc) • 14 kB
JavaScript
const OPERATIONS = {
"+": (a, b) => a + b,
"-": (a, b) => a - b,
"*": (a, b) => a * b,
"/": (a, b) => a / b,
};
const CONFIG = {
DIVISION_PRECISION: 2,
NUMBER_RANGE: { min: 1, max: 100 },
TOLERANCE: 1e-10, // For floating point comparison
OPERATIONS: Object.keys(OPERATIONS), // Allow customizing which operations to use
AVOID_DIVISION_BY_ZERO: true,
AVOID_NEGATIVE_RESULTS: true,
MAX_ATTEMPTS: 10, // Prevent infinite loops in generation
};
/**
* Returns a random integer between min and max, inclusive.
*/
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Returns a random element from the given array.
*/
function getRandomElement(arr) {
return arr[getRandomInt(0, arr.length - 1)];
}
/**
* Generates a math question with smart constraints to avoid problematic cases.
*/
function generateRandomMathQuestion(precision = CONFIG.DIVISION_PRECISION) {
let attempts = 0;
while (attempts < CONFIG.MAX_ATTEMPTS) {
let num1 = getRandomInt(CONFIG.NUMBER_RANGE.min, CONFIG.NUMBER_RANGE.max);
let num2 = getRandomInt(CONFIG.NUMBER_RANGE.min, CONFIG.NUMBER_RANGE.max);
const operation = getRandomElement(CONFIG.OPERATIONS);
// Smart constraints based on operation
if (operation === "-" && CONFIG.AVOID_NEGATIVE_RESULTS && num1 < num2) {
[num1, num2] = [num2, num1]; // Swap to ensure positive result
}
if (operation === "/" && CONFIG.AVOID_DIVISION_BY_ZERO && num2 === 0) {
attempts++;
continue; // Retry with different numbers
}
const question = `${num1} ${operation} ${num2}`;
const result = OPERATIONS[operation](num1, num2);
// Store both the raw numeric result and formatted string
const formattedAnswer = operation === "/"
? result.toFixed(precision)
: result.toString();
return {
question,
answer: formattedAnswer,
numericAnswer: result,
operation,
operands: [num1, num2]
};
}
// Fallback to simple addition if all attempts failed
const num1 = getRandomInt(1, 10);
const num2 = getRandomInt(1, 10);
const result = num1 + num2;
return {
question: `${num1} + ${num2}`,
answer: result.toString(),
numericAnswer: result,
operation: "+",
operands: [num1, num2]
};
}
/**
* Generates multiple questions at once.
*/
function generateMultipleQuestions(count = 1, precision = CONFIG.DIVISION_PRECISION) {
const questions = [];
for (let i = 0; i < count; i++) {
questions.push(generateRandomMathQuestion(precision));
}
return questions;
}
/**
* Generates a question with specific constraints.
*/
function generateQuestionWithConstraints(constraints = {}) {
const {
operations = CONFIG.OPERATIONS,
numberRange = CONFIG.NUMBER_RANGE,
maxResult = Infinity,
minResult = -Infinity,
precision = CONFIG.DIVISION_PRECISION
} = constraints;
const tempConfig = { ...CONFIG };
CONFIG.OPERATIONS = operations;
CONFIG.NUMBER_RANGE = numberRange;
let attempts = 0;
while (attempts < CONFIG.MAX_ATTEMPTS) {
const question = generateRandomMathQuestion(precision);
const result = question.numericAnswer;
if (result >= minResult && result <= maxResult) {
Object.assign(CONFIG, tempConfig); // Restore original config
return question;
}
attempts++;
}
Object.assign(CONFIG, tempConfig); // Restore original config
throw new Error('Could not generate question within constraints');
}
/**
* Validates if a string represents a well-formed number.
*/
function isValidNumericString(str) {
if (typeof str !== 'string') return false;
const trimmed = str.trim();
// Reject empty string
if (trimmed === '') return false;
// Reject malformed patterns like "1.2.3", "4,56", "--5"
if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return false;
// Additional check for valid number
return !isNaN(Number(trimmed));
}
/**
* Compares two numbers with tolerance for floating point precision issues.
*/
function numbersEqual(a, b, tolerance = CONFIG.TOLERANCE) {
return Math.abs(a - b) < tolerance;
}
/**
* Enhanced validation that supports flexible decimal input while maintaining original strict behavior.
* For backwards compatibility with existing tests.
*/
function validateAnswer(questionObj, userAnswer) {
// Handle null/undefined input
if (userAnswer === null || userAnswer === undefined) {
return false;
}
const expected = questionObj.answer.toString();
const userStr = userAnswer.toString().trim();
// Reject malformed input
if (!isValidNumericString(userStr)) {
return false;
}
const expectedDecimals = getDecimalPlaces(expected);
// For integer answers, reject any decimal input (strict mode)
if (expectedDecimals === 0) {
if (userStr.includes('.')) return false;
return userStr === expected;
}
// Reject negative decimals (original business rule)
if (userStr.startsWith('-') && userStr.includes('.')) {
return false;
}
// For division answers with decimals, use flexible validation
// Allow 3.4, 3.40, 3.444 to all match expected answer of 3.40
const userNum = parseFloat(userStr);
const expectedNum = parseFloat(expected);
// Handle special case for zero
if (userNum === 0 && expectedNum === 0) {
return true;
}
// Round user input to expected precision and compare
const roundedUser = roundToDecimalPlaces(userStr, expectedDecimals);
return roundedUser === expected;
}
/**
* Flexible validation that accepts different decimal representations.
* Examples: 3.4, 3.40, 3.444444 all match expected answer of 3.40
*/
function validateAnswerFlexible(questionObj, userAnswer) {
if (userAnswer === null || userAnswer === undefined) {
return false;
}
const userStr = userAnswer.toString().trim();
// Reject malformed input
if (!isValidNumericString(userStr)) {
return false;
}
const userNum = parseFloat(userStr);
const expectedNum = questionObj.numericAnswer || parseFloat(questionObj.answer);
// Handle special cases
if (!Number.isFinite(userNum) || !Number.isFinite(expectedNum)) {
return false;
}
// For integer operations, allow flexible input but compare as numbers
if (questionObj.operation !== '/') {
return numbersEqual(userNum, expectedNum);
}
// For division, be more lenient with precision
const precision = CONFIG.DIVISION_PRECISION + 2; // Allow extra precision in input
const userRounded = Math.round(userNum * Math.pow(10, precision)) / Math.pow(10, precision);
const expectedRounded = Math.round(expectedNum * Math.pow(10, precision)) / Math.pow(10, precision);
return numbersEqual(userRounded, expectedRounded);
}
/**
* Enhanced validation with detailed feedback for debugging.
*/
function validateAnswerWithFeedback(questionObj, userAnswer) {
const userStr = userAnswer.toString().trim();
// Check for malformed input
if (!isValidNumericString(userStr)) {
return {
isValid: false,
reason: 'Invalid numeric format',
userInput: userStr,
expected: questionObj.answer
};
}
const userNum = parseFloat(userStr);
const expectedNum = questionObj.numericAnswer || parseFloat(questionObj.answer);
// Handle special cases
if (!Number.isFinite(userNum)) {
return {
isValid: false,
reason: 'User input is not a finite number',
userInput: userStr,
expected: questionObj.answer
};
}
if (!Number.isFinite(expectedNum)) {
return {
isValid: false,
reason: 'Expected answer is not a finite number',
userInput: userStr,
expected: questionObj.answer
};
}
// For integer operations, allow flexible input but compare as numbers
if (questionObj.operation !== '/') {
const isValid = numbersEqual(userNum, expectedNum);
return {
isValid,
reason: isValid ? 'Correct' : 'Incorrect value',
userInput: userStr,
userNumber: userNum,
expectedNumber: expectedNum,
expected: questionObj.answer
};
}
// For division, be more lenient with precision
const precision = CONFIG.DIVISION_PRECISION + 2;
const userRounded = Math.round(userNum * Math.pow(10, precision)) / Math.pow(10, precision);
const expectedRounded = Math.round(expectedNum * Math.pow(10, precision)) / Math.pow(10, precision);
const isValid = numbersEqual(userRounded, expectedRounded);
return {
isValid,
reason: isValid ? 'Correct' : 'Incorrect value',
userInput: userStr,
userNumber: userNum,
expectedNumber: expectedNum,
expected: questionObj.answer
};
}
/**
* Batch validation for multiple answers.
*/
function validateAnswers(questionAnswerPairs) {
return questionAnswerPairs.map(({ question, answer }) => ({
question: question.question,
isValid: validateAnswer(question, answer),
expectedAnswer: question.answer
}));
}
/**
* Determines the number of decimal places in a numeric string.
*/
function getDecimalPlaces(numStr) {
const parts = numStr.split('.');
return parts.length > 1 ? parts[1].length : 0;
}
/**
* Performs half-up rounding on a numeric string to specified decimal places.
*/
function roundToDecimalPlaces(numStr, decimals) {
const isNegative = numStr.startsWith('-');
const absStr = isNegative ? numStr.slice(1) : numStr;
let [intPart, decPart = ''] = absStr.split('.');
// Pad with zeros to ensure we have enough digits for rounding
decPart = decPart.padEnd(decimals + 1, '0');
const roundingDigit = parseInt(decPart[decimals] || '0');
let keptDecimals = decPart.slice(0, decimals);
// Apply half-up rounding (5 and above rounds up)
if (roundingDigit >= 5) {
const rounded = addOneToDecimalString(keptDecimals);
if (rounded.overflow) {
// Carry over to integer part
intPart = (BigInt(intPart) + 1n).toString();
keptDecimals = '0'.repeat(decimals);
} else {
keptDecimals = rounded.result;
}
}
// Construct final result
const finalDecimals = keptDecimals.padEnd(decimals, '0');
let result = decimals > 0 ? `${intPart}.${finalDecimals}` : intPart;
// Apply negative sign if needed (but normalize -0 to 0)
if (isNegative && !isEffectivelyZero(result)) {
result = `-${result}`;
}
return result;
}
/**
* Adds 1 to a decimal string, handling carry-over.
*/
function addOneToDecimalString(decStr) {
if (!decStr) return { result: '1', overflow: true };
const digits = decStr.split('').reverse();
let carry = 1;
for (let i = 0; i < digits.length && carry; i++) {
const sum = parseInt(digits[i]) + carry;
digits[i] = (sum % 10).toString();
carry = Math.floor(sum / 10);
}
return {
result: digits.reverse().join(''),
overflow: carry > 0
};
}
/**
* Checks if a numeric string represents zero (including "0.00", etc.).
*/
function isEffectivelyZero(numStr) {
return /^0*\.?0*$/.test(numStr.replace('-', ''));
}
/**
* Strict validation mode - matches original behavior exactly.
* Use this if you need the old strict decimal place matching.
*/
function validateAnswerStrict(questionObj, userAnswer) {
const expected = questionObj.answer.toString();
const userStr = userAnswer.toString().trim();
// Reject malformed input
if (!isValidNumericString(userStr)) {
return false;
}
const expectedDecimals = getDecimalPlaces(expected);
// For integer answers, reject any decimal input
if (expectedDecimals === 0) {
return userStr.includes('.') ? false : userStr === expected;
}
// Reject negative decimals (original business rule)
if (userStr.startsWith('-') && userStr.includes('.')) {
return false;
}
// Round user input to expected precision and compare
const roundedUser = roundToDecimalPlaces(userStr, expectedDecimals);
return roundedUser === expected;
}
/**
* Get statistics about question difficulty.
*/
function getQuestionStats(question) {
const [num1, num2] = question.operands;
const result = question.numericAnswer;
return {
operands: { num1, num2 },
result,
operation: question.operation,
difficulty: calculateDifficulty(question),
hasDecimals: question.operation === '/' || !Number.isInteger(result)
};
}
/**
* Calculate a simple difficulty score (1-10).
*/
function calculateDifficulty(question) {
const [num1, num2] = question.operands;
const { operation } = question;
let score = 1;
// Operation difficulty
const opDifficulty = { '+': 1, '-': 2, '*': 3, '/': 4 };
score += opDifficulty[operation] || 1;
// Number size difficulty
const maxOperand = Math.max(num1, num2);
if (maxOperand > 50) score += 2;
else if (maxOperand > 20) score += 1;
// Result complexity
if (operation === '/' && question.answer.includes('.')) score += 2;
if (question.numericAnswer > 100) score += 1;
return Math.min(score, 10);
}
/**
* Normalizes a numeric string by removing trailing zeros and unnecessary decimal points.
*/
function normalizeNumericString(str) {
const num = parseFloat(str);
if (Number.isInteger(num)) {
return num.toString();
}
return num.toString().replace(/\.?0+$/, '');
}
/**
* Alias for validateAnswer for backward compatibility.
*/
function checkIfSolvedCorrectly(questionObj, userAnswer) {
return validateAnswer(questionObj, userAnswer);
}
/**
* Configuration helper to adjust settings.
*/
function setConfig(newConfig) {
Object.assign(CONFIG, newConfig);
}
/**
* Get current configuration.
*/
function getConfig() {
return { ...CONFIG };
}
export {
generateRandomMathQuestion,
generateMultipleQuestions,
generateQuestionWithConstraints,
validateAnswer,
validateAnswerFlexible,
validateAnswerWithFeedback,
validateAnswers,
validateAnswerStrict,
checkIfSolvedCorrectly,
getQuestionStats,
setConfig,
getConfig,
normalizeNumericString
};