From 1a6937862f21a9a0b512a2defa2f7cfd38461583 Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Sun, 7 Dec 2025 15:09:32 +0100 Subject: [PATCH 1/9] convert numeric strings to numbers for output --- classes/local/evaluator.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/classes/local/evaluator.php b/classes/local/evaluator.php index c2247829..584543b0 100644 --- a/classes/local/evaluator.php +++ b/classes/local/evaluator.php @@ -163,6 +163,13 @@ public function substitute_variables_in_text(string $text, bool $skiplists = tru if ($skiplists && in_array($result->type, [token::LIST, token::SET])) { continue; } + // If the result is a numeric string, we convert it back to a number, in order for + // the standard PHP formatting to apply: numbers with an absolute value ≥ 1e14 + // or < 0.0001 would normally be printed in scientific notation. + if ($result->type === token::STRING && is_numeric($result->value)) { + $result->type = token::NUMBER; + $result->value = floatval($result->value); + } // If the result is a number, we try to localize it, unless the admin settings do not // allow the decimal comma. if ($result->type === token::NUMBER) { From 5db6eb91770757e1800321c82bf6c26998f2fd8e Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Sun, 7 Dec 2025 20:35:42 +0100 Subject: [PATCH 2/9] add test case --- tests/evaluator_test.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/evaluator_test.php b/tests/evaluator_test.php index 92e64d30..14d31167 100644 --- a/tests/evaluator_test.php +++ b/tests/evaluator_test.php @@ -1348,6 +1348,22 @@ public function test_substitute_variables_in_text(): void { self::assertEquals($expected, $output); } + public function test_substitute_variables_in_text_with_sigfig(): void { + // Define, parse and evaluate some variables. + $vars = 'a=0.3; b=sigfig(a, 2); c=0.000005; d=sigfig(c, 8); e=fact(19); f=sigfig(e, 3)'; + $parser = new parser($vars); + $statements = $parser->get_statements(); + $evaluator = new evaluator(); + $evaluator->evaluate($statements); + + // We only test the correct replacement of the numbers; correct interpretation of + // placeholders is covered in another test. + $text = '{a} -- {b} -- {c} -- {d} -- {e} -- {f}'; + $output = $evaluator->substitute_variables_in_text($text); + $expected = '0.3 -- 0.3 -- 5.0E-6 -- 5.0E-6 -- 1.2164510040883E+17 -- 1.22E+17'; + self::assertEquals($expected, $output); + } + public function test_substitute_variables_in_algebraic_formula(): void { // Define, parse and evaluate some variables. $vars = 'a=1; b=[2,3,4]; c={1,2,3}; x={1:10}; y={1:10}; k = [[1,2],[3,4]];'; From b764a44d31b3c088942cfaddfb2c081b95849b38 Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Sun, 7 Dec 2025 20:56:09 +0100 Subject: [PATCH 3/9] add test case --- tests/evaluator_test.php | 11 +++++++++++ tests/renderer_test.php | 27 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/tests/evaluator_test.php b/tests/evaluator_test.php index 14d31167..af5d8786 100644 --- a/tests/evaluator_test.php +++ b/tests/evaluator_test.php @@ -1349,6 +1349,9 @@ public function test_substitute_variables_in_text(): void { } public function test_substitute_variables_in_text_with_sigfig(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + // Define, parse and evaluate some variables. $vars = 'a=0.3; b=sigfig(a, 2); c=0.000005; d=sigfig(c, 8); e=fact(19); f=sigfig(e, 3)'; $parser = new parser($vars); @@ -1362,6 +1365,14 @@ public function test_substitute_variables_in_text_with_sigfig(): void { $output = $evaluator->substitute_variables_in_text($text); $expected = '0.3 -- 0.3 -- 5.0E-6 -- 5.0E-6 -- 1.2164510040883E+17 -- 1.22E+17'; self::assertEquals($expected, $output); + + // Setting the localised decimal separator, but disallow the decimal comma in the admin settings. + qtype_formulas_test_helper::define_local_decimal_separator(); + set_config('allowdecimalcomma', 1, 'qtype_formulas'); + self::assertEquals('1', get_config('qtype_formulas', 'allowdecimalcomma')); + $output = $evaluator->substitute_variables_in_text($text); + $expected = '0,3 -- 0,3 -- 5,0E-6 -- 5,0E-6 -- 1,2164510040883E+17 -- 1,22E+17'; + self::assertEquals($expected, $output); } public function test_substitute_variables_in_algebraic_formula(): void { diff --git a/tests/renderer_test.php b/tests/renderer_test.php index 82a52e6b..c72bd334 100644 --- a/tests/renderer_test.php +++ b/tests/renderer_test.php @@ -336,6 +336,33 @@ public function test_substitution_of_local_variables(): void { $this->check_output_does_not_contain_stray_placeholders(); } + public function test_right_answer_feedback_uses_appropriate_decimal_separator(): void { + // Setting the localised decimal separator, but disallow the decimal comma in the admin settings. + qtype_formulas_test_helper::define_local_decimal_separator(); + self::assertEquals('0', get_config('qtype_formulas', 'allowdecimalcomma')); + + $q = $this->get_test_formulas_question('testsinglenum'); + $q->parts[0]->answer = '3.5'; + + $this->start_attempt_at_question($q, 'immediatefeedback', 1); + $this->process_submission(['0_0' => '42', '-submit' => 1]); + $this->check_output_contains_lang_string('correctansweris', 'qtype_formulas', '3.5'); + + // Now allowing the decimal comma to be used. + set_config('allowdecimalcomma', 1, 'qtype_formulas'); + $this->start_attempt_at_question($q, 'immediatefeedback', 1); + $this->process_submission(['0_0' => '42', '-submit' => 1]); + $this->check_output_contains_lang_string('correctansweris', 'qtype_formulas', '3,5'); + + // Make sure the decimal comma is also applied for numbers that are string tokens. + // Note that we *should* have 3,50 as the model answer, but as we are converting + // the string output from sigfig() back to a number, trailing zeroes will be lost. + $q = $this->get_test_formulas_question('testsinglenum'); + $q->parts[0]->answer = 'sigfig(3.5, 3)'; + $this->process_submission(['0_0' => '42', '-submit' => 1]); + $this->check_output_contains_lang_string('correctansweris', 'qtype_formulas', '3,5'); + } + public function test_render_question_with_separate_unit_field(): void { $q = $this->get_test_formulas_question('testsinglenumunitsep'); $q->parts[0]->unitpenalty = 0.5; From e7948b40bb28b5369e41ac5703a5cbc4826ea9ff Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:21:50 +0200 Subject: [PATCH 4/9] better solution, keeping trailing zeroes --- classes/local/functions.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/classes/local/functions.php b/classes/local/functions.php index 487961fe..7661782c 100644 --- a/classes/local/functions.php +++ b/classes/local/functions.php @@ -657,7 +657,22 @@ public static function sigfig($number, $precision): string { // We only request decimals if $digitsafter is greater than zero. $digitsafter = max(0, $digitsafter); - return number_format($number, $digitsafter, '.', ''); + // If absolute value of the number is >= 1e-4 and < 1e14, we can format it and return + // a string. + if (abs($number) >= 1e-4 && abs($number) < 1e14) { + return number_format($number, $digitsafter, '.', ''); + } + + // For numbers with an absolute value >= 1e14, we must use the scientific notation, + // because that's how PHP would output the number, if it had not been formatted. Using + // the E format will give scientific notation with E, like the default behaviour. + if (abs($number) >= 1e14) { + return sprintf("%.{$precision}E", $number); + } + + // Finally, if the absolute value is < 1e-4, we must reduce the precision by 1, because + // there will be one figure before the decimal point. + return sprintf('%.' . ($precision - 1) . 'E', $number); } /** From a8ac66e326735d4eb1bb3c6ac45151cc68ab84a5 Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:24:08 +0200 Subject: [PATCH 5/9] remove old approach --- classes/local/evaluator.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/classes/local/evaluator.php b/classes/local/evaluator.php index 584543b0..c2247829 100644 --- a/classes/local/evaluator.php +++ b/classes/local/evaluator.php @@ -163,13 +163,6 @@ public function substitute_variables_in_text(string $text, bool $skiplists = tru if ($skiplists && in_array($result->type, [token::LIST, token::SET])) { continue; } - // If the result is a numeric string, we convert it back to a number, in order for - // the standard PHP formatting to apply: numbers with an absolute value ≥ 1e14 - // or < 0.0001 would normally be printed in scientific notation. - if ($result->type === token::STRING && is_numeric($result->value)) { - $result->type = token::NUMBER; - $result->value = floatval($result->value); - } // If the result is a number, we try to localize it, unless the admin settings do not // allow the decimal comma. if ($result->type === token::NUMBER) { From 7b5cf17c523d695d5ba7eab5a421a254958cecbc Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:23:28 +0200 Subject: [PATCH 6/9] fix implementation, add test cases --- classes/local/functions.php | 21 ++++++++++--------- tests/evaluator_test.php | 4 ++-- tests/functions_test.php | 42 +++++++++++++++++++++++++++++++++++++ tests/renderer_test.php | 2 +- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/classes/local/functions.php b/classes/local/functions.php index 7661782c..b8498dd4 100644 --- a/classes/local/functions.php +++ b/classes/local/functions.php @@ -636,6 +636,7 @@ public static function sigfig($number, $precision): string { get_string('error_func_second_posint', 'qtype_formulas', 'sigfig()'), self::POSITIVE | self::INTEGER ); + $originalnumber = $number; $number = floatval($number); $precision = intval($precision); @@ -657,21 +658,21 @@ public static function sigfig($number, $precision): string { // We only request decimals if $digitsafter is greater than zero. $digitsafter = max(0, $digitsafter); - // If absolute value of the number is >= 1e-4 and < 1e14, we can format it and return - // a string. - if (abs($number) >= 1e-4 && abs($number) < 1e14) { + // If the absolute value of the *original* number is >= 1e-4 and < 1e14, we can format + // it and return a string. + if (abs($originalnumber) >= 1e-4 && abs($originalnumber) < 1e14) { return number_format($number, $digitsafter, '.', ''); } - // For numbers with an absolute value >= 1e14, we must use the scientific notation, - // because that's how PHP would output the number, if it had not been formatted. Using - // the E format will give scientific notation with E, like the default behaviour. - if (abs($number) >= 1e14) { - return sprintf("%.{$precision}E", $number); + // If the absolute value of the original number was below 1e-4, but just reached that + // limit after rounding, it should be output as 0.0001, plus possibly some trailing zeroes. + if (abs($originalnumber) < 1e-4 && abs($number) == 1e-4) { + return sprintf("%.{$precision}H", $number); } - // Finally, if the absolute value is < 1e-4, we must reduce the precision by 1, because - // there will be one figure before the decimal point. + // For all other cases, we force scientific notation. Note that we have to subtract 1 + // from the requested precision, because with the E format, the parameter indicates + // the number of places *after* the decimal comma. return sprintf('%.' . ($precision - 1) . 'E', $number); } diff --git a/tests/evaluator_test.php b/tests/evaluator_test.php index af5d8786..d972f547 100644 --- a/tests/evaluator_test.php +++ b/tests/evaluator_test.php @@ -1363,7 +1363,7 @@ public function test_substitute_variables_in_text_with_sigfig(): void { // placeholders is covered in another test. $text = '{a} -- {b} -- {c} -- {d} -- {e} -- {f}'; $output = $evaluator->substitute_variables_in_text($text); - $expected = '0.3 -- 0.3 -- 5.0E-6 -- 5.0E-6 -- 1.2164510040883E+17 -- 1.22E+17'; + $expected = '0.3 -- 0.30 -- 5.0E-6 -- 5.0000000E-6 -- 1.2164510040883E+17 -- 1.22E+17'; self::assertEquals($expected, $output); // Setting the localised decimal separator, but disallow the decimal comma in the admin settings. @@ -1371,7 +1371,7 @@ public function test_substitute_variables_in_text_with_sigfig(): void { set_config('allowdecimalcomma', 1, 'qtype_formulas'); self::assertEquals('1', get_config('qtype_formulas', 'allowdecimalcomma')); $output = $evaluator->substitute_variables_in_text($text); - $expected = '0,3 -- 0,3 -- 5,0E-6 -- 5,0E-6 -- 1,2164510040883E+17 -- 1,22E+17'; + $expected = '0,3 -- 0,30 -- 5,0E-6 -- 5,0000000E-6 -- 1,2164510040883E+17 -- 1,22E+17'; self::assertEquals($expected, $output); } diff --git a/tests/functions_test.php b/tests/functions_test.php index 1e305a51..252d9741 100644 --- a/tests/functions_test.php +++ b/tests/functions_test.php @@ -627,6 +627,48 @@ public static function provide_sigfig_expressions(): array { ['-0.005', 'sigfig(-.005, 1)'], ['-0.0050', 'sigfig(-.005, 2)'], ['-0.00500', 'sigfig(-.005, 3)'], + + ['9.99E-5', 'sigfig(0.0000999, 3)'], + ['9.990E-5', 'sigfig(0.0000999, 4)'], + ['1.00E-5', 'sigfig(0.0000100, 3)'], + ['1.000E-5', 'sigfig(0.0000100, 4)'], + ['-9.99E-5', 'sigfig(-0.0000999, 3)'], + ['-9.990E-5', 'sigfig(-0.0000999, 4)'], + ['-1.00E-5', 'sigfig(-0.0000100, 3)'], + ['-1.000E-5', 'sigfig(-0.0000100, 4)'], + ['1.23E-6', 'sigfig(0.000001234, 3)'], + ['1.2340E-6', 'sigfig(0.000001234, 5)'], + ['-1.23E-5', 'sigfig(-0.0000123, 3)'], + ['-1.230E-5', 'sigfig(-0.0000123, 4)'], + + ['0.000100', 'sigfig(0.0001, 3)'], + ['0.0001000', 'sigfig(0.0001, 4)'], + ['0.0001', 'sigfig(0.00009995, 3)'], + ['9.995E-5', 'sigfig(0.00009995, 4)'], + ['-0.000100', 'sigfig(-0.0001, 3)'], + ['-0.0001000', 'sigfig(-0.0001, 4)'], + ['-0.0001', 'sigfig(-0.00009995, 3)'], + ['-9.995E-5', 'sigfig(-0.00009995, 4)'], + + ['1.00E+14', 'sigfig(1e14, 3)'], + ['1.0000E+14', 'sigfig(1e14, 5)'], + ['1.23E+14', 'sigfig(1.23e14, 3)'], + ['1.2300E+14', 'sigfig(1.23e14, 5)'], + ['1.20E+14', 'sigfig(1.2e14, 3)'], + ['9.99E+14', 'sigfig(9.99e14, 3)'], + ['-1.230E+14', 'sigfig(-1.23e14, 4)'], + + ['100000000000000', 'sigfig(99999999999999, 3)'], + ['100000000000000', 'sigfig(99999999999999, 5)'], + ['99900000000000', 'sigfig(99900000000000, 3)'], + ['-100000000000000', 'sigfig(-99999999999999, 3)'], + ['-100000000000000', 'sigfig(-99999999999999, 5)'], + ['-99900000000000', 'sigfig(-99900000000000, 3)'], + + ['0.300', 'sigfig(0.3, 3)'], + ['123.00', 'sigfig(123, 5)'], + ['99999.0', 'sigfig(99999, 6)'], + ['-0.00123', 'sigfig(-0.00123, 3)'], ]; } diff --git a/tests/renderer_test.php b/tests/renderer_test.php index c72bd334..e6a60296 100644 --- a/tests/renderer_test.php +++ b/tests/renderer_test.php @@ -360,7 +360,7 @@ public function test_right_answer_feedback_uses_appropriate_decimal_separator(): $q = $this->get_test_formulas_question('testsinglenum'); $q->parts[0]->answer = 'sigfig(3.5, 3)'; $this->process_submission(['0_0' => '42', '-submit' => 1]); - $this->check_output_contains_lang_string('correctansweris', 'qtype_formulas', '3,5'); + $this->check_output_contains_lang_string('correctansweris', 'qtype_formulas', '3,50'); } public function test_render_question_with_separate_unit_field(): void { From e53e8f6fa4b2db9a22faf8a261acc222bdbea74f Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:39:19 +0200 Subject: [PATCH 7/9] bugfix --- classes/local/evaluator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/classes/local/evaluator.php b/classes/local/evaluator.php index c2247829..ff674704 100644 --- a/classes/local/evaluator.php +++ b/classes/local/evaluator.php @@ -163,9 +163,9 @@ public function substitute_variables_in_text(string $text, bool $skiplists = tru if ($skiplists && in_array($result->type, [token::LIST, token::SET])) { continue; } - // If the result is a number, we try to localize it, unless the admin settings do not + // If the result is a number or a numeric string, we try to localize it, unless the admin settings do not // allow the decimal comma. - if ($result->type === token::NUMBER) { + if ($result->type === token::NUMBER || ($result->type === token::STRING && is_numeric($result->value))) { $result = qtype_formulas::format_float($result->value); } From d2227254c634dcc503c94730ec5b830ede4dc936 Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:36:33 +0200 Subject: [PATCH 8/9] fix bad test --- tests/renderer_test.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/renderer_test.php b/tests/renderer_test.php index e6a60296..e9ac96e8 100644 --- a/tests/renderer_test.php +++ b/tests/renderer_test.php @@ -355,10 +355,9 @@ public function test_right_answer_feedback_uses_appropriate_decimal_separator(): $this->check_output_contains_lang_string('correctansweris', 'qtype_formulas', '3,5'); // Make sure the decimal comma is also applied for numbers that are string tokens. - // Note that we *should* have 3,50 as the model answer, but as we are converting - // the string output from sigfig() back to a number, trailing zeroes will be lost. $q = $this->get_test_formulas_question('testsinglenum'); $q->parts[0]->answer = 'sigfig(3.5, 3)'; + $this->start_attempt_at_question($q, 'immediatefeedback', 1); $this->process_submission(['0_0' => '42', '-submit' => 1]); $this->check_output_contains_lang_string('correctansweris', 'qtype_formulas', '3,50'); } From 01b24b98635d4e467cc3ddb1c55fa070f6f993e5 Mon Sep 17 00:00:00 2001 From: Philipp Imhof <52650214+PhilippImhof@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:20:00 +0200 Subject: [PATCH 9/9] special case for zero --- classes/local/functions.php | 4 ++-- tests/functions_test.php | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/classes/local/functions.php b/classes/local/functions.php index b8498dd4..52885da0 100644 --- a/classes/local/functions.php +++ b/classes/local/functions.php @@ -659,8 +659,8 @@ public static function sigfig($number, $precision): string { $digitsafter = max(0, $digitsafter); // If the absolute value of the *original* number is >= 1e-4 and < 1e14, we can format - // it and return a string. - if (abs($originalnumber) >= 1e-4 && abs($originalnumber) < 1e14) { + // it and return a string. The same is true if the rounded number is zero. + if ($number == 0 || (abs($originalnumber) >= 1e-4 && abs($originalnumber) < 1e14)) { return number_format($number, $digitsafter, '.', ''); } diff --git a/tests/functions_test.php b/tests/functions_test.php index 252d9741..8c74b01a 100644 --- a/tests/functions_test.php +++ b/tests/functions_test.php @@ -669,6 +669,7 @@ public static function provide_sigfig_expressions(): array { ['123.00', 'sigfig(123, 5)'], ['99999.0', 'sigfig(99999, 6)'], ['-0.00123', 'sigfig(-0.00123, 3)'], + ['0.00', 'sigfig(0, 3)'], ]; }