From 37049a9ef01068b9feea0afdcaf19f3fcd1acf95 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Wed, 18 Dec 2024 09:31:28 +0000
Subject: [PATCH 01/20] Update the docs on releasing JSXGraph versions (notes
from issue #1169).
---
doc/dev/Updating_JSXGraph.md | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/doc/dev/Updating_JSXGraph.md b/doc/dev/Updating_JSXGraph.md
index 0dfde3ca902..045e7ccaa32 100644
--- a/doc/dev/Updating_JSXGraph.md
+++ b/doc/dev/Updating_JSXGraph.md
@@ -1,12 +1,17 @@
# Updating JSXGraph
-Since the stack-js update, we have been able to use JSXGraph in its released form.
+JSXGraph is used in its released form. The files related to it are now stored under `corsscripts/`.
-The files related to it are now stored under `corsscripts/`.
+Download JSXGraph: [https://github.com/jsxgraph/jsxgraph](https://github.com/jsxgraph/jsxgraph).
-Download JSXGraph from here: [https://github.com/jsxgraph/jsxgraph](https://github.com/jsxgraph/jsxgraph).
+1. Copy over minified files `jsxgraph.min.css` and `jsxgraphcore.min.js`, there is no need to copy the non minified versions.
+2. Add frozen CDN URLs to the named version map in stack/cas/castext2/blocks/jsxgraph.block.php.
+3. Test that the healthcheck example works, the binding in particular and the render of the formula.
+4. Test the binding in more detail by checking that samplequestions/stack_jxg.binding-demo-4.4.xml still does sensible things.
+5. If all looks good and keeps looking good after Moodle JavaScript reset, NOOP edit of question to recompile it and running in private-mode/incognito-mode to ensure that things are new and not coming from any cache. Then things should be fine.
+6. Check Meclib materials, and further testing!
-The files one needs to copy over are `jsxgraph.min.css` and `jsxgraphcore.min.js`, there is no need to copy the non minified versions.
+An example commit is https://github.com/maths/moodle-qtype_stack/commit/409cd0960f003e80d81a982fb96d6f7c310576de
The old STACK side `jsxgraph.js` that provided the `stack_jxg` features is now called `stackjsxgraph.js` and is being served from that same CORS-header tuning directory with that specific script. (Minification can be done using uglify-js:
`npm install -g uglify-js`
@@ -18,3 +23,4 @@ We do not apply Moodles or any other systems JavaScript processing on these, no
We really want to have a local JSXGraph copy instead of relying on a CDN version. We want to make it easy to run STACK in a closed network with no external requirements and having a local JSXGraph is one of the things we do to remove an external requirement.
+One can always just state that a particular `[[jsxgraph]]` block should use the official CDN version by setting `[[jsxgraph version="cdn"]]` or any specific other versions by tuning `[[jsxgraph overridecss="..." overridejs="..."]]` separately. Then, one can test if things work, but then one needs to check that one is testing with the correct version every time one tests...
\ No newline at end of file
From 868c615c1db9b7e0d3f1a01481750c305ddbf855 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Wed, 18 Dec 2024 10:13:42 +0000
Subject: [PATCH 02/20] code-tidy - Minor tweaks
---
stack/input/factory.class.php | 15 ++++-----------
stack/input/inputbase.class.php | 5 +----
tests/caskeyval_test.php | 9 +++++----
3 files changed, 10 insertions(+), 19 deletions(-)
diff --git a/stack/input/factory.class.php b/stack/input/factory.class.php
index 7b95ea30ecd..18ace7295dc 100644
--- a/stack/input/factory.class.php
+++ b/stack/input/factory.class.php
@@ -15,9 +15,11 @@
// along with Stack. If not, see .
/**
- * Add description here!
+ * Input factory.
+ * Provides a convenient way to create an input of any type,
+ * and to get metadata about the input types.
* @package qtype_stack
- * @copyright 2024 University of Edinburgh.
+ * @copyright 2012 University of Birmingham.
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
*/
@@ -26,15 +28,6 @@
require_once(__DIR__ . '/../options.class.php');
require_once(__DIR__ . '/inputbase.class.php');
-/**
- * Input factory. Provides a convenient way to create an input of any type,
- * and to get metadata about the input types.
- *
- * @package qtype_stack
- * @copyright 2012 University of Birmingham.
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
- */
-
// phpcs:ignore moodle.Commenting.MissingDocblock.Class
class stack_input_factory {
/**
diff --git a/stack/input/inputbase.class.php b/stack/input/inputbase.class.php
index 8bbe7e9b4be..75f5ceaf90c 100644
--- a/stack/input/inputbase.class.php
+++ b/stack/input/inputbase.class.php
@@ -17,7 +17,7 @@
/**
* Add description here!
* @package qtype_stack
- * @copyright 2024 University of Edinburgh.
+ * @copyright 2012 University of Birmingham
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
*/
@@ -34,9 +34,6 @@
* Inputs are the controls that the teacher can put into the question
* text to receive the student's response.
*
- * @package qtype_stack
- * @copyright 2012 University of Birmingham
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class stack_input {
// phpcs:ignore moodle.Commenting.MissingDocblock.Constant
diff --git a/tests/caskeyval_test.php b/tests/caskeyval_test.php
index 4a9d4ec860b..85289e79135 100644
--- a/tests/caskeyval_test.php
+++ b/tests/caskeyval_test.php
@@ -105,10 +105,11 @@ public function test_empty_case_1(): void {
$this->assertTrue($at1->get_valid());
}
- // Now here we have a problem, keyvals do not generate output values
- // they just load stuff to the session, therefore you cannot get
- // the instantiated values.
- // phpcs:ignore moodle.Commenting.MissingDocblock.Function
+ /**
+ * Now here we have a problem, keyvals do not generate output values
+ * they just load stuff to the session, therefore you cannot get
+ * the instantiated values.
+ */
public function test_equations_1(): void {
$at1 = new stack_cas_keyval('ta1 : x=1; ta2 : x^2-2*x=1; ta3:x=1 nounor x=2', null, 123);
From 7ce96fb75b9015cd16495617f0996c1cbfcf1698 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Wed, 18 Dec 2024 11:57:39 +0000
Subject: [PATCH 03/20] code-tidy - Run app behat tests in Moodle 4.5
---
.github/workflows/moodle-ci.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml
index 2edf159015d..3ad10cf7af8 100644
--- a/.github/workflows/moodle-ci.yml
+++ b/.github/workflows/moodle-ci.yml
@@ -42,6 +42,7 @@ jobs:
moodle-branch: 'MOODLE_405_STABLE'
database: 'pgsql'
maxima: 'SBCL'
+ moodle-app: true
- php: '8.2'
moodle-branch: 'MOODLE_404_STABLE'
database: 'pgsql'
From 6c544d4be72efc777546f00b6ee6aa84bfaaf400 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Wed, 18 Dec 2024 13:57:04 +0000
Subject: [PATCH 04/20] code-tidy - Make tweaks consistent
---
stack/input/factory.class.php | 6 +++++-
stack/input/inputbase.class.php | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/stack/input/factory.class.php b/stack/input/factory.class.php
index 18ace7295dc..5fcee553580 100644
--- a/stack/input/factory.class.php
+++ b/stack/input/factory.class.php
@@ -28,7 +28,11 @@
require_once(__DIR__ . '/../options.class.php');
require_once(__DIR__ . '/inputbase.class.php');
-// phpcs:ignore moodle.Commenting.MissingDocblock.Class
+/**
+ * Input factory.
+ * Provides a convenient way to create an input of any type,
+ * and to get metadata about the input types.
+ */
class stack_input_factory {
/**
* @var array type name => array of parameter names used. Used to cache the
diff --git a/stack/input/inputbase.class.php b/stack/input/inputbase.class.php
index 75f5ceaf90c..6652e27143a 100644
--- a/stack/input/inputbase.class.php
+++ b/stack/input/inputbase.class.php
@@ -15,7 +15,7 @@
// along with Stack. If not, see .
/**
- * Add description here!
+ * The base class for inputs in Stack.
* @package qtype_stack
* @copyright 2012 University of Birmingham
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later.
From 3682c2473d8972c4765a3b48773885873d592725 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Thu, 19 Dec 2024 09:53:03 +0000
Subject: [PATCH 05/20] iss1341 - Remove jQuery
---
templates/questionreport.mustache | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/templates/questionreport.mustache b/templates/questionreport.mustache
index c4db294c60b..ae082e45453 100644
--- a/templates/questionreport.mustache
+++ b/templates/questionreport.mustache
@@ -116,7 +116,7 @@
{{#str}} selectquiz, qtype_stack {{/str}}:
-
From cd193dfea6a1c4cb505db415818ef1f20fcb2ea1 Mon Sep 17 00:00:00 2001
From: Marcus Green
Date: Thu, 19 Dec 2024 12:50:20 +0000
Subject: [PATCH 06/20] Added title and alt tags to the string that is used in
the question editing form. I did not know what the icons meant as they are
not right next to the dropdown in the PRT part of the form.
---
lang/en/qtype_stack.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php
index 9b428830500..920f28d2fe8 100644
--- a/lang/en/qtype_stack.php
+++ b/lang/en/qtype_stack.php
@@ -336,8 +336,8 @@
$string['quiet'] = 'Quiet';
$string['quiet_help'] = 'When set to yes any feedback automatically generated by the answer tests is suppressed, and not displayed to the student. The feedback fields in the branches are unaffected by this option.';
// The icon fa-volume-off isn't very good really.
-$string['quiet_icon_true'] = '';
-$string['quiet_icon_false'] = '';
+$string['quiet_icon_true'] = '';
+$string['quiet_icon_false'] = '';
$string['renamequestionparts'] = 'Rename parts of the question';
$string['requiredfield'] = 'This field is required!';
$string['requirelowestterms'] = 'Require lowest terms';
From 1bb9a408a63573e7f3dcf9ab050e2a5c4223e4d3 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Fri, 20 Dec 2024 12:22:08 +0000
Subject: [PATCH 07/20] Rearrange links on the settings page, and add a note to
address PR #1339.
---
settings.php | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/settings.php b/settings.php
index 84b34e2986d..3266422e70d 100644
--- a/settings.php
+++ b/settings.php
@@ -32,8 +32,6 @@
$links = [
get_string('stackDoc_docs_desc', 'qtype_stack',
['link' => (string) new moodle_url('/question/type/stack/doc/doc.php/')]),
- get_string('healthcheck_desc', 'qtype_stack',
- ['link' => (string) new moodle_url('/question/type/stack/adminui/healthcheck.php')]),
get_string('chat_desc', 'qtype_stack',
['link' => (string) new moodle_url('/question/type/stack/adminui/caschat.php')]),
get_string('bulktestindexintro_desc', 'qtype_stack',
@@ -48,6 +46,9 @@
['link' => (string) new moodle_url('/question/type/stack/adminui/answertests.php')]),
get_string('stackInstall_input_title_desc', 'qtype_stack',
['link' => (string) new moodle_url('/question/type/stack/adminui/studentinputs.php')]),
+ // The healthcheck is not part of the adminui collection as it's only for plugin admins.
+ get_string('healthcheck_desc', 'qtype_stack',
+ ['link' => (string) new moodle_url('/question/type/stack/adminui/healthcheck.php')]),
];
$settings->add(new admin_setting_heading('docs',
From 60aad689a4901e8017f622cbc4333176b9a50b51 Mon Sep 17 00:00:00 2001
From: Edmund Farrow
Date: Fri, 20 Dec 2024 14:09:43 +0000
Subject: [PATCH 08/20] ci-again - Fix behat test
---
tests/behat/library.feature | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/behat/library.feature b/tests/behat/library.feature
index 74ef4d9e4b9..86f0da395ca 100644
--- a/tests/behat/library.feature
+++ b/tests/behat/library.feature
@@ -26,8 +26,8 @@ Feature: Test STACK library
Scenario: Import a question starting from question bank.
When I am on the "Course 1" "core_question > course question bank" page logged in as "teacher"
And I click on "Create a new question" "button"
- And I click on "STACK" "text"
- And I click on "[name$='submitbutton']" "css_element"
+ And I set the field "item_qtype_stack" to "1"
+ And I press "submitbutton"
And I click on "STACK question library" "link"
Then I should see "Test questions"
And I should not see "Question variables"
From e1874fedf14755c985aa146b0224e6c316e94603 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Fri, 20 Dec 2024 15:30:55 +0000
Subject: [PATCH 09/20] Support "allowempty" in dropdown, radio and checkbox
inputs. #1323.
---
doc/en/Authoring/Inputs/Input_options.md | 1 +
stack/input/checkbox/checkbox.class.php | 7 ++-
stack/input/dropdown/dropdown.class.php | 57 +++++++++++++++++-------
tests/input_checkbox_test.php | 33 +++++++++++++-
tests/input_dropdown_test.php | 31 +++++++++++++
tests/input_radio_test.php | 32 +++++++++++++
6 files changed, 142 insertions(+), 19 deletions(-)
diff --git a/doc/en/Authoring/Inputs/Input_options.md b/doc/en/Authoring/Inputs/Input_options.md
index be2e1da7f38..ba0f904c357 100644
--- a/doc/en/Authoring/Inputs/Input_options.md
+++ b/doc/en/Authoring/Inputs/Input_options.md
@@ -58,6 +58,7 @@ Normally a _blank_, i.e. empty, answer has a special status and are not consider
* String inputs will return the empty string `""` as an empty answer (to avoid a type-mismatch).
* Textarea inputs will return `[EMPTYANSWER]` to make sure the answer is always a list (to avoid a type-mismatch).
* Matrix inputs will return the correct size matrix filled with `null` atoms, e.g. `matrix([null,null],[null,null])`.
+* Checkbox inputs will return `[]` to make sure the answer is always a list (to avoid a type-mismatch).
We strongly recommend (with many years of experience) that teachers do not use this option without very careful thought!
diff --git a/stack/input/checkbox/checkbox.class.php b/stack/input/checkbox/checkbox.class.php
index 516d409b54b..3ce94c20842 100644
--- a/stack/input/checkbox/checkbox.class.php
+++ b/stack/input/checkbox/checkbox.class.php
@@ -53,7 +53,7 @@ public function contents_to_maxima($contents) {
foreach ($contents as $key) {
// ISS1211 - Moodle App returns value of 0 if box not checked but
// always safe to ignore 0 thanks to stack_dropdown_input->key_order().
- if ($key !== 0) {
+ if ($key !== 0 && $key != 'EMPTYANSWER') {
$vals[] = $this->get_input_ddl_value($key);
}
}
@@ -190,7 +190,7 @@ protected function ajax_to_response_array($in) {
public function response_to_contents($response) {
// Did the student chose the "Not answered" response?
if (array_key_exists($this->name.'_', $response)) {
- return [];
+ return [];
}
$contents = [];
foreach ($this->ddlvalues as $key => $val) {
@@ -198,6 +198,9 @@ public function response_to_contents($response) {
$contents[] = (int) $response[$this->name.'_'.$key];
}
}
+ if ($contents === [] && $this->get_extra_option('allowempty')) {
+ $contents[] = 'EMPTYANSWER';
+ }
return $contents;
}
diff --git a/stack/input/dropdown/dropdown.class.php b/stack/input/dropdown/dropdown.class.php
index 85af8c27d5b..b957492b63b 100644
--- a/stack/input/dropdown/dropdown.class.php
+++ b/stack/input/dropdown/dropdown.class.php
@@ -33,47 +33,46 @@ class stack_dropdown_input extends stack_input {
/**
* ddlvalues is an array of the types used.
+ * @var array
*/
- // phpcs:ignore moodle.Commenting.VariableComment.Missing
protected $ddlvalues = [];
/**
* ddltype must be one of 'select', 'checkbox' or 'radio'.
+ * @var string
*/
- // phpcs:ignore moodle.Commenting.VariableComment.Missing
protected $ddltype = 'select';
/**
* ddldisplay must be either 'LaTeX' or 'casstring' and it determines what is used for the displayed
* string the student uses. The default is LaTeX, but this doesn't always work in dropdowns.
+ * @var string
*/
- // phpcs:ignore moodle.Commenting.VariableComment.Missing
protected $ddldisplay = 'casstring';
/**
* Controls whether a "not answered" option is presented to the students.
+ * @var bool
*/
- // phpcs:ignore moodle.Commenting.VariableComment.Missing
protected $nonotanswered = true;
/**
* Controls the "not answered" message presented to the students.
+ * @var string
*/
- // phpcs:ignore moodle.Commenting.VariableComment.Missing
protected $notanswered = '';
/**
- * This holds the value of those
- * entries which the teacher has indicated are correct.
+ * This holds the value of those entries which the teacher has indicated are correct.
+ * @var string
*/
- // phpcs:ignore moodle.Commenting.VariableComment.Missing
protected $teacheranswervalue = '';
/**
* This holds a displayed form of $this->teacheranswer. We need to generate this from those
* entries which the teacher has indicated are correct.
+ * @var string
*/
- // phpcs:ignore moodle.Commenting.VariableComment.Missing
protected $teacheranswerdisplay = '';
// phpcs:ignore moodle.Commenting.MissingDocblock.Function
@@ -180,6 +179,7 @@ public function adapt_to_model_answer($teacheranswer) {
if ($this->options && $this->options->get_option('decimals') === ',') {
$decimal = ',';
}
+
foreach ($values as $distractor) {
$value = stack_utils::list_to_array($distractor, false);
$ddlvalue = [];
@@ -238,7 +238,7 @@ public function adapt_to_model_answer($teacheranswer) {
}
}
- if ($this->ddltype != 'checkbox' && $numbercorrect === 0) {
+ if ($this->ddltype != 'checkbox' && $numbercorrect === 0 && !$this->get_extra_option('allowempty')) {
$this->errors[] = stack_string('ddl_nocorrectanswersupplied');
return;
}
@@ -284,7 +284,11 @@ public function adapt_to_model_answer($teacheranswer) {
* of the correct responses. So, we create $this->teacheranswervalue to be a Maxima
* list of the values of those things the teacher said are correct.
*/
- if ($this->ddltype == 'checkbox') {
+ if ($numbercorrect === 0 && $this->get_extra_option('allowempty')) {
+ // This is an edge case.
+ $this->teacheranswervalue = 'EMPTYANSWER';
+ $this->teacheranswerdisplay = stack_string('teacheranswerempty');
+ } else if ($this->ddltype == 'checkbox') {
$this->teacheranswervalue = '['.implode(',', $correctanswer).']';
$this->teacheranswerdisplay = ''.'['.implode(',', $correctanswerdisplay).']'.'';
} else {
@@ -312,7 +316,7 @@ public function adapt_to_model_answer($teacheranswer) {
// At this point we do not want to do further simplification.
// If simp:true, it will have been set in the question and that is fine.
- // The other options are fine (and should be respects),
+ // The other options are fine (and should be respected),
// but the teacher's answer gets evaluated an extra time with default options,
// and this extra simplification breaks things.
if ($this->options === null) {
@@ -368,7 +372,13 @@ public function adapt_to_model_answer($teacheranswer) {
$teacheranswerdisplay[] = html_writer::tag('li', $ddlvalues[$key]['display']);
}
}
- $this->teacheranswerdisplay = html_writer::tag('ul', implode('', $teacheranswerdisplay));
+ if ($numbercorrect === 0 && $this->get_extra_option('allowempty')) {
+ // This is an edge case.
+ $this->teacheranswervalue = '[EMPTYANSWER]';
+ $this->teacheranswerdisplay = stack_string('teacheranswerempty');
+ } else {
+ $this->teacheranswerdisplay = html_writer::tag('ul', implode('', $teacheranswerdisplay));
+ }
$this->ddlvalues = $this->key_order($ddlvalues);
return;
@@ -380,8 +390,12 @@ private function key_order($values) {
// Make sure the array keys start at 1. This avoids
// potential confusion between keys 0 and ''.
if ($this->nonotanswered) {
+ $val = '';
+ if ($this->get_extra_option('allowempty')) {
+ $val = 'EMPTYANSWER';
+ }
$values = array_merge([
- '' => ['value' => '', 'display' => $this->notanswered, 'correct' => false],
+ '' => ['value' => $val, 'display' => $this->notanswered, 'correct' => false],
0 => null,
], $values);
} else {
@@ -630,6 +644,8 @@ public function response_to_contents($response) {
$contents = [];
if (array_key_exists($this->name, $response)) {
$contents[] = (int) $response[$this->name];
+ } else if ($this->get_extra_option('allowempty')) {
+ $contents[] = 'EMPTYANSWER';
}
return $contents;
}
@@ -653,13 +669,16 @@ protected function is_blank_response($contents) {
/**
* In this type we use the array keys in $this->ddlvalues within the HTML interactions,
- * not the CAS values. These next two methods map between the keys and the CAS values.
+ * not the CAS values. This method maps between the keys and the CAS values.
*/
protected function get_input_ddl_value($key) {
// Resolve confusion over null values in the key.
if (0 === $key || '0' === $key) {
$key = '';
}
+ if ($key === 'EMPTYANSWER') {
+ $key = '';
+ }
if (array_key_exists(trim($key), $this->ddlvalues)) {
return $this->ddlvalues[$key]['value'];
}
@@ -671,8 +690,14 @@ protected function get_input_ddl_value($key) {
return false;
}
- // phpcs:ignore moodle.Commenting.MissingDocblock.Function
+ /**
+ * In this type we use the array keys in $this->ddlvalues within the HTML interactions,
+ * not the CAS values. This method maps between the CAS values and the keys.
+ */
protected function get_input_ddl_key($value) {
+ if ($value === 'EMPTYANSWER') {
+ return '';
+ }
foreach ($this->ddlvalues as $key => $val) {
if ($val['value'] == $value) {
return $key;
diff --git a/tests/input_checkbox_test.php b/tests/input_checkbox_test.php
index 046ba5e8ffe..bf31267a802 100644
--- a/tests/input_checkbox_test.php
+++ b/tests/input_checkbox_test.php
@@ -583,4 +583,35 @@ public function test_decimals(): void {
'\(3{,}1415\)';
$this->assertEquals($expected, $el->get_teacher_answer_display(false, false));
}
-}
+
+ public function test_validate_student_response_with_allowempty(): void {
+ $options = new stack_options();
+ $ta = '[[A,false],[B,true],[C,false]]';
+ $el = stack_input_factory::make('checkbox', 'sans1', $ta, $options, ['options' => '']);
+ $el->set_parameter('options', 'allowempty');
+ $state = $el->validate_student_response(['sans1' => ''], $options, $ta, new stack_cas_security());
+ // In this case empty responses jump straight to score.
+ $this->assertEquals(stack_input::SCORE, $state->status);
+ $this->assertEquals('[]', $state->contentsmodified);
+ $this->assertEquals('\[ \left[ \right] \]', $state->contentsdisplayed);
+ $this->assertEquals('', $state->errors);
+ $this->assertEquals('A correct answer is:
' .
+ '\(B\)
',
+ $el->get_teacher_answer_display($state->contentsmodified, $state->contentsdisplayed));
+ }
+
+ public function test_validate_student_response_with_allowempty_nocorrect(): void {
+ $options = new stack_options();
+ // Normally teachers must have one correct answer.
+ $ta = '[[A,false],[B,false],[C,false]]';
+ $el = stack_input_factory::make('checkbox', 'sans1', $ta, $options, ['options' => '']);
+ $el->set_parameter('options', 'allowempty');
+ $state = $el->validate_student_response(['sans1' => ''], $options, $ta, new stack_cas_security());
+ // In this case empty responses jump straight to score.
+ $this->assertEquals(stack_input::SCORE, $state->status);
+ $this->assertEquals('[]', $state->contentsmodified);
+ $this->assertEquals('\[ \left[ \right] \]', $state->contentsdisplayed);
+ $this->assertEquals('', $state->errors);
+ $this->assertEquals('A correct answer is: This input can be left blank.',
+ $el->get_teacher_answer_display($state->contentsmodified, $state->contentsdisplayed));
+ }}
diff --git a/tests/input_dropdown_test.php b/tests/input_dropdown_test.php
index 94228dd7dd6..dfeb6c893a4 100644
--- a/tests/input_dropdown_test.php
+++ b/tests/input_dropdown_test.php
@@ -445,4 +445,35 @@ public function test_validate_student_response_castext(): void {
$state = $el->validate_student_response(['ans1' => '2'], $options, '2', new stack_cas_security());
$this->assertEquals(stack_input::SCORE, $state->status);
}
+
+ public function test_validate_student_response_with_allowempty(): void {
+ $options = new stack_options();
+ $ta = '[[A,false],[B,true],[C,false]]';
+ $el = stack_input_factory::make('dropdown', 'sans1', $ta, $options, ['options' => '']);
+ $el->set_parameter('options', 'allowempty');
+ $state = $el->validate_student_response(['sans1' => ''], $options, '2', new stack_cas_security());
+ // In this case empty responses jump straight to score.
+ $this->assertEquals(stack_input::SCORE, $state->status);
+ $this->assertEquals('EMPTYANSWER', $state->contentsmodified);
+ $this->assertEquals('', $state->contentsdisplayed);
+ $this->assertEquals('', $state->errors);
+ $this->assertEquals('A correct answer is: B',
+ $el->get_teacher_answer_display($state->contentsmodified, $state->contentsdisplayed));
+ }
+
+ public function test_validate_student_response_with_allowempty_nocorrect(): void {
+ $options = new stack_options();
+ // Normally teachers must have one correct answer.
+ $ta = '[[A,false],[B,false],[C,false]]';
+ $el = stack_input_factory::make('dropdown', 'sans1', $ta, $options, ['options' => '']);
+ $el->set_parameter('options', 'allowempty');
+ $state = $el->validate_student_response(['sans1' => ''], $options, '2', new stack_cas_security());
+ // In this case empty responses jump straight to score.
+ $this->assertEquals(stack_input::SCORE, $state->status);
+ $this->assertEquals('EMPTYANSWER', $state->contentsmodified);
+ $this->assertEquals('', $state->contentsdisplayed);
+ $this->assertEquals('', $state->errors);
+ $this->assertEquals('A correct answer is: This input can be left blank.',
+ $el->get_teacher_answer_display($state->contentsmodified, $state->contentsdisplayed));
+ }
}
diff --git a/tests/input_radio_test.php b/tests/input_radio_test.php
index 4d95874477b..e84cee814fd 100644
--- a/tests/input_radio_test.php
+++ b/tests/input_radio_test.php
@@ -540,4 +540,36 @@ public function test_decimals(): void {
'\(\left[ a ; b ; c ; 2{,}78 \right] \)';
$this->assertEquals($expected, $el->get_teacher_answer_display(false, false));
}
+
+ public function test_validate_student_response_with_allowempty(): void {
+ $options = new stack_options();
+ $ta = '[[A,false],[B,true],[C,false]]';
+ $el = stack_input_factory::make('radio', 'sans1', $ta, $options, ['options' => '']);
+ $el->set_parameter('options', 'allowempty');
+ $state = $el->validate_student_response(['sans1' => ''], $options, '3.14', new stack_cas_security());
+ // In this case empty responses jump straight to score.
+ $this->assertEquals(stack_input::SCORE, $state->status);
+ $this->assertEquals('EMPTYANSWER', $state->contentsmodified);
+ $this->assertEquals('', $state->contentsdisplayed);
+ $this->assertEquals('', $state->errors);
+ $this->assertEquals('A correct answer is:
' .
+ '\(B\)
',
+ $el->get_teacher_answer_display($state->contentsmodified, $state->contentsdisplayed));
+ }
+
+ public function test_validate_student_response_with_allowempty_nocorrect(): void {
+ $options = new stack_options();
+ // Normally teachers must have one correct answer.
+ $ta = '[[A,false],[B,false],[C,false]]';
+ $el = stack_input_factory::make('radio', 'sans1', $ta, $options, ['options' => '']);
+ $el->set_parameter('options', 'allowempty');
+ $state = $el->validate_student_response(['sans1' => ''], $options, '2', new stack_cas_security());
+ // In this case empty responses jump straight to score.
+ $this->assertEquals(stack_input::SCORE, $state->status);
+ $this->assertEquals('EMPTYANSWER', $state->contentsmodified);
+ $this->assertEquals('', $state->contentsdisplayed);
+ $this->assertEquals('', $state->errors);
+ $this->assertEquals('A correct answer is: This input can be left blank.',
+ $el->get_teacher_answer_display($state->contentsmodified, $state->contentsdisplayed));
+ }
}
From 00c1ab7321eb2e39ad77974b334946d67f7fbd20 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Fri, 20 Dec 2024 17:17:28 +0000
Subject: [PATCH 10/20] Update the docs.
---
doc/en/Developer/Development_track.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/doc/en/Developer/Development_track.md b/doc/en/Developer/Development_track.md
index f7b76d0a96b..92d94154222 100644
--- a/doc/en/Developer/Development_track.md
+++ b/doc/en/Developer/Development_track.md
@@ -9,6 +9,7 @@ We use the [github issue tracker](https://github.com/maths/moodle-qtype_stack/is
Done.
1. Introduce `ta` as the default teacher's answer in the question variables, and use this in the input and default prt.
+2. Support `allowrmpty` for dropdown, radio and checkbox inputs.
Issues with [github milestone 4.9.0](https://github.com/maths/moodle-qtype_stack/issues?q=is%3Aissue+milestone%3A4.9.0) include
From f55669a36e3db0a51f8e84fa0ad0139b5b1cf759 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Fri, 20 Dec 2024 18:52:49 +0000
Subject: [PATCH 11/20] WIP: first pass at support for non-commutative answer
test support.
---
doc/en/Authoring/Answer_Tests/Rule_based.md | 7 ++++++-
stack/maxima/noun_simp.mac | 23 ++++++++++++++++-----
tests/fixtures/answertestfixtures.class.php | 20 ++++++++++++++++++
3 files changed, 44 insertions(+), 6 deletions(-)
diff --git a/doc/en/Authoring/Answer_Tests/Rule_based.md b/doc/en/Authoring/Answer_Tests/Rule_based.md
index 1d4883367a8..67514317cdf 100644
--- a/doc/en/Authoring/Answer_Tests/Rule_based.md
+++ b/doc/en/Authoring/Answer_Tests/Rule_based.md
@@ -33,7 +33,7 @@ This is an advanced test.
This test allows question authors to create equivalence classes based on equality up to associativity and commutativity with the addition of optional rules. For example, the teacher can include the identity operations of addition and multiplication: \(0+ x\rightarrow x\) and \(1\times x\rightarrow x\). This makes it much easier to establish things like \(0-1\times i\) is equivalent to \(-i\). However, more general integer arithmetic is still not automatically included so \(2\times 3 \neq 6\).
-This test always assumes associativity and commutativity of addition and multiplication. Essentially this test extends the `EqualComAss` test by adding in additional rules. Without assumptions of commutativity and associativity we would need all sorts of additional rules, such as \(x+0 \rightarrow x\), since without commutativity this would not be captured by the rule `zeroAdd`, i.e. \(0+x \rightarrow x\). Furthermore, the way `EqualComAss` deals with unary minus and division make associativity and commutativity difficult to add in their pure form.
+This test always assumes associativity of addition and multiplication. By default the test assumes commutativity of addition and multiplication, but this can be dropped. Essentially this test extends the `EqualComAss` test by adding in additional rules. Without assumptions of commutativity and associativity we would need all sorts of additional rules, such as \(x+0 \rightarrow x\), since without commutativity this would not be captured by the rule `zeroAdd`, i.e. \(0+x \rightarrow x\). Furthermore, the way `EqualComAss` deals with unary minus and division make associativity and commutativity difficult to add in their pure form.
Each rule is a named function in Maxima, and each rule has an associated predicate function to decide if the rule is applicable at the top level of an expression. E.g. `zeroAddp(0+x)` would return `true` and `zeroAdd(0+x)` would return `x`.
@@ -46,6 +46,9 @@ The teacher must supply an option consisting of a list of the following rule nam
| `assMul` | Associativity of multiplication |
| `comAdd` | Commutativity of addition |
| `comMul` | Commutativity of multiplication |
+| - | _Options to switch off the defaults_ |
+| `noncomAdd` | Indicate addition is non-commutative |
+| `noncomMul` | Indicate multiplication is non-commutative |
| (`ID_TRANS`) | |
| `zeroAdd` | \(0+x \rightarrow x\) |
| `zeroMul` | \(0\times x \rightarrow 0\) |
@@ -78,6 +81,8 @@ However \(y-x\) is never ordered as \(-x+y\). Furthermore, \(-(x-y) \neq -x+y\)
Factoring out is better than distributing here, since in a produce such as \(-(x-y)(x-z)\) it is not clear which term in the product the initial minus sign will end up in.
Since `negOrd` is a factor command, it is incompatible with `negDist`.
+By default the test assumes commutativity of addition and multiplication. If you choose the `nonmulCom` rule then you can switch off commutativity of multiplication. However, rules such as `zeroMul` include both \(0\times x \rightarrow 0\) and \(x\times 0 \rightarrow 0\). The rules `intMul` (etc) would appear to be non-compatible with `nonmulCom`, however they are very useful in that by performing integer arithmetic we bring integers to the front of the expression.
+
For convenience sets of rules can be specified. E.g. you can use the name `ID_TRANS` in place of the list `[zeroAdd,zeroMul,oneMul,oneDiv,onePow,idPow,zeroPow,zPow]` to include all of the basic identity operators.
If you want to remove tests from a list you can use code such as `delete(zeroAdd, ID_TRANS)`.
diff --git a/stack/maxima/noun_simp.mac b/stack/maxima/noun_simp.mac
index 326374cb121..4b1971dedf7 100644
--- a/stack/maxima/noun_simp.mac
+++ b/stack/maxima/noun_simp.mac
@@ -60,8 +60,10 @@ infix("noundiv", 122, 123);
infix("nounpow", 140, 139);
prefix("UNARY_RECIP", 100);
+/* This is now an option.
declare("nounmul", commutative);
declare("nounadd", commutative);
+*/
/* (2) */
load("noun_arith.lisp");
@@ -143,7 +145,7 @@ flatten_recurse_nouns(ex) := block(
sort_nouns(ex) := block([exl],
if atom(ex) then return(ex),
exl:maplist(sort_nouns, args(ex)),
- if safe_op(ex)="nouneq" or safe_op(ex)="nounand" or safe_op(ex)="nounor" or safe_op(ex)="nounnot" or safe_op(ex)="nounset" or op(ex)="nounadd" or op(ex)="nounmul" then
+ if safe_op(ex)="nouneq" or safe_op(ex)="nounand" or safe_op(ex)="nounor" or safe_op(ex)="nounnot" or safe_op(ex)="nounset" then
exl:sort(exl),
apply(op(ex), exl)
)$
@@ -253,6 +255,15 @@ ATEqualComAssRules(sa, sb, so) :=
debugtest:true,
so:delete(testdebug, so)
) else debugtest:false,
+ /* Support non-commutative * and +. */
+ if ev(elementp(noncomAdd, setify(so)), simp) then block(
+ so:delete(comAdd, so),
+ so:delete(noncomAdd, so)
+ ),
+ if ev(elementp(noncomMul, setify(so)), simp) then block(
+ so:delete(comMul, so),
+ so:delete(noncomMul, so)
+ ),
if not(all_listp(lambda([ex], ev(elementp(ex, setify(ALL_TRANS)), simp) ), so)) then
return([false,false,StackAddNote("", "ATEqualComAssRules_Opt_Wrong"), StackAddFeedback("", "ATEqualComAssRules_Opt_List")]),
@@ -298,7 +309,7 @@ NEG_TRANS:[negNeg, negDiv, negOrd]$
INT_ARITH:[intAdd, intMul, intPow]$
DIV_TRANS:[recipMul, divDiv, divCancel]$
-ALL_TRANS:append(ALG_TRANS, ID_TRANS, NEG_TRANS, DIV_TRANS, INT_ARITH, [intFac, negDist, sqrtRem])$
+ALL_TRANS:append(ALG_TRANS, ID_TRANS, NEG_TRANS, DIV_TRANS, INT_ARITH, [intFac, negDist, sqrtRem, noncomAdd, noncomMul])$
/* Set up a hash table of functions and their corresponding predicate. */
ALL_TRANSP[assAdd] : assAddp$
@@ -623,10 +634,12 @@ sqrtRem(ex):=first(ex) nounpow (1 nounmul UNARY_RECIP(2))$
/**********************************************************/
/* We either have an integer, or "UNARY_MINUS * integer". */
noun_simp_integerp(ex) := if atom(ex) then integerp(ex) else
- if (safe_op(ex)="*" or safe_op(ex)="nounmul") and length(args(ex))=2 and part(ex, 1)=UNARY_MINUS and atom(part(ex, 2)) and integerp(part(ex, 2)) then true else false$
+ if (safe_op(ex)="*" or safe_op(ex)="nounmul") and length(args(ex))=2 and
+ ((part(ex, 1)=UNARY_MINUS and atom(part(ex, 2)) and integerp(part(ex, 2)))
+ or (part(ex, 2)=UNARY_MINUS and atom(part(ex, 1)) and integerp(part(ex, 1)))) then true else false$
notnoun_simp_integerp(ex):=not(noun_simp_integerp(ex))$
-/* Evaluate integer arithmetic */
+/* Evaluate integer arithmetic. */
intAddp(ex):= block(
if not(safe_op(ex)="+" or safe_op(ex)="nounadd") then return(false),
if length(sublist(args(ex), noun_simp_integerp))>1 then return(true) else return(false)
@@ -643,7 +656,7 @@ intAdd(ex) := block([a1, a2],
intMulp(ex):= block(
if not(safe_op(ex)="*" or safe_op(ex)="nounmul") then return(false),
- if length(sublist(args(ex), integerp))>1 then return(true) else return(false)
+ if length(sublist(args(ex), noun_simp_integerp))>1 then return(true) else return(false)
)$
intMul(ex) := block([a1, a2],
if intMulp(ex)=false then return(ex),
diff --git a/tests/fixtures/answertestfixtures.class.php b/tests/fixtures/answertestfixtures.class.php
index b6f263d8062..5dec3d5065e 100644
--- a/tests/fixtures/answertestfixtures.class.php
+++ b/tests/fixtures/answertestfixtures.class.php
@@ -1044,11 +1044,25 @@ class stack_answertest_test_data {
['EqualComAssRules', '[testdebug,zeroAdd]', '1+1', '2', 0, 'ATEqualComAssRules: [1 nounadd 1,2].', ''],
['EqualComAssRules', '[zeroAdd]', '0+a', 'a', 1, '', ''],
['EqualComAssRules', '[zeroAdd]', 'a+0', 'a', 1, '', ''],
+ // Confirm basic operations are commutative and associative.
+ ['EqualComAssRules', '[zeroAdd]', 'a+b', 'b+a', 1, '', ''],
+ ['EqualComAssRules', '[zeroAdd]', 'a+(b+c)', '(a+b)+c', 1, '', ''],
+ ['EqualComAssRules', '[zeroAdd]', 'a*b', 'b*a', 1, '', ''],
+ ['EqualComAssRules', '[zeroAdd]', 'a*(b*c)', '(a*b)*c', 1, '', ''],
+ ['EqualComAssRules', '[noncomAdd]', 'a+b', 'b+a', 0, '', ''],
+ ['EqualComAssRules', '[noncomAdd]', 'a+(b+c)', '(a+b)+c', 1, '', ''],
+ ['EqualComAssRules', '[noncomMul]', 'a*b', 'b*a', 0, '', ''],
+ ['EqualComAssRules', '[noncomMul]', 'a*(b*c)', '(a*b)*c', 1, '', ''],
+ ['EqualComAssRules', '[noncomMul]', '-a*b', 'b*-a', 0, '', ''],
+ ['EqualComAssRules', '[noncomMul]', '-a/b', 'a/-b', 0, '', ''],
['EqualComAssRules', '[testdebug,zeroAdd]', '1*a', 'a', 0, 'ATEqualComAssRules: [1 nounmul a,a].', ''],
// This is a common example where EqualComAss is not adequate.
['EqualComAssRules', '[zeroAdd]', '1/2*sin(3*x)', 'sin(3*x)/2', 0, '', ''],
['EqualComAssRules', '[oneMul]', '1/2*sin(3*x)', 'sin(3*x)/2', 1, '', ''],
['EqualComAssRules', '[oneMul]', '1*a', 'a', 1, '', ''],
+ ['EqualComAssRules', '[oneMul,noncomMul]', '1*a', 'a*1', 1, '', ''],
+ ['EqualComAssRules', '[zeroMul,noncomMul]', '0*a', '0', 1, '', ''],
+ ['EqualComAssRules', '[zeroMul,noncomMul]', 'a*0', '0', 1, '', ''],
['EqualComAssRules', 'ID_TRANS', '1*a', 'a', 1, '', ''],
['EqualComAssRules', 'ID_TRANS', 'a/1', 'a', 1, '', ''],
['EqualComAssRules', 'ID_TRANS', '0*a', '0', 1, '', ''],
@@ -1072,7 +1086,9 @@ class stack_answertest_test_data {
['EqualComAssRules', 'ID_TRANS', '0^(1-1)', '0', 0, 'ATEqualComAssRules_STACKERROR_SAns.', ''],
['EqualComAssRules', 'delete(zeroMul, ID_TRANS)', '0*a', '0', 0, '', ''],
['EqualComAssRules', '[negNeg]', '-(-a)', 'a', 1, '', ''],
+ ['EqualComAssRules', '[negNeg,noncomMul]', '-(-a)', 'a', 1, '', ''],
['EqualComAssRules', '[negNeg]', '-(-(-a))', '-a', 1, '', ''],
+ ['EqualComAssRules', '[negNeg,noncomMul]', '-(-(-a))', '-a', 1, '', ''],
['EqualComAssRules', '[testdebug,negNeg]', '-(-(-a))', 'a', 0, 'ATEqualComAssRules (AlgEquiv-false).', ''],
['EqualComAssRules', 'ID_TRANS', '3/(-x)', '-3/x', 0, '', ''],
[
@@ -1148,6 +1164,10 @@ class stack_answertest_test_data {
'ATEqualComAssRules: [UNARY_MINUS nounmul UNARY_MINUS nounmul 21 nounmul x nounmul x,21 nounmul x nounmul x].', '',
],
['EqualComAssRules', '[ID_TRANS,intMul,negNeg]', '(-7*x)*(-3*x)', '21*x*x', 1, '', ''],
+ ['EqualComAssRules', '[noncomMul,intMul]', '2*a*3', '6*a', 1, '', ''],
+ ['EqualComAssRules', '[noncomMul,intMul]', '2*a*3', 'a*6', 0, '', ''],
+ ['EqualComAssRules', '[noncomMul,intMul]', 'a*6', '6*a', 0, '', ''],
+ ['EqualComAssRules', '[noncomMul,intMul]', 'A^2+2*A*B+B^2', 'B^2+A*2*B+A^2', 0, '', ''],
// This next example is parsing rules. In Maxima ev(a/b/c, simp)=a/(b*c).
[
'EqualComAssRules', '[testdebug,ID_TRANS]', 'a/b/c', 'a/(b*c)', 0,
From dbf8805a01513210af9151a9c0568b33a437de4e Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Fri, 20 Dec 2024 19:40:13 +0000
Subject: [PATCH 12/20] Support for non-commutative mul and add in rules-based
answer test.
---
stack/maxima/noun_simp.mac | 17 ++++++++++-------
tests/fixtures/answertestfixtures.class.php | 10 +++++-----
2 files changed, 15 insertions(+), 12 deletions(-)
diff --git a/stack/maxima/noun_simp.mac b/stack/maxima/noun_simp.mac
index 4b1971dedf7..f22822a8a90 100644
--- a/stack/maxima/noun_simp.mac
+++ b/stack/maxima/noun_simp.mac
@@ -632,11 +632,10 @@ sqrtRem(ex):=first(ex) nounpow (1 nounmul UNARY_RECIP(2))$
/**********************************************************/
-/* We either have an integer, or "UNARY_MINUS * integer". */
-noun_simp_integerp(ex) := if atom(ex) then integerp(ex) else
- if (safe_op(ex)="*" or safe_op(ex)="nounmul") and length(args(ex))=2 and
- ((part(ex, 1)=UNARY_MINUS and atom(part(ex, 2)) and integerp(part(ex, 2)))
- or (part(ex, 2)=UNARY_MINUS and atom(part(ex, 1)) and integerp(part(ex, 1)))) then true else false$
+/* We either have an integer, UNARY_MINUS
+ or "UNARY_MINUS * integer". */
+noun_simp_integerp(ex) := if atom(ex) then (is(ex=UNARY_MINUS) or integerp(ex)) else
+ if (safe_op(ex)="*" or safe_op(ex)="nounmul") and length(args(ex))=2 and part(ex, 1)=UNARY_MINUS and atom(part(ex, 2)) and integerp(part(ex, 2)) then true else false$
notnoun_simp_integerp(ex):=not(noun_simp_integerp(ex))$
/* Evaluate integer arithmetic. */
@@ -654,9 +653,13 @@ intAdd(ex) := block([a1, a2],
else apply(op(ex),append([a1], a2))
)$
-intMulp(ex):= block(
+intMulp(ex):= block([sl],
if not(safe_op(ex)="*" or safe_op(ex)="nounmul") then return(false),
- if length(sublist(args(ex), noun_simp_integerp))>1 then return(true) else return(false)
+ sl:sublist(args(ex), noun_simp_integerp),
+ if emptyp(sl) then return(false),
+ if length(sl)=1 then return(not(is(first(args(ex))=first(sl)))),
+ if length(sl)=2 then return(not((is(first(sl)=UNARY_MINUS) and integerp(second(sl))))),
+ return(true)
)$
intMul(ex) := block([a1, a2],
if intMulp(ex)=false then return(ex),
diff --git a/tests/fixtures/answertestfixtures.class.php b/tests/fixtures/answertestfixtures.class.php
index 5dec3d5065e..979865d467f 100644
--- a/tests/fixtures/answertestfixtures.class.php
+++ b/tests/fixtures/answertestfixtures.class.php
@@ -1160,14 +1160,14 @@ class stack_answertest_test_data {
['EqualComAssRules', '[ID_TRANS,intAdd]', '(3-5)*x+x', '-2*x+x', 1, '', ''],
['EqualComAssRules', '[ID_TRANS,intMul]', '7*x*(-3*x)', '-21*x*x', 1, '', ''],
[
- 'EqualComAssRules', '[testdebug,ID_TRANS,intMul]', '(-7*x)*(-3*x)', '21*x*x', 0,
- 'ATEqualComAssRules: [UNARY_MINUS nounmul UNARY_MINUS nounmul 21 nounmul x nounmul x,21 nounmul x nounmul x].', '',
+ 'EqualComAssRules', '[testdebug,ID_TRANS,intMul]', '(-7*x)*(-3*x)', '21*x*x', 1,
+ 'ATEqualComAssRules: [21 nounmul x nounmul x,21 nounmul x nounmul x].', '',
],
['EqualComAssRules', '[ID_TRANS,intMul,negNeg]', '(-7*x)*(-3*x)', '21*x*x', 1, '', ''],
['EqualComAssRules', '[noncomMul,intMul]', '2*a*3', '6*a', 1, '', ''],
- ['EqualComAssRules', '[noncomMul,intMul]', '2*a*3', 'a*6', 0, '', ''],
- ['EqualComAssRules', '[noncomMul,intMul]', 'a*6', '6*a', 0, '', ''],
- ['EqualComAssRules', '[noncomMul,intMul]', 'A^2+2*A*B+B^2', 'B^2+A*2*B+A^2', 0, '', ''],
+ ['EqualComAssRules', '[noncomMul,intMul]', '2*a*3', 'a*6', 1, '', ''],
+ ['EqualComAssRules', '[noncomMul,intMul]', 'a*6', '6*a', 1, '', ''],
+ ['EqualComAssRules', '[noncomMul,intMul]', 'A^2+2*A*B+B^2', 'B^2+A*2*B+A^2', 1, '', ''],
// This next example is parsing rules. In Maxima ev(a/b/c, simp)=a/(b*c).
[
'EqualComAssRules', '[testdebug,ID_TRANS]', 'a/b/c', 'a/(b*c)', 0,
From 2e931c80d5a01c3ed12d001227f15b2dc2fb3e45 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Fri, 20 Dec 2024 21:47:16 +0000
Subject: [PATCH 13/20] Fix ATComAss.
---
stack/maxima/noun_simp.mac | 44 +++++++++++++++++++++++++++-----------
1 file changed, 32 insertions(+), 12 deletions(-)
diff --git a/stack/maxima/noun_simp.mac b/stack/maxima/noun_simp.mac
index f22822a8a90..e147556e911 100644
--- a/stack/maxima/noun_simp.mac
+++ b/stack/maxima/noun_simp.mac
@@ -60,7 +60,7 @@ infix("noundiv", 122, 123);
infix("nounpow", 140, 139);
prefix("UNARY_RECIP", 100);
-/* This is now an option.
+/* These are no longer needed.
declare("nounmul", commutative);
declare("nounadd", commutative);
*/
@@ -142,14 +142,6 @@ flatten_recurse_nouns(ex) := block(
apply(op(ex), maplist(flatten_recurse_nouns, args(ex)))
)$
-sort_nouns(ex) := block([exl],
- if atom(ex) then return(ex),
- exl:maplist(sort_nouns, args(ex)),
- if safe_op(ex)="nouneq" or safe_op(ex)="nounand" or safe_op(ex)="nounor" or safe_op(ex)="nounnot" or safe_op(ex)="nounset" then
- exl:sort(exl),
- apply(op(ex), exl)
-)$
-
/* Rule which takes (a^n)^-1 when n is an integer to a^-n */
flatten_pow_minus_one(ex):= block(
if not(safe_op(ex)="nounpow") then return(ex),
@@ -180,6 +172,34 @@ equals_commute_prepare(ex):=block([ex1n],
return(ex1n)
)$
+sort_nouns(ex) := block([exl],
+ if atom(ex) then return(ex),
+ exl:maplist(sort_nouns, args(ex)),
+ if safe_op(ex)="nouneq" or safe_op(ex)="nounand" or safe_op(ex)="nounor" or safe_op(ex)="nounnot" or safe_op(ex)="nounset" or safe_op(ex)="nounadd" or safe_op(ex)="nounmul" then
+ exl:sort(exl),
+ apply(op(ex), exl)
+)$
+
+/* Add in a version in which *, + are not assumed commuatative. */
+equals_commute_prepare_noncom(ex):=block([ex1n],
+ /* We need to strip out any internal simplification. */
+ ex1n:parse_string(string(ex)),
+
+ ex1n:subst(nounset, set, ex1n),
+ ex1n:noun_arith_full(ex1n),
+ ex1n:flatten_recurse_nouns(ex1n),
+ ex1n:sort_nouns_noncom(ex1n),
+ return(ex1n)
+)$
+
+sort_nouns_noncom(ex) := block([exl],
+ if atom(ex) then return(ex),
+ exl:maplist(sort_nouns_noncom, args(ex)),
+ if safe_op(ex)="nouneq" or safe_op(ex)="nounand" or safe_op(ex)="nounor" or safe_op(ex)="nounnot" or safe_op(ex)="nounset" then
+ exl:sort(exl),
+ apply(op(ex), exl)
+)$
+
/* Returns true iff ex1 and ex2 are equal up to commutativity and associativity. */
equals_commute_associate(ex1, ex2) := block([oldsimp, ret, ex1n, ex2n],
oldsimp:simp,
@@ -283,9 +303,9 @@ ATEqualComAssRules(sa, sb, so) :=
if ret[2]=false then
(ret[3]:StackAddNote("ATEqualComAssRules (AlgEquiv-false)", StackTrimNote(ret[3])), return([false, ret[2], ret[3], ""])),
- /* Put the expressions in basic form. */
- SAA:equals_commute_prepare(SAA),
- SBB:equals_commute_prepare(SBB),
+ /* Put the expressions in basic form (non-com version). */
+ SAA:equals_commute_prepare_noncom(SAA),
+ SBB:equals_commute_prepare_noncom(SBB),
if debug then print(["Transforming", SAA]),
SAA:transl(SAA, so),
From a1d117928421cf495e5c43c47bf3ae61d7e13542 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Fri, 27 Dec 2024 13:12:27 +0000
Subject: [PATCH 14/20] Refactor maxima code to prepare expressions for
rules-based simplifier.
---
doc/en/Authoring/Answer_Tests/Rule_based.md | 33 ++++++++++++++-
stack/maxima/noun_simp.mac | 45 ++++++++-------------
stack/maxima/rtest_noun_simp.mac | 4 +-
3 files changed, 50 insertions(+), 32 deletions(-)
diff --git a/doc/en/Authoring/Answer_Tests/Rule_based.md b/doc/en/Authoring/Answer_Tests/Rule_based.md
index 67514317cdf..f4d885c27c4 100644
--- a/doc/en/Authoring/Answer_Tests/Rule_based.md
+++ b/doc/en/Authoring/Answer_Tests/Rule_based.md
@@ -115,9 +115,38 @@ Imagine the teacher's answer is \(\frac{\sin(3x)}{2}\) but a student types in \(
This functionality was introduced in April 2021. It is essential that the rules, and any combination of the rules, can only proceed in a single direction and that no infinite loops are created. So, `intAdd` is fine because adding together two integers will make an expression _simpler_ which in this case is shorter. For this reason we do not have expanding out (i.e. distribution) rules in the above set, and no rules of indices (which readily lead to mathematical errors). Use Maxima's simplifier if you want to include such rules.
-The rules names are Maxima functions, but they assume `simp:false` and that the expression has noun forms e.g. `nounadd` instead of `+`. You can use `equals_commute_prepare(ex)` to change an expression into this noun form. The goal of this code is to create reliable equivalence classes of expressions, not perform algebraic manipulation as we traditionally know it. In particular the way unary minus is transformed into multiplication with a special tag `UNARY_MINUS` is likely to cause confusion to students if an expression is manipulated using these rules and then shown to a student. The transformation is designed to go in one direction only, and we do not support displaying the resulting manipulated expressions in traditional form.
+STACK creates parallel operators for `*`, `+` etc. so that we have full control over the simplifier and the rules in play. E.g. `nounadd` instead of `+`.
-__As of May 2023, these rules are not intended as an end-user simplifier and we do not currently support user-defined rules (sorry!).__
+You can use `equals_commute_prepare(ex)` to change an expression into this noun form. An optional second argument `equals_commute_prepare(ex, sc)` is the set of operators considered commutative, e.g. typically a subset of `{"nouneq", "nounand", "nounor", "nounset", "nounadd", "nounmul"}`.
+
+The simplifier rules assume `simp:false` and that the expression has noun forms.
+The simplifier rule names are Maxima functions with exactly the names shown above.
+Each rule has a predicate function, which decides if the rule can be applied _at the top level of the expression_. E.g. `oneMulp(1*x)` is true, but `oneMulp(2+(1*x))` is false because the `1*x` is not at the top level of the expression `2+(1*x)`, it's deeper within the expression.
+
+To deal with unary minus we transform it into multiplication with a special tag `UNARY_MINUS`. For example `-x` becomes `UNARY_MINUS * x`. This approach looks odd at first, but does not confuse `UNARY_MINUS` with the integer \(-1\) or the unary function "minus". In this way multiple unary minus operations commute to the front of an expression. E.g. `(-x)*(-y) = UNARY_MINUS * UNARY_MINUS * x * y` (when `*` is assumed to be commutative and associative, of course!)
+
+Similarly, division is also conveted to `UNARY_RECIP`. E.g. `(-x)/(-y) = UNARY_MINUS nounmul UNARY_RECIP(UNARY_MINUS nounmul y) nounmul x`.
+
+
+We the use the rule `negDiv` to pull out the unary minus outside the devision (pulls `UNARY_MINUS` outside `UNARY_RECIP`), but we also need the rules `assMul` (associativity) and `comMul` (commutativity). E.g. try the following in the STACK-maxima sandbox.
+
+ ex:(-x)/(-y);
+ ex:equals_commute_prepare(ex);
+ transl(ex,[assMul, comMul, negDiv]);
+
+This results in `UNARY_MINUS nounmul UNARY_MINUS nounmul x nounmul UNARY_RECIP(y)`, literally `- * - * x * 1/y`. We could also include the rule `negNeg` to remove the double minus.
+
+ transl(ex,[assMul, comMul, negDiv, negNeg]);
+
+gives `x nounmul UNARY_RECIP(y)`.
+
+The goal of this code is to create reliable equivalence classes of expressions, not perform algebraic manipulation as we traditionally know it. In particular the use of `UNARY_MINUS` and `UNARY_RECIP` are likely to cause confusion to students if an expression is manipulated using these rules and then shown to a student. The function `verb_arith` removes all the noun forms used by this simplfier, translating the expression back to core Maxima functions. Note however that `UNARY_MINUS` and `UNARY_RECIP(ex)` are replaced by `(-1)*` and `ex^(-1)` respectively. E.g. `verb_arith(UNARY_MINUS nounmul x nounmul UNARY_RECIP(y)) = (-1)*x*y^-1`.
+
+The simplfier is designed to go in one direction only to establish membership of an equivalence class. We do not (as of Dec 2024) support displaying the resulting manipulated expressions in traditional form.
+
+The code is all in `stack/maxima/noun_simp.mac`.
+
+__We do not currently support user-defined rules (sorry!).__
# See also
diff --git a/stack/maxima/noun_simp.mac b/stack/maxima/noun_simp.mac
index e147556e911..3201a886445 100644
--- a/stack/maxima/noun_simp.mac
+++ b/stack/maxima/noun_simp.mac
@@ -161,42 +161,31 @@ unary_minus_remove(ex):= block([exl],
apply("nounmul", exl)
)$
-equals_commute_prepare(ex):=block([ex1n],
- /* We need to strip out any internal simplification. */
- ex1n:parse_string(string(ex)),
-
- ex1n:subst(nounset, set, ex1n),
- ex1n:noun_arith_full(ex1n),
- ex1n:flatten_recurse_nouns(ex1n),
- ex1n:sort_nouns(ex1n),
- return(ex1n)
-)$
-
-sort_nouns(ex) := block([exl],
- if atom(ex) then return(ex),
- exl:maplist(sort_nouns, args(ex)),
- if safe_op(ex)="nouneq" or safe_op(ex)="nounand" or safe_op(ex)="nounor" or safe_op(ex)="nounnot" or safe_op(ex)="nounset" or safe_op(ex)="nounadd" or safe_op(ex)="nounmul" then
- exl:sort(exl),
- apply(op(ex), exl)
-)$
+/*
+ Prepare an expression for rules based simplifier.
+ ex: expression to be prepared.
+ opcomset: list of operators (as strings) to be considered commutative.
+ e.g. {"nouneq", "nounand", "nounor", "nounset", "nounadd", "nounmul"}
+*/
+equals_commute_prepare([ex]):=block([ex1n, opcomset],
-/* Add in a version in which *, + are not assumed commuatative. */
-equals_commute_prepare_noncom(ex):=block([ex1n],
/* We need to strip out any internal simplification. */
- ex1n:parse_string(string(ex)),
+ ex1n:parse_string(string(first(ex))),
+
+ opcomset:{"nouneq", "nounand", "nounor", "nounset", "nounadd", "nounmul"},
+ if length(ex)>1 then opcomset:second(ex),
ex1n:subst(nounset, set, ex1n),
ex1n:noun_arith_full(ex1n),
ex1n:flatten_recurse_nouns(ex1n),
- ex1n:sort_nouns_noncom(ex1n),
+ ex1n:sort_nouns(ex1n, opcomset),
return(ex1n)
)$
-sort_nouns_noncom(ex) := block([exl],
+sort_nouns(ex, opcomset) := block([exl],
if atom(ex) then return(ex),
- exl:maplist(sort_nouns_noncom, args(ex)),
- if safe_op(ex)="nouneq" or safe_op(ex)="nounand" or safe_op(ex)="nounor" or safe_op(ex)="nounnot" or safe_op(ex)="nounset" then
- exl:sort(exl),
+ exl:maplist(lambda([ex2], sort_nouns(ex2, opcomset)), args(ex)),
+ if ev(elementp(safe_op(ex), opcomset),simp) then exl:sort(exl),
apply(op(ex), exl)
)$
@@ -304,8 +293,8 @@ ATEqualComAssRules(sa, sb, so) :=
(ret[3]:StackAddNote("ATEqualComAssRules (AlgEquiv-false)", StackTrimNote(ret[3])), return([false, ret[2], ret[3], ""])),
/* Put the expressions in basic form (non-com version). */
- SAA:equals_commute_prepare_noncom(SAA),
- SBB:equals_commute_prepare_noncom(SBB),
+ SAA:equals_commute_prepare(SAA, {"nouneq", "nounand", "nounor", "nounset"}),
+ SBB:equals_commute_prepare(SBB, {"nouneq", "nounand", "nounor", "nounset"}),
if debug then print(["Transforming", SAA]),
SAA:transl(SAA, so),
diff --git a/stack/maxima/rtest_noun_simp.mac b/stack/maxima/rtest_noun_simp.mac
index 39430fe6497..0db7551dcb8 100644
--- a/stack/maxima/rtest_noun_simp.mac
+++ b/stack/maxima/rtest_noun_simp.mac
@@ -145,9 +145,9 @@ intFac(7);
intFac(18);
2 nounmul 3 nounpow 2$
-equals_commute_prepare((a/b)/c);
+equals_commute_prepare((a/b)/c, {"nouneq", "nounand", "nounor", "nounnot", "nounset", "nounadd", "nounmul"});
a nounmul (UNARY_RECIP(b)) nounmul (UNARY_RECIP(c))$
-equals_commute_prepare(a/(b/c));
+equals_commute_prepare(a/(b/c), {"nouneq", "nounand", "nounor", "nounnot", "nounset", "nounadd", "nounmul"});
a nounmul UNARY_RECIP(b nounmul UNARY_RECIP(c))$
divDivp(a nounmul UNARY_RECIP(b nounmul UNARY_RECIP(c)))$
From ef0e52723fe74b52236747c36de86c9f109a0419 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Sat, 28 Dec 2024 09:32:05 +0000
Subject: [PATCH 15/20] Update the docs on display of fractions.
---
doc/en/Authoring/Question_options.md | 12 ++++++++++--
doc/en/Topics/Units.md | 4 +++-
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/doc/en/Authoring/Question_options.md b/doc/en/Authoring/Question_options.md
index 7eee340471a..8926c5f0c38 100644
--- a/doc/en/Authoring/Question_options.md
+++ b/doc/en/Authoring/Question_options.md
@@ -168,11 +168,15 @@ See the entry on [matrices](../CAS/Matrix.md#matrixparens).
### Inline and displayed fractions. ###
-The display of fractions can take two forms: inline \( 1/x \) and displayed \( \frac{1}{x} \).
+There are three ways to display fractions.
+
+1. displayed \( \frac{1}{x} \);
+2. inline \( 1/x \);
+3. negative powers \( x^{-1} \).
The default behaviour is displayed, i.e. using LaTeX `\frac{}{}`.
-The function `stack_disp_fractions(ex)` can be used to control the display.
+The function `stack_disp_fractions(ex)` can be used to control the display globally within a question.
* `stack_disp_fractions("i")` switches display to inline.
* `stack_disp_fractions("d")` switches display to display.
@@ -180,3 +184,7 @@ The function `stack_disp_fractions(ex)` can be used to control the display.
Note, for CASText the display is controlled by the prevailing setting at the moment the text is displayed, not when a variable is defined in the question variables. Hence, if you would like a single inline fraction within a CASText you will need to use
Normally fractions are displayed {@1/x@}. This switches to inline {@(stack_disp_fractions("i"), 1/x)@}, which persists {@1/a@}. Switch explicitly back to displayed {@(stack_disp_fractions("d"),1/x)@}.
+
+For scientific units we also have an input "extra option" `negpow` for student's input to be displayed as negative powers, e.g. \(m\,s^{-1}\).
+
+We do not, currently, have support for global display of fractions using negative powers (Dec 2024). This is because the difference between displayed and inline fractions is purely notational, involving the TeX output from the division operator. Converting division to negative powers is a mathematical re-write rule and is therefore significantly more complicated. E.g. we would have to decide how to display \( \frac{1}{x^{-2}} \).
\ No newline at end of file
diff --git a/doc/en/Topics/Units.md b/doc/en/Topics/Units.md
index 437c015d6f0..e3d27cb5aa4 100644
--- a/doc/en/Topics/Units.md
+++ b/doc/en/Topics/Units.md
@@ -67,7 +67,9 @@ Note, the input does not currently support a situation where you want to accept
The extra options to the input should be a comma separated list of tags. This input type makes use of the additional options in two ways:
-1. Units can be displayed using inline fractions \(m/s\) or negative powers \(m\,s^{-1}\). Add `negpow` to the Extra Options field to use negative powers.
+1. Units can be displayed using inline fractions \(\frac{m}{s}\) (by default fractions are displayed, not inline) or negative powers \(m\,s^{-1}\). Add `negpow` to the Extra Options field to use negative powers.
+
+See the question options entry on [inline and displayed fractions](../Authoring/Question_options.md).
## Answer tests ##
From 8bef817bca6e6b76100905b6508b3798d54b6ba6 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Thu, 2 Jan 2025 17:48:33 +0000
Subject: [PATCH 16/20] Update the documentation, and add in test cases.
---
doc/en/Authoring/Answer_Tests/Equivalence.md | 4 ++--
doc/en/Authoring/Inputs/index.md | 2 ++
doc/en/CAS/Numbers.md | 1 +
stack/cas/cassecurity.class.php | 3 +--
tests/fixtures/answertestfixtures.class.php | 4 ++++
5 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/doc/en/Authoring/Answer_Tests/Equivalence.md b/doc/en/Authoring/Answer_Tests/Equivalence.md
index f6b4940f9ea..9b1d770f96f 100644
--- a/doc/en/Authoring/Answer_Tests/Equivalence.md
+++ b/doc/en/Authoring/Answer_Tests/Equivalence.md
@@ -32,9 +32,9 @@ This is the most commonly used test. The pseudo code
This test will work with a variety of [types of object](../../CAS/Maxima_background.md#Types_of_object) of mathematical objects, including lists, sets, equations, inequalities and matrices.
* This test disregards whether [simplification](../../CAS/Simplification.md) is switched on, it always fully simplifies all its arguments.
-* Use `AlgEquiv(predicate(ex),true)` with [predicate functions](../../CAS/Predicate_functions.md).
+* Use `AlgEquiv(predicate(ex),true)` with [predicate functions](../../CAS/Predicate_functions.md), to test the result is true. But note that many predicate functions only work without simplification! In particular, testing for "form" such as scientific notation need `simp:false`, in which case use `EqualComAss(predicate(ex),true)` rather than algebraic equivalence.
-Note: exactly what it does depends on what objects are given to it. In particular the pseudo code above only applies to expressions. We cannot subtract one list or set from another, so we have to use other tests.
+Note: exactly what this answer test does depends on what objects are given to it. In particular the pseudo code above only applies to expressions. We cannot subtract one list or set from another, so we have to use other tests.
For sets, the CAS tries to write the expression in a canonical form. It then compares the string representations these forms to remove duplicate elements and compare sets. This is subtly different from trying to simplify the difference of two expressions to zero. For example, imagine we have \(\{(x-a)^{6000}\}\) and \(\{(a-x)^{6000}\}\). One canonical form is to expand out both sides. While this work in principal, in practice this is much too slow for assessment.
diff --git a/doc/en/Authoring/Inputs/index.md b/doc/en/Authoring/Inputs/index.md
index f8051cb1ae4..1dec0f84764 100644
--- a/doc/en/Authoring/Inputs/index.md
+++ b/doc/en/Authoring/Inputs/index.md
@@ -109,6 +109,8 @@ There are groups of common keywords which you can forbid simply as
* `[[BASIC-CALCULUS]]` common calculus operations such as `int`, `diff`, `taylor`, etc.
* `[[BASIC-MATRIX]]` common matrix operations such as `transpose`, `invert`, `charpoly`, etc.
+These list are hard-wired into the code [here](https://github.com/maths/moodle-qtype_stack/blob/master/stack/cas/cassecurity.class.php#L56).
+
If you have suggestions for more lists, or additional operations which should be added to the existing lists, please contact the developers.
diff --git a/doc/en/CAS/Numbers.md b/doc/en/CAS/Numbers.md
index ed536b1fe65..75ed1faa056 100644
--- a/doc/en/CAS/Numbers.md
+++ b/doc/en/CAS/Numbers.md
@@ -155,3 +155,4 @@ The following commands generate displayed forms of numbers. These will not be m
| `anyfloatex(ex)` | Decides if any floats are in the expression.
| `scientific_notationp(ex)` | Determines if \(ex\) is written in the form \(a10^n\) where \(a\) is an integer or float, and \(n\) is an integer.
+Please note that these predicate functions need to be used with `simp:false`. Some answer tests, including the default algebraic equivalence (`ATAlgEquiv`) always simplify their arguments. Instead use a non-simplifying answer test such as `EqualComAss`.
diff --git a/stack/cas/cassecurity.class.php b/stack/cas/cassecurity.class.php
index ece45b76d51..ead8634ca4d 100644
--- a/stack/cas/cassecurity.class.php
+++ b/stack/cas/cassecurity.class.php
@@ -49,8 +49,7 @@ class stack_cas_security {
/**
* These lists are used by question authors for groups of words.
- * They should be lower case, because Maxima is lower case, and these correspond to Maxima names.
- * Actually, not lower case, Maxima is not case insensitive just check "ModeMatrix" for an example.
+ * These correspond to Maxima names, and should follow the case in maxima, e.g. "ModeMatrix" for an example.
*/
public static $keywordlists = [
'[[basic-algebra]]' => [
diff --git a/tests/fixtures/answertestfixtures.class.php b/tests/fixtures/answertestfixtures.class.php
index 35a6682942c..3a3cd5d7858 100644
--- a/tests/fixtures/answertestfixtures.class.php
+++ b/tests/fixtures/answertestfixtures.class.php
@@ -75,6 +75,7 @@ class stack_answertest_test_data {
['AlgEquiv', '', 'lowesttermsp(-y/-x)', 'true', 1, 'ATLogic_True.', ''],
['AlgEquiv', '', 'lowesttermsp((x^2-1)/(x-1))', 'true', 0, '', ''],
['AlgEquiv', '', 'lowesttermsp((x^2-1)/(x+2))', 'true', 1, 'ATLogic_True.', ''],
+ ['AlgEquiv', '', 'scientific_notationp(4.1561*10^16)', 'true', 0, '', ''],
['AlgEquiv', '', 'X', 'x', 0, 'ATAlgEquiv_WrongCase.', 'Case sensitivity'],
['AlgEquiv', '', '1/(R-r)', '1', 0, '', ''],
['AlgEquiv', '', 'exdowncase(X)', 'x', 1, '', ''],
@@ -997,6 +998,9 @@ class stack_answertest_test_data {
['EqualComAss', '', 'lowesttermsp((x^2-1)/(x-1))', 'true', 0, 'ATEqualComAss (AlgEquiv-false).', ''],
['EqualComAss', '', 'lowesttermsp((x^2-1)/(x+2))', 'true', 1, '', ''],
+ ['EqualComAss', '', 'scientific_notationp(1/3)', 'true', 0, 'ATEqualComAss (AlgEquiv-false).', ''],
+ ['EqualComAss', '', 'scientific_notationp(4.1561*10^16)', 'true', 1, '', ''],
+
// We can't use ATAlgEquiv with rationalized as Maxima simplified sqrt(3)/3 to 1/sqrt(3).
['EqualComAss', '', 'rationalized(1+sqrt(3)/3)', 'true', 1, '', 'Bad things in denominators'],
['EqualComAss', '', 'rationalized(1+1/sqrt(3))', '[sqrt(3)]', 1, '', ''],
From 629f2efdb194f59130a9c30e8a53e671772ffc90 Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Thu, 2 Jan 2025 18:15:35 +0000
Subject: [PATCH 17/20] Fix to issue #1348: add in a supported list to forbid
trig functions in student input.
---
doc/en/Authoring/Inputs/index.md | 1 +
stack/cas/cassecurity.class.php | 7 +++++++
tests/ast_container_test.php | 1 +
3 files changed, 9 insertions(+)
diff --git a/doc/en/Authoring/Inputs/index.md b/doc/en/Authoring/Inputs/index.md
index 1dec0f84764..710627e35cd 100644
--- a/doc/en/Authoring/Inputs/index.md
+++ b/doc/en/Authoring/Inputs/index.md
@@ -106,6 +106,7 @@ If you wish to forbid commas, then escape it with a backslash.
There are groups of common keywords which you can forbid simply as
* `[[BASIC-ALGEBRA]]` common algebraic operations such as `simplify`, `factor`, `expand`, `solve`, etc.
+* `[[BASIC-TRIG]]` names of all the trig and hyperbolic trig functions and their inverses, e.g. `sin`, `asin`, `sinh`, `asinh`, etc.
* `[[BASIC-CALCULUS]]` common calculus operations such as `int`, `diff`, `taylor`, etc.
* `[[BASIC-MATRIX]]` common matrix operations such as `transpose`, `invert`, `charpoly`, etc.
diff --git a/stack/cas/cassecurity.class.php b/stack/cas/cassecurity.class.php
index 4bae6bec984..92d5410108a 100644
--- a/stack/cas/cassecurity.class.php
+++ b/stack/cas/cassecurity.class.php
@@ -73,6 +73,13 @@ class stack_cas_security {
'trigrat' => true, 'trigreduce' => true, 'trigsign' => true,
'trigsimp' => true, 'truncate' => true, 'decimalplaces' => true, 'simplify' => true,
],
+ '[[basic-trig]]' => [
+ 'sin' => true, 'cos' => true, 'tan' => true, 'sec' => true, 'csc' => true, 'cot' => true,
+ 'asin' => true, 'acos' => true, 'atan' => true, 'asec' => true, 'acsc' => true, 'acot' => true,
+ 'atan2' => true,
+ 'sinh' => true, 'cosh' => true, 'tanh' => true, 'sech' => true, 'csch' => true, 'coth' => true,
+ 'asinh' => true, 'acosh' => true, 'atanh' => true, 'asech' => true, 'acsch' => true, 'acoth' => true,
+ ],
'[[basic-calculus]]' => [
'defint' => true, 'diff' => true, 'int' => true, 'integrate' => true,
'limit' => true, 'partial' => true, 'desolve' => true, 'express' => true, 'taylor' => true,
diff --git a/tests/ast_container_test.php b/tests/ast_container_test.php
index fe2bbf1f491..db73f7e55f3 100644
--- a/tests/ast_container_test.php
+++ b/tests/ast_container_test.php
@@ -324,6 +324,7 @@ public function test_check_external_forbidden_words_literal(): void {
['[x,y,z]', 'b,\,,c', false],
['diff(x^2,x)', '[[BASIC-CALCULUS]]', false], // From lists.
['solve((x-6)^4,x)', '[[BASIC-ALGEBRA]]', false], // From lists.
+ ['sin(A-B)', '[[BASIC-TRIG]]', false], // From lists.
];
foreach ($cases as $case) {
From b215c6e4f32a386347baa86fc71b5c9cd77fecac Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Fri, 3 Jan 2025 10:58:06 +0000
Subject: [PATCH 18/20] Update assessment.mac
Fix spelling mistake.
---
stack/maxima/assessment.mac | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/stack/maxima/assessment.mac b/stack/maxima/assessment.mac
index 1a3634019de..083e30529eb 100644
--- a/stack/maxima/assessment.mac
+++ b/stack/maxima/assessment.mac
@@ -542,7 +542,7 @@ polarform_simp(ex) := block([%_r, %_theta, %_pf,simp],
*/
-/* Decides if an expression is precicely of the form a*10^n, where a is an integer, or a float, and n is an integer. */
+/* Decides if an expression is precisely of the form a*10^n, where a is an integer, or a float, and n is an integer. */
scientific_notationp(ex) := block([tn],
if not(safe_op(ex)="*") then return(false),
if not(length(args(ex))=2) then return(false),
From ee4d67e41abea1a324882edf4d64163601ecf10a Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Mon, 6 Jan 2025 13:23:25 +0000
Subject: [PATCH 19/20] Fix to issue #1349: support for display of spaces in
TeX output of integers.
---
doc/en/CAS/Numbers.md | 23 ++++++++++++-----------
stack/maxima/stackmaxima.mac | 9 ++++++---
tests/cassession2_test.php | 3 +++
tests/castext_test.php | 29 +++++++++++++++++++++++++++++
4 files changed, 50 insertions(+), 14 deletions(-)
diff --git a/doc/en/CAS/Numbers.md b/doc/en/CAS/Numbers.md
index 75ed1faa056..115f1ba4152 100644
--- a/doc/en/CAS/Numbers.md
+++ b/doc/en/CAS/Numbers.md
@@ -87,18 +87,19 @@ To force all floating point numbers to decimal floating point numbers use
You can also force all integers to be displayed as floating point decimals or in scientific notation using `stackintfmt` and the appropriate template. This function calls the LISP `format` function, which is complex and more example are available [online](http://www.gigamonkeys.com/book/a-few-format-recipes.html) elsewhere.
-| Template | Input | TeX Output | Description/notes
+| Template | Input | TeX Output | Description/notes
| ----------- | ----------- | ---------------- | ----------------------------------------------------------------------------------------------
-| `"~,4f"` | `0.12349` | \(0.1235\) | Output four decimal places: floating point.
-| | `0.12345` | \(0.1234\) | Note the rounding.
-| | `0.12` | \(0.1200\) |
-| `"~,5e"` | `100.34` | \(1.00340e+2\) | Output five decimal places: scientific notation.
-| `"~:d"` | `10000000` | \(10,000,000\) | Separate decimal groups of three digits with commas.
-| `~r` | `9` | \(\text{nine}\) | Rhetoric.
-| `~:r` | `9` | \(\text{ninth}\) | Ordinal rhetoric.
-| `~7r` | `9` | \(12\) | Base 7.
-| `~@r` | `9` | \(IX\) | Roman numerals.
-| `~:@r` | `9` | \(VIIII\) | Old style Roman numerals.
+| `"~,4f"` | `0.12349` | \(0.1235\) | Output four decimal places: floating point.
+| | `0.12345` | \(0.1234\) | Note the rounding.
+| | `0.12` | \(0.1200\) |
+| `"~,5e"` | `100.34` | \(1.00340e+2\) | Output five decimal places: scientific notation.
+| `"~:d"` | `10000000` | \(10,000,000\) | Separate decimal groups of three digits with commas.
+| `"~,,\' ,:d"` | `10000000` | \(10\ 000\ 000\) | Separate decimal groups of three digits with spaces.
+| `~r` | `9` | \(\text{nine}\) | Rhetoric.
+| `~:r` | `9` | \(\text{ninth}\) | Ordinal rhetoric.
+| `~7r` | `9` | \(12\) | Base 7.
+| `~@r` | `9` | \(IX\) | Roman numerals.
+| `~:@r` | `9` | \(VIIII\) | Old style Roman numerals.
There are many other options within the LISP format command. Please note with the rhetoric and Roman numerals that the numbers will be in LaTeX mathematics environments.
diff --git a/stack/maxima/stackmaxima.mac b/stack/maxima/stackmaxima.mac
index a9d368e0381..eb547430d98 100644
--- a/stack/maxima/stackmaxima.mac
+++ b/stack/maxima/stackmaxima.mac
@@ -768,8 +768,11 @@ texput_decimal(ex):= stackfltsep:ex$
) else if ev(integerp(x),simp) then (
if (is(stackintfmt="~r") or is(stackintfmt="~:r")) then
tx:sconcat("\\text{",ev(printf(false, stackintfmt, x), simp),"}")
- else
- tx:ev(printf(false, stackintfmt, x), simp)
+ else (
+ /* Protect spaces in integers so TeX (MathJax) displays them as spaces. */
+ tx:ev(printf(false, stackintfmt, x), simp),
+ tx:ssubst("\\ ", " ", tx)
+ )
) else
tx:string(x),
/* We need this separation because validation displays trailing zeros and this is controlled by stackfltfmt. */
@@ -777,7 +780,7 @@ texput_decimal(ex):= stackfltsep:ex$
tx:ssubst("\\ ", ",", tx),
tx:ssubst("{,}", ".", tx)
),
- tx
+ tx
)$
/* Some systems are throwing an error here, which is spurious. */
errcatch(compile(?texnumformat));
diff --git a/tests/cassession2_test.php b/tests/cassession2_test.php
index 89531ddb378..6228a5dbb6e 100644
--- a/tests/cassession2_test.php
+++ b/tests/cassession2_test.php
@@ -2101,6 +2101,9 @@ public function test_stack_stackintfmt(): void {
$cases[] = ['(stackintfmt:"~r",n0)', '73', '\text{seventy-three}'];
$cases[] = ['(stackintfmt:"~:r",n0)', '73', '\text{seventy-third}'];
$cases[] = ['(stackintfmt:"~@R",n0)', '73', 'LXXIII'];
+ $cases[] = ['(stackintfmt:"~:d",123456789)', '123456789', '123,456,789'];
+ $cases[] = ['(stackintfmt:"~,,\',,:d",123456789)', '123456789', '123,456,789'];
+ $cases[] = ['(stackintfmt:"~,,\' ,:d",123456789)', '123456789', '123\\ 456\\ 789'];
foreach ($cases as $i => $case) {
$s = 'n' . $i . ':' . $case[0];
diff --git a/tests/castext_test.php b/tests/castext_test.php
index 6209660a73a..fb872fadd1b 100644
--- a/tests/castext_test.php
+++ b/tests/castext_test.php
@@ -1546,6 +1546,35 @@ public function test_numerical_display_commas(): void {
$at2->get_rendered());
}
+ /**
+ * Fix to issue #1349, actually display spaces in TeX output.
+ * @covers \qtype_stack\stack_cas_castext2_latex
+ */
+ public function test_numerical_display_group_spaces(): void {
+
+ $st = 'The number {@10000000@} is written with spaces. ';
+ $st .= 'Sets {@{1200, 45678}@} and lists {@[1200, 45678]@}';
+
+ $a2 = ['stackintfmt:"~,,\' ,:d"'];
+ $s2 = [];
+ foreach ($a2 as $s) {
+ $s2[] = stack_ast_container::make_from_teacher_source($s, '', new stack_cas_security(), []);
+ }
+ $cs2 = new stack_cas_session2($s2, null, 0);
+
+ $at2 = castext2_evaluatable::make_from_source($st, 'test-case');
+
+ $this->assertTrue($at2->get_valid());
+ $cs2->add_statement($at2);
+ $cs2->instantiate();
+
+ $this->assertEquals(
+ 'The number \({10\\ 000\\ 000}\) is written with spaces. ' .
+ 'Sets \({\left \{1\\ 200 , 45\\ 678 \right \}}\) ' .
+ 'and lists \({\left[ 1\\ 200 , 45\\ 678 \right]}\)',
+ $at2->get_rendered());
+ }
+
/**
* Add description here.
* @covers \qtype_stack\stack_cas_castext2_latex
From 48104f536344aadaab1e392bdc01f6c72079390c Mon Sep 17 00:00:00 2001
From: Chris Sangwin
Date: Mon, 6 Jan 2025 17:48:17 +0000
Subject: [PATCH 20/20] Separate out admin-only links in the settings page.
---
settings.php | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/settings.php b/settings.php
index 3266422e70d..68cef402c4a 100644
--- a/settings.php
+++ b/settings.php
@@ -28,7 +28,7 @@
require_once(__DIR__ . '/stack/options.class.php');
require_once(__DIR__ . '/stack/prt.class.php');
-// Useful links.
+// Useful links in the adminui.
$links = [
get_string('stackDoc_docs_desc', 'qtype_stack',
['link' => (string) new moodle_url('/question/type/stack/doc/doc.php/')]),
@@ -46,14 +46,19 @@
['link' => (string) new moodle_url('/question/type/stack/adminui/answertests.php')]),
get_string('stackInstall_input_title_desc', 'qtype_stack',
['link' => (string) new moodle_url('/question/type/stack/adminui/studentinputs.php')]),
- // The healthcheck is not part of the adminui collection as it's only for plugin admins.
+];
+
+// These links are only for plugin admins, e.g. the healthcheck. They are not part of the adminui collection.
+$link = '* ' . implode("\n* ", $links);
+$link .= "\n***\n";
+$links = [
get_string('healthcheck_desc', 'qtype_stack',
- ['link' => (string) new moodle_url('/question/type/stack/adminui/healthcheck.php')]),
+ ['link' => (string) new moodle_url('/question/type/stack/adminui/healthcheck.php')])
];
+$link .= '* ' . implode("\n* ", $links);
$settings->add(new admin_setting_heading('docs',
- get_string('settingusefullinks', 'qtype_stack'),
- '* ' . implode("\n* ", $links)));
+ get_string('settingusefullinks', 'qtype_stack'), $link));
// Options for connection to Maxima.
// Note that any settings here where we try to set the default