From e1874fedf14755c985aa146b0224e6c316e94603 Mon Sep 17 00:00:00 2001 From: Chris Sangwin Date: Fri, 20 Dec 2024 15:30:55 +0000 Subject: [PATCH] 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 be2e1da7f3..ba0f904c35 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 516d409b54..3ce94c2084 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 85af8c27d5..b957492b63 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 046ba5e8ff..bf31267a80 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: ', + $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 94228dd7dd..dfeb6c893a 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 4d95874477..e84cee814f 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: ', + $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)); + } }