Skip to content

Commit

Permalink
Support "allowempty" in dropdown, radio and checkbox inputs. #1323.
Browse files Browse the repository at this point in the history
  • Loading branch information
sangwinc committed Dec 20, 2024
1 parent 56d3e21 commit e1874fe
Show file tree
Hide file tree
Showing 6 changed files with 142 additions and 19 deletions.
1 change: 1 addition & 0 deletions doc/en/Authoring/Inputs/Input_options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down
7 changes: 5 additions & 2 deletions stack/input/checkbox/checkbox.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -190,14 +190,17 @@ 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) {
if (array_key_exists($this->name.'_'.$key, $response)) {
$contents[] = (int) $response[$this->name.'_'.$key];
}
}
if ($contents === [] && $this->get_extra_option('allowempty')) {
$contents[] = 'EMPTYANSWER';
}
return $contents;
}

Expand Down
57 changes: 41 additions & 16 deletions stack/input/dropdown/dropdown.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 = '<code>'.'['.implode(',', $correctanswerdisplay).']'.'</code>';
} else {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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'];
}
Expand All @@ -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;
Expand Down
33 changes: 32 additions & 1 deletion tests/input_checkbox_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -583,4 +583,35 @@ public function test_decimals(): void {
'\(3{,}1415\)</span></span></li></ul>';
$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: <ul><li><span class="filter_mathjaxloader_equation">' .
'<span class="nolink">\(B\)</span></span></li></ul>',
$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));
}}
31 changes: 31 additions & 0 deletions tests/input_dropdown_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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: <code>B</code>',
$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));
}
}
32 changes: 32 additions & 0 deletions tests/input_radio_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -540,4 +540,36 @@ public function test_decimals(): void {
'\(\left[ a ; b ; c ; 2{,}78 \right] \)</span></span></li></ul>';
$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: <ul><li><span class="filter_mathjaxloader_equation">' .
'<span class="nolink">\(B\)</span></span></li></ul>',
$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));
}
}

0 comments on commit e1874fe

Please sign in to comment.