From 20be88236eb53623701be7eb0e71b3ba0a925ab4 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 2 May 2024 21:29:04 +0100 Subject: [PATCH] Implement response analysis methods That is get_possible_responses in the question type class and classify_response in the question classes. Co-authored-by: Mahmoud Kassaei --- lang/en/qtype_oumatrix.php | 1 + question.php | 90 ++++++++++++++++++++++++++++++++ questiontype.php | 60 +++++++++++++++------ tests/question_multiple_test.php | 74 ++++++++++++++++++++++++++ tests/question_single_test.php | 68 ++++++++++++++++++++++++ tests/questiontype_test.php | 79 +++++++++++++++++++++++++++- 6 files changed, 354 insertions(+), 18 deletions(-) diff --git a/lang/en/qtype_oumatrix.php b/lang/en/qtype_oumatrix.php index 1a4509a..f867f92 100644 --- a/lang/en/qtype_oumatrix.php +++ b/lang/en/qtype_oumatrix.php @@ -69,6 +69,7 @@ $string['rowshdr'] = 'Matrix rows (sub-questions)'; $string['rowanswerlist'] = 'Select answers'; $string['rowx'] = 'Row{$a})'; +$string['selected'] = 'Selected'; $string['shuffleanswers'] = 'Shuffle the items?'; $string['shuffleanswers_desc'] = 'Whether options should be randomly shuffled for each attempt by default.'; $string['shuffleanswers_help'] = 'If enabled, the order of the row items is randomly shuffled for each attempt, provided that "Shuffle within questions" in the activity settings is also enabled.'; diff --git a/question.php b/question.php index 0364f54..b666500 100644 --- a/question.php +++ b/question.php @@ -248,6 +248,51 @@ public function is_complete_response(array $response): bool { return true; } + public function classify_response(array $response): array { + $classifiedresponse = []; + foreach ($this->roworder as $key => $rownumber) { + $row = $this->rows[$rownumber]; + $partname = format_string($row->name); + if (!array_key_exists($this->field($key), $response)) { + $classifiedresponse[$partname] = question_classified_response::no_response(); + continue; + } + + $selectedcolumn = $this->columns[$response[$this->field($key)]]; + $classifiedresponse[$partname] = new question_classified_response( + $selectedcolumn->number, + format_string($selectedcolumn->name), + (int) array_key_exists($response[$this->field($key)], $row->correctanswers), + ); + } + + return $classifiedresponse; + } + + public function prepare_simulated_post_data($simulatedresponse): array { + // Expected structure of $simulatedresponse is Row field name => Col name. + // Each row must be present, in order. + $postdata = []; + $subquestions = array_keys($simulatedresponse); + $answers = array_values($simulatedresponse); + + foreach ($this->roworder as $key => $rownumber) { + $row = $this->rows[$rownumber]; + if ($row->name !== $subquestions[$key]) { + continue; + } + if ($key === ($row->number - 1) && $row->name === $subquestions[$key]) { + foreach ($this->columns as $column) { + if ($column->name !== $answers[$key]) { + continue; + } + $postdata[$this->field($key)] = $column->number; + } + } + } + return $postdata; + } + public function grade_response(array $response): array { // Retrieve the number of right responses and the total number of responses. [$numrightparts, $total] = $this->get_num_parts_right($response); @@ -367,6 +412,51 @@ public function is_complete_response(array $response): bool { return true; } + public function classify_response(array $response) { + $classifiedresponse = []; + foreach ($this->roworder as $rowkey => $rownumber) { + $row = $this->rows[$rownumber]; + $rowname = format_string($row->name); + + foreach ($this->columns as $column) { + if ($this->is_choice_selected($response, $rowkey, $column->number)) { + $classifiedresponse[$rowname . ': ' . format_string($column->name)] = + new question_classified_response( + 1, + get_string('selected', 'qtype_oumatrix'), + array_key_exists($column->number, $row->correctanswers) / count($row->correctanswers), + ); + } + } + } + + return $classifiedresponse; + } + + public function prepare_simulated_post_data($simulatedresponse): array { + $postdata = []; + $subquestions = array_keys($simulatedresponse); + $answers = array_values($simulatedresponse); + foreach ($this->roworder as $key => $rowid) { + $row = $this->rows[$rowid]; + $rowanswers = $answers[$key]; + if ($key === ($row->number - 1) && $row->name === $subquestions[$key]) { + foreach ($this->columns as $colid => $column) { + // Set the field to '0' initially. + $postdata[$this->field($key, $column->number)] = '0'; + foreach ($rowanswers as $colnumber => $colname) { + if ($row->name === $subquestions[$key] && + $column->number === $colnumber && $column->name === $colname) { + // Set the field to '1' if it has been ticked.. + $postdata[$this->field($key, $column->number)] = '1'; + } + } + } + } + } + return $postdata; + } + public function grade_response(array $response): array { // Retrieve the number of right responses and the total number of responses. if ($this->grademethod == 'allnone') { diff --git a/questiontype.php b/questiontype.php index 0af0711..4fc4064 100644 --- a/questiontype.php +++ b/questiontype.php @@ -316,30 +316,56 @@ public function get_num_correct_choices(stdClass $questiondata): int { } public function get_possible_responses($questiondata) { - if ($questiondata->options->single) { - $responses = []; + if ($questiondata->options->inputtype == 'single') { + return $this->get_possible_responses_single($questiondata); + } else { + return $this->get_possible_responses_multiple($questiondata); + } + } - // TODO: Sort out this funtion to work with rows and columns, etc. - foreach ($questiondata->options->answers as $aid => $answer) { - $responses[$aid] = new question_possible_response( - question_utils::to_plain_text($answer->answer, $answer->answerformat), - $answer->fraction); + /** + * Do the radio button case of get_possible_responses. + * + * @param stdClass $questiondata the question definition data. + * @return array as for get_possible_responses. + */ + protected function get_possible_responses_single(stdClass $questiondata): array { + $parts = []; + foreach ($questiondata->rows as $row) { + $responses = []; + foreach ($questiondata->columns as $column) { + $responses[$column->number] = new question_possible_response( + format_string($column->name), + (int) ($column->number == $row->correctanswers) + ); } - $responses[null] = question_possible_response::no_response(); - return [$questiondata->id => $responses]; - } else { - $parts = []; + $parts[format_string($row->name)] = $responses; + } + return $parts; + } - foreach ($questiondata->options->answers as $aid => $answer) { - $parts[$aid] = [ - $aid => new question_possible_response(question_utils::to_plain_text( - $answer->answer, $answer->answerformat), $answer->fraction), + /** + * Do the checkbox button case of get_possible_responses. + * + * @param stdClass $questiondata the question definition data. + * @return array as for get_possible_responses. + */ + protected function get_possible_responses_multiple(stdClass $questiondata): array { + $parts = []; + foreach ($questiondata->rows as $row) { + $rowname = format_string($row->name); + $correctanswer = explode(',', $row->correctanswers); + foreach ($questiondata->columns as $column) { + $parts[$rowname . ': ' . format_string($column->name)] = [ + 1 => new question_possible_response( + get_string('selected', 'qtype_oumatrix'), + in_array($column->number, $correctanswer) / count($correctanswer), + ), ]; } - - return $parts; } + return $parts; } public function import_from_xml($data, $question, qformat_xml $format, $extra = null) { diff --git a/tests/question_multiple_test.php b/tests/question_multiple_test.php index dab1c8d..09a341f 100644 --- a/tests/question_multiple_test.php +++ b/tests/question_multiple_test.php @@ -18,6 +18,7 @@ use question_attempt_step; use question_state; +use question_classified_response; defined('MOODLE_INTERNAL') || die(); @@ -100,6 +101,79 @@ public function test_is_gradable_response(): void { $this->assertTrue($question->is_gradable_response($response), $question->is_complete_response($response)); } + public function test_classify_response_multiple(): void { + $this->resetAfterTest(); + $question = \test_question_maker::make_question('oumatrix', 'food_multiple'); + $question->shuffleanswers = 0; + $question->start_attempt(new question_attempt_step(), 1); + + // Test a correct response. + $response = $question->prepare_simulated_post_data([ + 'Proteins' => [1 => 'Chicken breast', 3 => 'Salmon fillet', 6 => 'Steak'], + 'Vegetables' => [2 => 'Carrot', 4 => 'Asparagus', 7 => 'Potato'], + 'Fats' => [5 => 'Olive oil']]); + $this->assertEquals([ + "Proteins: Chicken breast" => new question_classified_response(1, 'Selected', 1 / 3), + "Proteins: Salmon fillet" => new question_classified_response(1, 'Selected', 1 / 3), + "Proteins: Steak" => new question_classified_response(1, 'Selected', 1 / 3), + "Vegetables: Carrot" => new question_classified_response(1, 'Selected', 1 / 3), + "Vegetables: Asparagus" => new question_classified_response(1, 'Selected', 1 / 3), + "Vegetables: Potato" => new question_classified_response(1, 'Selected', 1 / 3), + "Fats: Olive oil" => new question_classified_response(1, 'Selected', 1), + ], $question->classify_response($response)); + + // Test a partial response. + $response = $question->prepare_simulated_post_data([ + 'Proteins' => [1 => 'Chicken breast', 4 => 'Asparagus'], + 'Vegetables' => [2 => 'Carrot', 1 => 'Chicken breast'], + 'Fats' => [5 => 'Olive oil']]); + $this->assertEquals([ + "Proteins: Chicken breast" => new question_classified_response(1, 'Selected', 1 / 3), + "Proteins: Asparagus" => new question_classified_response(1, 'Selected', 0), + "Vegetables: Chicken breast" => new question_classified_response(1, 'Selected', 0), + "Vegetables: Carrot" => new question_classified_response(1, 'Selected', 1 / 3), + "Fats: Olive oil" => new question_classified_response(1, 'Selected', 1), + ], $question->classify_response($response)); + } + + public function test_prepare_simulated_post_data_multiple(): void { + $this->resetAfterTest(); + $question = \test_question_maker::make_question('oumatrix', 'food_multiple'); + $question->shuffleanswers = 0; + $question->start_attempt(new question_attempt_step(), 1); + + $response = ['Proteins' => [1 => 'Chicken breast', 3 => 'Salmon fillet', 6 => 'Steak'], + 'Vegetables' => [2 => 'Carrot', 4 => 'Asparagus', 7 => 'Potato'], 'Fats' => [5 => 'Olive oil']]; + + $expected = [ + 'rowanswers0_1' => '1', + 'rowanswers0_2' => '0', + 'rowanswers0_3' => '1', + 'rowanswers0_4' => '0', + 'rowanswers0_5' => '0', + 'rowanswers0_6' => '1', + 'rowanswers0_7' => '0', + + 'rowanswers1_1' => '0', + 'rowanswers1_2' => '1', + 'rowanswers1_3' => '0', + 'rowanswers1_4' => '1', + 'rowanswers1_5' => '0', + 'rowanswers1_6' => '0', + 'rowanswers1_7' => '1', + + 'rowanswers2_1' => '0', + 'rowanswers2_2' => '0', + 'rowanswers2_3' => '0', + 'rowanswers2_4' => '0', + 'rowanswers2_5' => '1', + 'rowanswers2_6' => '0', + 'rowanswers2_7' => '0', + ]; + $this->assertEquals($expected, $question->prepare_simulated_post_data($response)); + } + + public function test_is_same_response(): void { $question = \test_question_maker::make_question('oumatrix', 'food_multiple'); $question->start_attempt(new question_attempt_step(), 1); diff --git a/tests/question_single_test.php b/tests/question_single_test.php index ea8ea40..a97c3ae 100644 --- a/tests/question_single_test.php +++ b/tests/question_single_test.php @@ -15,8 +15,10 @@ // along with Moodle. If not, see . namespace qtype_oumatrix; + use question_attempt_step; use question_state; +use question_classified_response; defined('MOODLE_INTERNAL') || die(); @@ -60,6 +62,72 @@ public function test_is_gradable_response(): void { $this->assertEquals($question->is_gradable_response($response), $question->is_complete_response($response)); } + public function test_classify_response_single(): void { + $this->resetAfterTest(); + $question = \test_question_maker::make_question('oumatrix', 'animals_single'); + $question->shuffleanswers = 0; + $question->start_attempt(new question_attempt_step(), 1); + + // All sub-questions are answered correctly. + $response = $question->prepare_simulated_post_data( + ['Bee' => 'Insects', 'Salmon' => 'Fish', 'Seagull' => 'Birds', 'Dog' => 'Mammals']); + $this->assertEquals([ + 'Bee' => new question_classified_response(1, 'Insects', 1), + 'Salmon' => new question_classified_response(2, 'Fish', 1), + 'Seagull' => new question_classified_response(3, 'Birds', 1), + 'Dog' => new question_classified_response(4, 'Mammals', 1), + ], $question->classify_response($response)); + + // Three sub-questions are answered correctly and one incorrectly. + $response = $question->prepare_simulated_post_data( + ['Bee' => 'Insects', 'Salmon' => 'Birds', 'Seagull' => 'Birds', 'Dog' => 'Mammals']); + $this->assertEquals([ + 'Bee' => new question_classified_response(1, 'Insects', 1), + 'Salmon' => new question_classified_response(3, 'Birds', 0), + 'Seagull' => new question_classified_response(3, 'Birds', 1), + 'Dog' => new question_classified_response(4, 'Mammals', 1), + ], $question->classify_response($response)); + + // Two sub-questions are answered correctly and two incorrectly. + $response = $question->prepare_simulated_post_data( + ['Bee' => 'Insects', 'Salmon' => 'Birds', 'Seagull' => 'Birds', 'Dog' => 'Insects']); + $this->assertEquals([ + 'Bee' => new question_classified_response(1, 'Insects', 1), + 'Salmon' => new question_classified_response(3, 'Birds', 0), + 'Seagull' => new question_classified_response(3, 'Birds', 1), + 'Dog' => new question_classified_response(1, 'Insects', 0), + ], $question->classify_response($response)); + + // Two sub-questions are answered correctly, one incorrectly, and the second sub-question is not answered. + $response = $question->prepare_simulated_post_data( + ['Bee' => 'Insects', 'Salmon' => '', 'Seagull' => 'Birds', 'Dog' => 'Insects']); + $this->assertEquals([ + 'Bee' => new question_classified_response(1, 'Insects', 1), + 'Salmon' => question_classified_response::no_response(), + 'Seagull' => new question_classified_response(3, 'Birds', 1), + 'Dog' => new question_classified_response(1, 'Insects', 0), + ], $question->classify_response($response)); + } + + public function test_prepare_simulated_post_data_single(): void { + $this->resetAfterTest(); + $question = \test_question_maker::make_question('oumatrix', 'animals_single'); + $question->shuffleanswers = 0; + $question->start_attempt(new question_attempt_step(), 1); + + $response = ['Bee' => 'Insects', 'Salmon' => 'Fish', 'Seagull' => 'Birds', 'Dog' => 'Mammals']; + $expected = ['rowanswers0' => 1, 'rowanswers1' => 2, 'rowanswers2' => 3, 'rowanswers3' => 4]; + $this->assertEquals($expected, $question->prepare_simulated_post_data($response)); + + $response = ['Bee' => 'Insects', 'Salmon' => 'Birds', 'Seagull' => 'Birds', 'Dog' => 'Mammals']; + $expected = ['rowanswers0' => 1, 'rowanswers1' => 3, 'rowanswers2' => 3, 'rowanswers3' => 4]; + $this->assertEquals($expected, $question->prepare_simulated_post_data($response)); + + $response = ['Bee' => 'Insects', 'Salmon' => 'Birds', 'Seagull' => 'Birds', 'Dog' => 'Insects']; + $expected = ['rowanswers0' => 1, 'rowanswers1' => 3, 'rowanswers2' => 3, 'rowanswers3' => 1]; + $this->assertEquals($expected, $question->prepare_simulated_post_data($response)); + } + public function test_is_same_response(): void { $question = \test_question_maker::make_question('oumatrix'); $question->start_attempt(new question_attempt_step(), 1); diff --git a/tests/questiontype_test.php b/tests/questiontype_test.php index 1634e89..42896fc 100644 --- a/tests/questiontype_test.php +++ b/tests/questiontype_test.php @@ -17,12 +17,14 @@ namespace qtype_oumatrix; use qtype_oumatrix; -use qtype_oumatrix_edit_form; use qtype_oumatrix_test_helper; +use question_bank; +use question_possible_response; defined('MOODLE_INTERNAL') || die(); global $CFG; + require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); require_once($CFG->dirroot . '/question/type/oumatrix/tests/helper.php'); require_once($CFG->dirroot . '/question/type/oumatrix/questiontype.php'); @@ -70,6 +72,81 @@ public function test_get_random_guess_score_broken_question(): void { $this->assertNull($this->qtype->get_random_guess_score($q)); } + public function test_get_possible_responses_single(): void { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $category = $generator->create_question_category([]); + $createdquestion = $generator->create_question('oumatrix', 'animals_single', + ['category' => $category->id, 'name' => 'Test question']); + $q = question_bank::load_question_data($createdquestion->id); + + $expected = [ + 'Bee' => [ + 1 => new question_possible_response('Insects', 1), + 2 => new question_possible_response('Fish', 0), + 3 => new question_possible_response('Birds', 0), + 4 => new question_possible_response('Mammals', 0), + null => question_possible_response::no_response(), + ], + 'Salmon' => [ + 1 => new question_possible_response('Insects', 0), + 2 => new question_possible_response('Fish', 1), + 3 => new question_possible_response('Birds', 0), + 4 => new question_possible_response('Mammals', 0), + null => question_possible_response::no_response(), + ], + 'Seagull' => [ + 1 => new question_possible_response('Insects', 0), + 2 => new question_possible_response('Fish', 0), + 3 => new question_possible_response('Birds', 1), + 4 => new question_possible_response('Mammals', 0), + null => question_possible_response::no_response(), + ], + 'Dog' => [ + 1 => new question_possible_response('Insects', 0), + 2 => new question_possible_response('Fish', 0), + 3 => new question_possible_response('Birds', 0), + 4 => new question_possible_response('Mammals', 1), + null => question_possible_response::no_response(), + ], + ]; + $this->assertEquals($expected, $this->qtype->get_possible_responses($q)); + } + + public function test_get_possible_responses_multiple(): void { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $category = $generator->create_question_category([]); + $createdquestion = $generator->create_question('oumatrix', 'food_multiple', + ['category' => $category->id, 'name' => 'Test question']); + $q = question_bank::load_question_data($createdquestion->id); + $expected = [ + 'Proteins: Chicken breast' => [1 => new question_possible_response('Selected', 1 / 3)], + 'Proteins: Carrot' => [1 => new question_possible_response('Selected', 0)], + 'Proteins: Salmon fillet' => [1 => new question_possible_response('Selected', 1 / 3)], + 'Proteins: Asparagus' => [1 => new question_possible_response('Selected', 0)], + 'Proteins: Olive oil' => [1 => new question_possible_response('Selected', 0)], + 'Proteins: Steak' => [1 => new question_possible_response('Selected', 1 / 3)], + 'Proteins: Potato' => [1 => new question_possible_response('Selected', 0)], + 'Vegetables: Chicken breast' => [1 => new question_possible_response('Selected', 0)], + 'Vegetables: Carrot' => [1 => new question_possible_response('Selected', 1 / 3)], + 'Vegetables: Salmon fillet' => [1 => new question_possible_response('Selected', 0)], + 'Vegetables: Asparagus' => [1 => new question_possible_response('Selected', 1 / 3)], + 'Vegetables: Olive oil' => [1 => new question_possible_response('Selected', 0)], + 'Vegetables: Steak' => [1 => new question_possible_response('Selected', 0)], + 'Vegetables: Potato' => [1 => new question_possible_response('Selected', 1 / 3)], + 'Fats: Chicken breast' => [1 => new question_possible_response('Selected', 0)], + 'Fats: Carrot' => [1 => new question_possible_response('Selected', 0)], + 'Fats: Salmon fillet' => [1 => new question_possible_response('Selected', 0)], + 'Fats: Asparagus' => [1 => new question_possible_response('Selected', 0)], + 'Fats: Olive oil' => [1 => new question_possible_response('Selected', 1)], + 'Fats: Steak' => [1 => new question_possible_response('Selected', 0)], + 'Fats: Potato' => [1 => new question_possible_response('Selected', 0)], + ]; + $this->assertEquals($expected, $this->qtype->get_possible_responses($q)); + } + public function get_save_question_which() { return [['animals_single'], ['oumatrix_multiple']]; }