diff --git a/question.php b/question.php index 7fb07cd..3b4c99f 100644 --- a/question.php +++ b/question.php @@ -59,7 +59,7 @@ class qtype_drawlines_question extends question_graded_automatically { public function get_expected_data() { $expecteddata = []; foreach ($this->lines as $line) { - $expecteddata[$this->choice($line->number - 1)] = PARAM_RAW; + $expecteddata[$this->field($line->number - 1)] = PARAM_RAW; } return $expecteddata; } @@ -75,8 +75,8 @@ public function is_complete_response(array $response): bool { return false; } foreach ($this->lines as $key => $line) { - if (isset($response[$this->choice($key)]) && - !line::are_response_coordinates_valid($response[$this->choice($key)], $line->type)) { + if (isset($response[$this->field($key)]) && + !line::are_response_coordinates_valid($response[$this->field($key)], $line->type)) { return false; } } @@ -87,7 +87,7 @@ public function is_complete_response(array $response): bool { public function get_correct_response() { $response = []; foreach ($this->lines as $key => $line) { - $response[$this->choice($key)] = line::get_coordinates($line->zonestart) . ' ' + $response[$this->field($key)] = line::get_coordinates($line->zonestart) . ' ' . line::get_coordinates($line->zoneend); } return $response; @@ -98,14 +98,14 @@ public function summarise_response(array $response): ?string { $responsewords = []; $answers = []; foreach ($this->lines as $key => $line) { - if (array_key_exists($this->choice($key), $response) && $response[$this->choice($key)] != '') { - $coordinates = explode(' ', $response[$this->choice($key)]); + if (array_key_exists($this->field($key), $response) && $response[$this->field($key)] != '') { + $coordinates = explode(' ', $response[$this->field($key)]); if ($line->type == 'lineinfinite' && count($coordinates) == 4) { - $coordinates = explode(' ', $response[$this->choice($key)]); + $coordinates = explode(' ', $response[$this->field($key)]); $answers[] = 'Line ' . $line->number . ': ' . $coordinates[1] . ' ' . $coordinates[2]; continue; } - $answers[] = 'Line ' . $line->number . ': ' . $response[$this->choice($key)]; + $answers[] = 'Line ' . $line->number . ': ' . $response[$this->field($key)]; } } if (count($answers) > 0) { @@ -117,7 +117,7 @@ public function summarise_response(array $response): ?string { #[\Override] public function is_gradable_response(array $response) { foreach ($this->lines as $key => $line) { - if (array_key_exists($this->choice($key), $response)) { + if (array_key_exists($this->field($key), $response)) { return true; } } @@ -127,7 +127,7 @@ public function is_gradable_response(array $response) { #[\Override] public function is_same_response(array $prevresponse, array $newresponse) { foreach ($this->lines as $key => $line) { - $fieldname = $this->choice($key); + $fieldname = $this->field($key); if (!question_utils::arrays_same_at_key_missing_is_blank($prevresponse, $newresponse, $fieldname)) { return false; } @@ -135,32 +135,20 @@ public function is_same_response(array $prevresponse, array $newresponse) { return true; } - #[\Override] - public function grade_response(array $response): array { - // Retrieve the number of right responses and the total number of responses. - if ($this->grademethod == 'partial') { - [$numright, $total] = $this->get_num_parts_right_grade_partialt($response); - } else { - [$numright, $total] = $this->get_num_parts_right_grade_allornone($response); - } - $fraction = $numright / $total; - return [$fraction, question_state::graded_state_for_fraction($fraction)]; - } - /** * Get the number of correct choices selected in the response, for 'Give partial credit' grade method. * * @param array $response The response list. * @return array The array of number of correct lines (start, end or both points of lines). */ - public function get_num_parts_right_grade_partialt(array $response): array { + public function get_num_parts_right_grade_partial(array $response): array { if (!$response) { return [0, 0]; } $numpartright = 0; foreach ($this->lines as $key => $line) { - if (array_key_exists($this->choice($key), $response) && $response[$this->choice($key)] !== '') { - $coords = explode(' ', $response[$this->choice($key)]); + if (array_key_exists($this->field($key), $response) && $response[$this->field($key)] !== '') { + $coords = explode(' ', $response[$this->field($key)]); if ($line->type == 'lineinfinite') { if (count($coords) == 2) { // Response with 2 coordinates (x1,y1 x2,y2). @@ -185,12 +173,15 @@ public function get_num_parts_right_grade_partialt(array $response): array { } } } else { + $numpartrightstart = 0; + $numpartrightend = 0; if (line::is_dragitem_in_the_right_place($coords[0], $line->zonestart)) { - $numpartright++; + $numpartrightstart++; } if (line::is_dragitem_in_the_right_place($coords[1], $line->zoneend)) { - $numpartright++; + $numpartrightend++; } + $numpartright += $numpartrightstart + $numpartrightend; } } } @@ -210,25 +201,25 @@ public function get_num_parts_right_grade_allornone(array $response): array { } $numright = 0; foreach ($this->lines as $key => $line) { - if (array_key_exists($this->choice($key), $response) && $response[$this->choice($key)] !== '') { - $coords = explode(' ', $response[$this->choice($key)]); + if (array_key_exists($this->field($key), $response) && $response[$this->field($key)] !== '') { + $coords = explode(' ', $response[$this->field($key)]); if ($line->type == 'lineinfinite') { if (count($coords) == 2) { // Response with 2 coordinates (x1,y1 x2,y2 x3,y3 x4,y4). $isstartrightplace = line::is_item_positioned_correctly_on_axis( - $coords[0], $line->zonestart, $line->zoneend, 'start' + $coords[0], $line->zonestart, $line->zoneend, 'start' ); $isendrightplace = line::is_item_positioned_correctly_on_axis( - $coords[1], $line->zonestart, $line->zoneend, 'end' + $coords[1], $line->zonestart, $line->zoneend, 'end' ); } else { // Response has 4 coordinates(x1,y1 x2,y2 x3,y3 x4,y4). // Here we need to consider x2,y2 x3,y3 for calculation. $isstartrightplace = line::is_item_positioned_correctly_on_axis( - $coords[1], $line->zonestart, $line->zoneend, 'start' + $coords[1], $line->zonestart, $line->zoneend, 'start' ); $isendrightplace = line::is_item_positioned_correctly_on_axis( - $coords[2], $line->zonestart, $line->zoneend, 'end' + $coords[2], $line->zonestart, $line->zoneend, 'end' ); } if ($isstartrightplace && $isendrightplace) { @@ -274,7 +265,7 @@ public function get_validation_error(array $response): string { public function classify_response(array $response) { $classifiedresponse = []; foreach ($this->lines as $key => $line) { - if (array_key_exists($this->choice($key), $response) && $response[$this->choice($key)] !== '') { + if (array_key_exists($this->field($key), $response) && $response[$this->field($key)] !== '') { if ($this->grademethod == 'partial') { $fraction = 0.5; } else { @@ -282,7 +273,7 @@ public function classify_response(array $response) { } $classifiedresponse[$key] = new question_classified_response( $line->number, - 'Line ' . $line->number . ': ' . $response[$this->choice($key)], + 'Line ' . $line->number . ': ' . $response[$this->field($key)], $fraction); } else { $classifiedresponse[$key] = question_classified_response::no_response(); @@ -291,6 +282,30 @@ public function classify_response(array $response) { return $classifiedresponse; } + + #[\Override] + public function grade_response(array $response): array { + // Retrieve the number of right responses and the total number of responses. + [$numright, $numtotal] = $this->retrieve_numright_numtotal($response); + $fraction = $numright / $numtotal; + return [$fraction, question_state::graded_state_for_fraction($fraction)]; + } + + /** + * Return number of right responses and the total number of answers. + * + * @param array $response The respnse array + * @return array|int[] The array containing number of correct responses and the total. + */ + public function retrieve_numright_numtotal(array $response): array { + if ($this->grademethod == 'partial') { + [$numright, $numtotal] = $this->get_num_parts_right_grade_partial($response); + } else { + [$numright, $numtotal] = $this->get_num_parts_right_grade_allornone($response); + } + return [$numright, $numtotal]; + } + /** * Work out a final grade for this attempt, taking into account * all the tries the student made and return the grade value. @@ -304,24 +319,38 @@ public function classify_response(array $response) { * @return float the fraction that should be awarded for this * sequence of response. */ - public function compute_final_grade(array $responses, int $totaltries): float { - // TODO: To incorporate the question penalty for interactive with multiple tries behaviour. - - $grade = 0; - foreach ($responses as $response) { + public function compute_final_grade(array $responses, int $totaltries): int|float { + $penalties = 0; + foreach ($responses as $attempt => $response) { [$fraction, $state] = $this->grade_response($response); - $grade += $fraction; + if ($state->is_graded() === true) { + if ($totaltries === 1) { + return round($fraction, 7); + } + if ($state->get_feedback_class() === 'correct') { + $grade = max(0, $fraction - $penalties); + return round($grade, 7); + } + [$numright, $numtotal] = $this->retrieve_numright_numtotal($response); + if ($state->get_feedback_class() === 'incorrect') { + $penalties += $this->penalty; + } else if ($state->get_feedback_class() === 'partiallycorrect') { + $partpenaly = ($numtotal - $numright) * $this->penalty / $totaltries; + $penalties += min($this->penalty, round($partpenaly, 7)); + } + } } - return $grade; + $grade = max(0, $fraction - $penalties); + return round($grade, 7); } /** - * Get a choice identifier + * Get a choice index identifier * - * @param int $choice stem number + * @param int $choice * @return string the question-type variable name. */ - public function choice($choice) { + public function field($choice): string { return 'c' . $choice; } } diff --git a/questiontype.php b/questiontype.php index 67329be..9c6ca76 100644 --- a/questiontype.php +++ b/questiontype.php @@ -172,12 +172,6 @@ public function save_hints($fromform, $withparts = false) { } } - #[\Override] - protected function make_question_instance($questiondata) { - question_bank::load_question_definition_classes($this->name()); - return new qtype_drawlines_question; - } - #[\Override] protected function initialise_question_instance(question_definition $question, $questiondata): void { parent::initialise_question_instance($question, $questiondata); diff --git a/renderer.php b/renderer.php index 7326999..eab8ed7 100644 --- a/renderer.php +++ b/renderer.php @@ -104,7 +104,7 @@ protected function hidden_field_for_qt_var(question_attempt $qa, $varname, $valu * @return mixed */ protected function hidden_field_choice(question_attempt $qa, $choicenumber, $value = null, $class = null) { - $varname = 'c'. $choicenumber; + $varname = $qa->get_question()->field($choicenumber); $classes = ['choices', 'choice'. $choicenumber]; [, $html] = $this->hidden_field_for_qt_var($qa, $varname, $value, $classes); return $html; diff --git a/tests/behat/preview.feature b/tests/behat/preview.feature index 62bae11..db46e32 100644 --- a/tests/behat/preview.feature +++ b/tests/behat/preview.feature @@ -23,11 +23,11 @@ Feature: Preview a DrawLines question @javascript Scenario: Preview a question using the keyboard - When I am on the "Drawlines to preview" "core_question > preview" page logged in as teacher + Given I am on the "Drawlines to preview" "core_question > preview" page logged in as teacher And I type "up" "360" times on line "1" "line" in the drawlines question And I type "left" "40" times on line "1" "line" in the drawlines question And I type "down" "190" times on line "1" "endcircle" in the drawlines question And I type "left" "200" times on line "1" "endcircle" in the drawlines question - And I press "Submit and finish" + When I press "Submit and finish" Then the state of "Draw 2 lines on the map" question is shown as "Partially correct" And I should see "Mark 0.50 out of 1.00" diff --git a/tests/helper.php b/tests/helper.php index e2297bc..5c44340 100644 --- a/tests/helper.php +++ b/tests/helper.php @@ -218,7 +218,7 @@ public function make_drawlines_question_mkmap_twolines(): qtype_drawlines_questi 11, $question->id, 1, line::TYPE_LINE_SEGMENT, 'Start1', 'Mid1', 'End1', '10,10;12', '300,10;12'), 1 => new line( - 11, $question->id, 2, line::TYPE_LINE_SEGMENT, + 12, $question->id, 2, line::TYPE_LINE_SEGMENT, 'Start2', '', 'End2', '10,200;12', '300,200;12'), ]; diff --git a/tests/question_test.php b/tests/question_test.php index c1788b1..6c831a9 100644 --- a/tests/question_test.php +++ b/tests/question_test.php @@ -146,27 +146,27 @@ public function test_summarise_response(): void { $this->assertEquals($expected, $actual); } - public function test_get_num_parts_right_grade_partialt(): void { + public function test_get_num_parts_right_grade_partial(): void { $question = \test_question_maker::make_question('drawlines'); $question->start_attempt(new question_attempt_step(), 1); $correctresponse = $question->get_correct_response(); - [$numpartright, $total] = $question->get_num_parts_right_grade_partialt($correctresponse); + [$numpartright, $total] = $question->get_num_parts_right_grade_partial($correctresponse); $this->assertEquals(4, $numpartright); $this->assertEquals(4, $total); $response = ['c0' => '10,10 300,123', 'c1' => '10,123 300,123']; - [$numpartright, $total] = $question->get_num_parts_right_grade_partialt($response); + [$numpartright, $total] = $question->get_num_parts_right_grade_partial($response); $this->assertEquals(1, $numpartright); $this->assertEquals(4, $total); $response = ['c0' => '10,10 300,10', 'c1' => '10,123 300,123']; - [$numpartright, $total] = $question->get_num_parts_right_grade_partialt($response); + [$numpartright, $total] = $question->get_num_parts_right_grade_partial($response); $this->assertEquals(2, $numpartright); $this->assertEquals(4, $total); $response = ['c0' => '10,10 300,10', 'c1' => '10,200 300,123']; - [$numpartright, $total] = $question->get_num_parts_right_grade_partialt($response); + [$numpartright, $total] = $question->get_num_parts_right_grade_partial($response); $this->assertEquals(3, $numpartright); $this->assertEquals(4, $total); } @@ -196,36 +196,135 @@ public function test_get_num_parts_right_grade_allornone(): void { $this->assertEquals(2, $total); } + public function test_retrieve_numright_numtotal(): void { + $question = \test_question_maker::make_question('drawlines'); + $question->start_attempt(new question_attempt_step(), 1); + + // The grade method is set to 'Give partial credit' by default. + $correctresponse = $question->get_correct_response(); + [$numpartright, $total] = $question->retrieve_numright_numtotal($correctresponse); + $this->assertEquals(4, $numpartright); + $this->assertEquals(4, $total); + + $response = ['c0' => '10,10 300,123', 'c1' => '10,123 300,123']; + [$numpartright, $total] = $question->retrieve_numright_numtotal($response); + $this->assertEquals(1, $numpartright); + $this->assertEquals(4, $total); + + $response = ['c0' => '10,10 300,10', 'c1' => '10,123 300,123']; + [$numpartright, $total] = $question->retrieve_numright_numtotal($response); + $this->assertEquals(2, $numpartright); + $this->assertEquals(4, $total); + + $response = ['c0' => '10,10 300,10', 'c1' => '10,200 300,123']; + [$numpartright, $total] = $question->retrieve_numright_numtotal($response); + $this->assertEquals(3, $numpartright); + $this->assertEquals(4, $total); + + // Set the grade method to 'All-or-nothing'. + $question->grademethod = 'allnone'; + $correctresponse = $question->get_correct_response(); + [$numright, $total] = $question->retrieve_numright_numtotal($correctresponse); + $this->assertEquals(2, $numright); + $this->assertEquals(2, $total); + + $response = ['c0' => '10,10 300,123', 'c1' => '10,123 300,123']; + [$numright, $total] = $question->retrieve_numright_numtotal($response); + $this->assertEquals(0, $numright); + $this->assertEquals(2, $total); + + $response = ['c0' => '10,10 300,10', 'c1' => '10,123 300,123']; + [$numright, $total] = $question->retrieve_numright_numtotal($response); + $this->assertEquals(1, $numright); + $this->assertEquals(2, $total); + + $response = ['c0' => '10,10 300,10', 'c1' => '10,200 300,123']; + [$numright, $total] = $question->retrieve_numright_numtotal($response); + $this->assertEquals(1, $numright); + $this->assertEquals(2, $total); + } + + public function test_grade_response(): void { + $question = \test_question_maker::make_question('drawlines'); + $question->start_attempt(new question_attempt_step(), 1); + + $correctresponse = $question->get_correct_response(); + $this->assertEquals([1, question_state::$gradedright], $question->grade_response($correctresponse)); + + $partiallycorrectresponse = ['c0' => '10,10 300,10', 'c1' => '10,200 300,123']; + $this->assertEquals([0.75, question_state::$gradedpartial], $question->grade_response($partiallycorrectresponse)); + + $partiallycorrectresponse = ['c0' => '10,10 300,10', 'c1' => '10,123 300,123']; + $this->assertEquals([0.5, question_state::$gradedpartial], $question->grade_response($partiallycorrectresponse)); + + $wrongresponse = ['c0' => '123,10 123,10', 'c1' => '10,123 300,123']; + $this->assertEquals([0, question_state::$gradedwrong], $question->grade_response($wrongresponse)); + } + public function test_compute_final_grade(): void { $question = \test_question_maker::make_question('drawlines'); $question->start_attempt(new question_attempt_step(), 1); - // TODO: To incorporate the question penalty for interactive with multiple tries behaviour. + // Single try. $totaltries = 1; - - $response = ['c0' => '100,10 300,100', 'c1' => '10,123 300,123']; - $responses[] = $response; + $responses[1] = ['c0' => '10,123 300,123', 'c1' => '10,123 300,123']; // Both lines are incorrect. $fraction = $question->compute_final_grade($responses, $totaltries); - $this->assertEquals($fraction, 0 / $totaltries, 'Incorrect responses should return fraction of 0'); + $this->assertEquals(0, $fraction, 'Incorrect responses should return fraction of 0'); - $responses = null; - $response = ['c0' => '10,10 300,10', 'c1' => '10,123 300,123']; - $responses[] = $response; + $responses[1] = ['c0' => '10,10 300,10', 'c1' => '10,123 300,123'];// Line 1 is correct and line 2 is incorrect. $fraction = $question->compute_final_grade($responses, $totaltries); - $this->assertEquals($fraction, 0.5 / $totaltries, + $this->assertEquals(0.5, $fraction, 'Partially correct responses(line 1 is correct and line 2 is incorrect) should return fraction of 0.5'); - $responses = null; - $response = ['c0' => '10,10 300,10', 'c1' => '10,200 300,123']; - $responses[] = $response; + $responses[1] = ['c0' => '10,10 300,10', 'c1' => '10,200 300,123'];// Line 1 is correct and line 2 is partially correct. $fraction = $question->compute_final_grade($responses, $totaltries); - $this->assertEquals($fraction, 0.75 / $totaltries, + $this->assertEquals($fraction, 0.75, 'Partially correct responses(line 1 is correct and line 2 is half-correct) should return fraction of 0.75'); - $responses = null; - $correctresponse = $question->get_correct_response(); - $responses[] = $correctresponse; + $responses[1] = $question->get_correct_response(); // Both lines are correct. + $fraction = $question->compute_final_grade($responses, $totaltries); + $this->assertEquals($fraction, 1, 'All correct responses should return fraction of 1'); + + + // Multiple tries with penalties, totaltries set to 3. + $totaltries = 3; + + // First attempt wrong, second attempt partially correct, third attemmpt correct. + $responses[1] = ['c0' => '10,123 300,123', 'c1' => '10,123 300,123']; // Both lines are incorrect. + $responses[2] = ['c0' => '10,10 300,10', 'c1' => '10,123 300,123']; // Line 1 is correct and line 2 is incorrect. + $responses[3] = $question->get_correct_response(); // 4 correct 0 incorrect. + $fraction = $question->compute_final_grade($responses, $totaltries); + $this->assertEqualsWithDelta( 0.4444445, $fraction, 0.0000001); + + // First attempt partially correct, second attempt partially correct, third attemmpt correct. + $responses[1] = ['c0' => '10,10 300,123', 'c1' => '10,123 300,123']; // Line 1 is partially correct and line 2 is incorrect. + $responses[2] = ['c0' => '10,10 300,10', 'c1' => '10,123 300,200']; // Line 1 is correct and line 2 is partially correct. + $responses[3] = $question->get_correct_response(); // Both lines are correct. + $fraction = $question->compute_final_grade($responses, $totaltries); + $this->assertEqualsWithDelta(0.5555556, $fraction, 0.0000001); + + // First attempt wrong, second attempt correct. + $responses[1] = ['c0' => '10,123 300,123', 'c1' => '10,123 300,123']; // Both lines are incorrect. + $responses[2] = $question->get_correct_response(); // 4 correct 0 incorrect. + $fraction = $question->compute_final_grade($responses, $totaltries); + $this->assertEqualsWithDelta( 0.6666667, $fraction, 0.0000001); + + // First attempt partially correct, second attempt correct. + $responses[1] = ['c0' => '10,10 300,10', 'c1' => '10,123 300,200']; // Line 1 is correct and line 2 is correct. + $responses[2] = $question->get_correct_response(); // Both lines are correct. + $fraction = $question->compute_final_grade($responses, $totaltries); + $this->assertEqualsWithDelta(0.8888889, $fraction, 0.0000001); + + // First attempt correct. + $responses[1] = $question->get_correct_response(); // Both lines are correct (4 correct coordinates). + $fraction = $question->compute_final_grade($responses, $totaltries); + $this->assertEquals(1, $fraction, 'On first attempt, correct responses should return fraction of 1'); + + // First attempt wrong, second attempt partially correct, third attemmpt correct. + $responses[1] = ['c0' => '10,123 10,123', 'c1' => '173,200 173,200']; // Both lines are incorrect. + $responses[2] = ['c0' => '10,10 300,10', 'c1' => '10,123 300,123']; // Line 1 is correct and line 2 is incorrect. + $responses[3] = ['c0' => '10,10 300,10', 'c1' => '10,123 300,200']; // Line 1 is correct and line 2 is partially correct. $fraction = $question->compute_final_grade($responses, $totaltries); - $this->assertEquals($fraction, 1 / $totaltries, 'All correct responses should return fraction of 1'); + $this->assertEqualsWithDelta( 0.0833334, $fraction, 0.0000001); } }