diff --git a/.upgradenotes/MDL-81521-2024101701160681.yml b/.upgradenotes/MDL-81521-2024101701160681.yml new file mode 100644 index 00000000000..5892fa5b587 --- /dev/null +++ b/.upgradenotes/MDL-81521-2024101701160681.yml @@ -0,0 +1,19 @@ +issueNumber: MDL-81521 +notes: + mod_quiz: + - message: > + The `\mod_quiz\attempt_walkthrough_from_csv_test` unit test has been + marked as final and should not be extended by other tests. + + + All shared functionality has been moved to a new autoloadable test-case: + + `\mod_quiz\tests\attempt_walkthrough_testcase`. + + + To support this testcase the existing `$files` instance property should be replaced with a + new static method, `::get_test_files`. + + Both the existing instance property and the new static method can co-exist. + + type: changed diff --git a/mod/quiz/report/responses/tests/responses_from_steps_walkthrough_test.php b/mod/quiz/report/responses/tests/responses_from_steps_walkthrough_test.php index 430d04e2923..931e080c038 100644 --- a/mod/quiz/report/responses/tests/responses_from_steps_walkthrough_test.php +++ b/mod/quiz/report/responses/tests/responses_from_steps_walkthrough_test.php @@ -19,13 +19,6 @@ namespace quiz_responses; use mod_quiz\quiz_attempt; use question_bank; -defined('MOODLE_INTERNAL') || die(); - -global $CFG; -require_once($CFG->dirroot . '/mod/quiz/tests/attempt_walkthrough_from_csv_test.php'); -require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); -require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); - /** * Quiz attempt walk through using data from csv file. * @@ -35,16 +28,21 @@ require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class responses_from_steps_walkthrough_test extends \mod_quiz\attempt_walkthrough_from_csv_test { - protected function get_full_path_of_csv_file(string $setname, string $test): string { - // Overridden here so that __DIR__ points to the path of this file. - return __DIR__."/fixtures/{$setname}{$test}.csv"; +final class responses_from_steps_walkthrough_test extends \mod_quiz\tests\attempt_walkthrough_testcase { + #[\Override] + public static function setUpBeforeClass(): void { + global $CFG; + + parent::setUpBeforeClass(); + + require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); + require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); } - /** - * @var string[] names of the files which contain the test data. - */ - protected $files = ['questions', 'steps', 'responses']; + #[\Override] + protected static function get_test_files(): array { + return ['questions', 'steps', 'responses']; + } /** * Create a quiz add questions to it, walk through quiz attempts and then check results. @@ -54,7 +52,6 @@ class responses_from_steps_walkthrough_test extends \mod_quiz\attempt_walkthroug * @dataProvider get_data_for_walkthrough */ public function test_walkthrough_from_csv($quizsettings, $csvdata): void { - $this->resetAfterTest(true); question_bank::get_qtype('random')->clear_caches_before_testing(); @@ -72,7 +69,14 @@ class responses_from_steps_walkthrough_test extends \mod_quiz\attempt_walkthroug } } - protected function assert_response_test($quizattemptid, $responses) { + /** + * Helper to assert a response. + * + * @param mixed $quizattemptid + * @param mixed $responses + * @throws \coding_exception + */ + protected function assert_response_test($quizattemptid, $responses): void { $quizattempt = quiz_attempt::create($quizattemptid); foreach ($responses['slot'] as $slot => $tests) { diff --git a/mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php b/mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php index 6f8e966e376..0a0bd7886c1 100644 --- a/mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php +++ b/mod/quiz/report/statistics/tests/stats_from_steps_walkthrough_test.php @@ -21,13 +21,6 @@ use question_bank; use question_finder; use quiz_statistics_report; -defined('MOODLE_INTERNAL') || die(); - -global $CFG; -require_once($CFG->dirroot . '/mod/quiz/tests/attempt_walkthrough_from_csv_test.php'); -require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); -require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); - /** * Quiz attempt walk through using data from csv file. * @@ -45,22 +38,26 @@ require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class stats_from_steps_walkthrough_test extends \mod_quiz\attempt_walkthrough_from_csv_test { - +final class stats_from_steps_walkthrough_test extends \mod_quiz\tests\attempt_walkthrough_testcase { /** * @var quiz_statistics_report object to do stats calculations. */ protected $report; - protected function get_full_path_of_csv_file(string $setname, string $test): string { - // Overridden here so that __DIR__ points to the path of this file. - return __DIR__."/fixtures/{$setname}{$test}.csv"; + #[\Override] + public static function setUpBeforeClass(): void { + global $CFG; + + parent::setUpBeforeClass(); + + require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php'); + require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php'); } - /** - * @var string[] names of the files which contain the test data. - */ - protected $files = ['questions', 'steps', 'results', 'qstats', 'responsecounts']; + #[\Override] + protected static function get_test_files(): array { + return ['questions', 'steps', 'results', 'qstats', 'responsecounts']; + } /** * Create a quiz add questions to it, walk through quiz attempts and then check results. @@ -69,7 +66,6 @@ class stats_from_steps_walkthrough_test extends \mod_quiz\attempt_walkthrough_fr * @dataProvider get_data_for_walkthrough */ public function test_walkthrough_from_csv($quizsettings, $csvdata): void { - $this->create_quiz_simulate_attempts_and_check_results($quizsettings, $csvdata); $whichattempts = QUIZ_GRADEAVERAGE; // All attempts. diff --git a/mod/quiz/tests/attempt_walkthrough_from_csv_test.php b/mod/quiz/tests/attempt_walkthrough_from_csv_test.php index 848bca04490..77b67c8bb79 100644 --- a/mod/quiz/tests/attempt_walkthrough_from_csv_test.php +++ b/mod/quiz/tests/attempt_walkthrough_from_csv_test.php @@ -16,14 +16,7 @@ namespace mod_quiz; -use question_engine; -use mod_quiz\quiz_settings; - -defined('MOODLE_INTERNAL') || die(); - -global $CFG; -require_once($CFG->dirroot . '/mod/quiz/locallib.php'); -require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); +// phpcs:disable moodle.PHPUnit.TestCaseNames.Missing /** * Quiz attempt walk through using data from csv file. @@ -34,335 +27,9 @@ require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.ph * @author Jamie Pratt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class attempt_walkthrough_from_csv_test extends \advanced_testcase { - - use \quiz_question_helper_test_trait; - - /** - * @var string[] names of the files which contain the test data. - */ - protected $files = ['questions', 'steps', 'results']; - - /** - * @var stdClass the quiz record we create. - */ - protected $quiz; - - /** - * @var array with slot no => question name => questionid. Question ids of questions created in the same category as random q. - */ - protected $randqids; - - /** - * The only test in this class. This is run multiple times depending on how many sets of files there are in fixtures/ - * directory. - * - * @param array $quizsettings of settings read from csv file quizzes.csv - * @param array $csvdata of data read from csv file "questionsXX.csv", "stepsXX.csv" and "resultsXX.csv". - * @dataProvider get_data_for_walkthrough - */ - public function test_walkthrough_from_csv($quizsettings, $csvdata): void { - - // CSV data files for these tests were generated using : - // https://github.com/jamiepratt/moodle-quiz-tools/tree/master/responsegenerator - - $this->create_quiz_simulate_attempts_and_check_results($quizsettings, $csvdata); - } - - public function create_quiz($quizsettings, $qs) { - global $SITE, $DB; - $this->setAdminUser(); - - /** @var core_question_generator $questiongenerator */ - $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); - $slots = []; - $qidsbycat = []; - $sumofgrades = 0; - foreach ($qs as $qsrow) { - $q = $this->explode_dot_separated_keys_to_make_subindexs($qsrow); - - $catname = ['name' => $q['cat']]; - if (!$cat = $DB->get_record('question_categories', ['name' => $q['cat']])) { - $cat = $questiongenerator->create_question_category($catname); - } - $q['catid'] = $cat->id; - foreach (['which' => null, 'overrides' => []] as $key => $default) { - if (empty($q[$key])) { - $q[$key] = $default; - } - } - - if ($q['type'] !== 'random') { - // Don't actually create random questions here. - $overrides = ['category' => $cat->id, 'defaultmark' => $q['mark']] + $q['overrides']; - if ($q['type'] === 'truefalse') { - // True/false question can never have hints, but sometimes we need to put them - // in the CSV file, to keep it rectangular. - unset($overrides['hint']); - } - $question = $questiongenerator->create_question($q['type'], $q['which'], $overrides); - $q['id'] = $question->id; - - if (!isset($qidsbycat[$q['cat']])) { - $qidsbycat[$q['cat']] = []; - } - if (!empty($q['which'])) { - $name = $q['type'].'_'.$q['which']; - } else { - $name = $q['type']; - } - $qidsbycat[$q['catid']][$name] = $q['id']; - } - if (!empty($q['slot'])) { - $slots[$q['slot']] = $q; - $sumofgrades += $q['mark']; - } - } - - ksort($slots); - - // Make a quiz. - $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); - - // Settings from param override defaults. - $aggregratedsettings = $quizsettings + ['course' => $SITE->id, - 'questionsperpage' => 0, - 'grade' => 100.0, - 'sumgrades' => $sumofgrades]; - - $this->quiz = $quizgenerator->create_instance($aggregratedsettings); - - $this->randqids = []; - foreach ($slots as $slotno => $slotquestion) { - if ($slotquestion['type'] !== 'random') { - quiz_add_quiz_question($slotquestion['id'], $this->quiz, 0, $slotquestion['mark']); - } else { - $this->add_random_questions($this->quiz->id, 0, $slotquestion['catid'], 1); - $this->randqids[$slotno] = $qidsbycat[$slotquestion['catid']]; - } - } - } - - /** - * Create quiz, simulate attempts and check results (if resultsXX.csv exists). - * - * @param array $quizsettings Quiz overrides for this quiz. - * @param array $csvdata Data loaded from csv files for this test. - */ - protected function create_quiz_simulate_attempts_and_check_results(array $quizsettings, array $csvdata) { - $this->resetAfterTest(); - - $this->create_quiz($quizsettings, $csvdata['questions']); - - $attemptids = $this->walkthrough_attempts($csvdata['steps']); - - if (isset($csvdata['results'])) { - $this->check_attempts_results($csvdata['results'], $attemptids); - } - } - - /** - * Get full path of CSV file. - * - * @param string $setname - * @param string $test - * @return string full path of file. - */ - protected function get_full_path_of_csv_file(string $setname, string $test): string { - return __DIR__."/fixtures/{$setname}{$test}.csv"; - } - - /** - * Load dataset from CSV file "{$setname}{$test}.csv". - * - * @param string $setname - * @param string $test - * @return array - */ - protected function load_csv_data_file(string $setname, string $test = ''): array { - $files = [$setname => $this->get_full_path_of_csv_file($setname, $test)]; - return $this->dataset_from_files($files)->get_rows([$setname]); - } - - /** - * Break down row of csv data into sub arrays, according to column names. - * - * @param array $row from csv file with field names with parts separate by '.'. - * @return array the row with each part of the field name following a '.' being a separate sub array's index. - */ - protected function explode_dot_separated_keys_to_make_subindexs(array $row): array { - $parts = []; - foreach ($row as $columnkey => $value) { - $newkeys = explode('.', trim($columnkey)); - $placetoputvalue =& $parts; - foreach ($newkeys as $newkeydepth => $newkey) { - if ($newkeydepth + 1 === count($newkeys)) { - $placetoputvalue[$newkey] = $value; - } else { - // Going deeper down. - if (!isset($placetoputvalue[$newkey])) { - $placetoputvalue[$newkey] = []; - } - $placetoputvalue =& $placetoputvalue[$newkey]; - } - } - } - return $parts; - } - - /** - * Data provider method for test_walkthrough_from_csv. Called by PHPUnit. - * - * @return array One array element for each run of the test. Each element contains an array with the params for - * test_walkthrough_from_csv. - */ - public function get_data_for_walkthrough(): array { - $quizzes = $this->load_csv_data_file('quizzes')['quizzes']; - $datasets = []; - foreach ($quizzes as $quizsettings) { - $dataset = []; - foreach ($this->files as $file) { - if (file_exists($this->get_full_path_of_csv_file($file, $quizsettings['testnumber']))) { - $dataset[$file] = $this->load_csv_data_file($file, $quizsettings['testnumber'])[$file]; - } - } - $datasets[] = [$quizsettings, $dataset]; - } - return $datasets; - } - - /** - * @param array $steps the step data from the csv file. - * @return array attempt no as in csv file => the id of the quiz_attempt as stored in the db. - */ - protected function walkthrough_attempts(array $steps): array { - global $DB; - $attemptids = []; - foreach ($steps as $steprow) { - - $step = $this->explode_dot_separated_keys_to_make_subindexs($steprow); - // Find existing user or make a new user to do the quiz. - $username = ['firstname' => $step['firstname'], - 'lastname' => $step['lastname']]; - - if (!$user = $DB->get_record('user', $username)) { - $user = $this->getDataGenerator()->create_user($username); - } - - if (!isset($attemptids[$step['quizattempt']])) { - // Start the attempt. - $quizobj = quiz_settings::create($this->quiz->id, $user->id); - $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); - $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); - - $prevattempts = quiz_get_user_attempts($this->quiz->id, $user->id, 'all', true); - $attemptnumber = count($prevattempts) + 1; - $timenow = time(); - $attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $timenow, false, $user->id); - // Select variant and / or random sub question. - if (!isset($step['variants'])) { - $step['variants'] = []; - } - if (isset($step['randqs'])) { - // Replace 'names' with ids. - foreach ($step['randqs'] as $slotno => $randqname) { - $step['randqs'][$slotno] = $this->randqids[$slotno][$randqname]; - } - } else { - $step['randqs'] = []; - } - - quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, $step['randqs'], $step['variants']); - quiz_attempt_save_started($quizobj, $quba, $attempt); - $attemptid = $attemptids[$step['quizattempt']] = $attempt->id; - } else { - $attemptid = $attemptids[$step['quizattempt']]; - } - - // Process some responses from the student. - $attemptobj = quiz_attempt::create($attemptid); - $attemptobj->process_submitted_actions($timenow, false, $step['responses']); - - // Finish the attempt. - if (!isset($step['finished']) || ($step['finished'] == 1)) { - $attemptobj = quiz_attempt::create($attemptid); - $attemptobj->process_finish($timenow, false); - } - } - return $attemptids; - } - - /** - * @param array $results the results data from the csv file. - * @param array $attemptids attempt no as in csv file => the id of the quiz_attempt as stored in the db. - */ - protected function check_attempts_results(array $results, array $attemptids) { - foreach ($results as $resultrow) { - $result = $this->explode_dot_separated_keys_to_make_subindexs($resultrow); - // Re-load quiz attempt data. - $attemptobj = quiz_attempt::create($attemptids[$result['quizattempt']]); - $this->check_attempt_results($result, $attemptobj); - } - } - - /** - * Check that attempt results are as specified in $result. - * - * @param array $result row of data read from csv file. - * @param quiz_attempt $attemptobj the attempt object loaded from db. - */ - protected function check_attempt_results(array $result, quiz_attempt $attemptobj) { - foreach ($result as $fieldname => $value) { - if ($value === '!NULL!') { - $value = null; - } - switch ($fieldname) { - case 'quizattempt' : - break; - case 'attemptnumber' : - $this->assertEquals($value, $attemptobj->get_attempt_number()); - break; - case 'slots' : - foreach ($value as $slotno => $slottests) { - foreach ($slottests as $slotfieldname => $slotvalue) { - switch ($slotfieldname) { - case 'mark' : - $this->assertEquals(round($slotvalue, 2), $attemptobj->get_question_mark($slotno), - "Mark for slot $slotno of attempt {$result['quizattempt']}."); - break; - default : - throw new \coding_exception('Unknown slots sub field column in csv file ' - .s($slotfieldname)); - } - } - } - break; - case 'finished' : - $this->assertEquals((bool)$value, $attemptobj->is_finished()); - break; - case 'summarks' : - $this->assertEquals((float)$value, $attemptobj->get_sum_marks(), - "Sum of marks of attempt {$result['quizattempt']}."); - break; - case 'quizgrade' : - // Check quiz grades. - $grades = quiz_get_user_grades($attemptobj->get_quiz(), $attemptobj->get_userid()); - $grade = array_shift($grades); - $this->assertEquals($value, $grade->rawgrade, "Quiz grade for attempt {$result['quizattempt']}."); - break; - case 'gradebookgrade' : - // Check grade book. - $gradebookgrades = grade_get_grades($attemptobj->get_courseid(), - 'mod', 'quiz', - $attemptobj->get_quizid(), - $attemptobj->get_userid()); - $gradebookitem = array_shift($gradebookgrades->items); - $gradebookgrade = array_shift($gradebookitem->grades); - $this->assertEquals($value, $gradebookgrade->grade, "Gradebook grade for attempt {$result['quizattempt']}."); - break; - default : - throw new \coding_exception('Unknown column in csv file '.s($fieldname)); - } - } +final class attempt_walkthrough_from_csv_test extends \mod_quiz\tests\attempt_walkthrough_testcase { + #[\Override] + protected static function get_test_files(): array { + return ['questions', 'steps', 'results']; } } diff --git a/mod/quiz/tests/attempt_walkthrough_test.php b/mod/quiz/tests/attempt_walkthrough_test.php index 8a6988890b6..77d7b7e111a 100644 --- a/mod/quiz/tests/attempt_walkthrough_test.php +++ b/mod/quiz/tests/attempt_walkthrough_test.php @@ -19,13 +19,7 @@ namespace mod_quiz; use moodle_url; use question_bank; use question_engine; -use mod_quiz\question\bank\qbank_helper; - -defined('MOODLE_INTERNAL') || die(); - -global $CFG; -require_once($CFG->dirroot . '/mod/quiz/locallib.php'); -require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); +use mod_quiz\tests\question_helper_test_trait; /** * Quiz attempt walk through. @@ -37,9 +31,17 @@ require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.ph * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \mod_quiz\quiz_attempt */ -class attempt_walkthrough_test extends \advanced_testcase { +final class attempt_walkthrough_test extends \advanced_testcase { + use question_helper_test_trait; - use \quiz_question_helper_test_trait; + #[\Override] + public static function setUpBeforeClass(): void { + global $CFG; + + parent::setUpBeforeClass(); + + require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + } /** * Create a quiz with questions and walk through a quiz attempt. @@ -424,7 +426,7 @@ class attempt_walkthrough_test extends \advanced_testcase { } - public function get_correct_response_for_variants() { + public static function get_correct_response_for_variants(): array { return [[1, 9.9], [2, 8.5], [5, 14.2], [10, 6.8, true]]; } diff --git a/mod/quiz/tests/classes/attempt_walkthrough_testcase.php b/mod/quiz/tests/classes/attempt_walkthrough_testcase.php new file mode 100644 index 00000000000..905d80125b6 --- /dev/null +++ b/mod/quiz/tests/classes/attempt_walkthrough_testcase.php @@ -0,0 +1,387 @@ +. + +namespace mod_quiz\tests; + +use question_engine; +use mod_quiz\quiz_settings; +use mod_quiz\quiz_attempt; +use stdClass; + +/** + * Quiz attempt walk through using data from csv file. + * + * @package mod_quiz + * @category test + * @copyright 2013 The Open University + * @author Jamie Pratt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class attempt_walkthrough_testcase extends \advanced_testcase { + use question_helper_test_trait; + + /** + * @var stdClass the quiz record we create. + */ + protected $quiz; + + /** + * @var array with slot no => question name => questionid. Question ids of questions created in the same category as random q. + */ + protected $randqids; + + /** + * Get the list of files which contain test data. + * + * @return array + */ + protected static function get_test_files(): array { + return []; + } + + /** + * Get the component name. + * + * @return string + */ + protected static function get_component(): string { + // If the late-static class name is namespaced, use the first part of the namespace. + if (str_contains(static::class, '\\')) { + return explode('\\', static::class)[0]; + } + + // Otherwise we have to assume that the test name is correctly frankenstyle named. + return implode('_', + array_slice( + explode('_', static::class, 3), + 0, + 2, + ) + ); + } + + /** + * Get the full path of the csv file. + * + * @param string $setname + * @param string $test + * @return string + */ + protected static function get_full_path_of_csv_file(string $setname, string $test): string { + return static::get_fixture_path(static::get_component(), "{$setname}{$test}.csv"); + } + + /** + * The only test in this class. This is run multiple times depending on how many sets of files there are in fixtures/ + * directory. + * + * @param array $quizsettings of settings read from csv file quizzes.csv + * @param array $csvdata of data read from csv file "questionsXX.csv", "stepsXX.csv" and "resultsXX.csv". + * @dataProvider get_data_for_walkthrough + */ + public function test_walkthrough_from_csv($quizsettings, $csvdata): void { + // CSV data files for these tests were generated using : + // https://github.com/jamiepratt/moodle-quiz-tools/tree/master/responsegenerator + + $this->create_quiz_simulate_attempts_and_check_results($quizsettings, $csvdata); + } + + public function create_quiz($quizsettings, $qs) { + global $SITE, $DB; + $this->setAdminUser(); + + /** @var core_question_generator $questiongenerator */ + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $slots = []; + $qidsbycat = []; + $sumofgrades = 0; + foreach ($qs as $qsrow) { + $q = $this->explode_dot_separated_keys_to_make_subindexs($qsrow); + + $catname = ['name' => $q['cat']]; + if (!$cat = $DB->get_record('question_categories', ['name' => $q['cat']])) { + $cat = $questiongenerator->create_question_category($catname); + } + $q['catid'] = $cat->id; + foreach (['which' => null, 'overrides' => []] as $key => $default) { + if (empty($q[$key])) { + $q[$key] = $default; + } + } + + if ($q['type'] !== 'random') { + // Don't actually create random questions here. + $overrides = ['category' => $cat->id, 'defaultmark' => $q['mark']] + $q['overrides']; + if ($q['type'] === 'truefalse') { + // True/false question can never have hints, but sometimes we need to put them + // in the CSV file, to keep it rectangular. + unset($overrides['hint']); + } + $question = $questiongenerator->create_question($q['type'], $q['which'], $overrides); + $q['id'] = $question->id; + + if (!isset($qidsbycat[$q['cat']])) { + $qidsbycat[$q['cat']] = []; + } + if (!empty($q['which'])) { + $name = $q['type'].'_'.$q['which']; + } else { + $name = $q['type']; + } + $qidsbycat[$q['catid']][$name] = $q['id']; + } + if (!empty($q['slot'])) { + $slots[$q['slot']] = $q; + $sumofgrades += $q['mark']; + } + } + + ksort($slots); + + // Make a quiz. + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + + // Settings from param override defaults. + $aggregratedsettings = $quizsettings + ['course' => $SITE->id, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => $sumofgrades]; + + $this->quiz = $quizgenerator->create_instance($aggregratedsettings); + + $this->randqids = []; + foreach ($slots as $slotno => $slotquestion) { + if ($slotquestion['type'] !== 'random') { + quiz_add_quiz_question($slotquestion['id'], $this->quiz, 0, $slotquestion['mark']); + } else { + $this->add_random_questions($this->quiz->id, 0, $slotquestion['catid'], 1); + $this->randqids[$slotno] = $qidsbycat[$slotquestion['catid']]; + } + } + } + + /** + * Create quiz, simulate attempts and check results (if resultsXX.csv exists). + * + * @param array $quizsettings Quiz overrides for this quiz. + * @param array $csvdata Data loaded from csv files for this test. + */ + protected function create_quiz_simulate_attempts_and_check_results(array $quizsettings, array $csvdata) { + $this->resetAfterTest(); + + $this->create_quiz($quizsettings, $csvdata['questions']); + + $attemptids = $this->walkthrough_attempts($csvdata['steps']); + + if (isset($csvdata['results'])) { + $this->check_attempts_results($csvdata['results'], $attemptids); + } + } + + /** + * Load dataset from CSV file "{$setname}{$test}.csv". + * + * @param string $setname + * @param string $test + * @return array + */ + protected static function load_csv_data_file(string $setname, string $test = ''): array { + $files = [$setname => static::get_full_path_of_csv_file($setname, $test)]; + return static::dataset_from_files($files)->get_rows([$setname]); + } + + /** + * Break down row of csv data into sub arrays, according to column names. + * + * @param array $row from csv file with field names with parts separate by '.'. + * @return array the row with each part of the field name following a '.' being a separate sub array's index. + */ + protected function explode_dot_separated_keys_to_make_subindexs(array $row): array { + $parts = []; + foreach ($row as $columnkey => $value) { + $newkeys = explode('.', trim($columnkey)); + $placetoputvalue =& $parts; + foreach ($newkeys as $newkeydepth => $newkey) { + if ($newkeydepth + 1 === count($newkeys)) { + $placetoputvalue[$newkey] = $value; + } else { + // Going deeper down. + if (!isset($placetoputvalue[$newkey])) { + $placetoputvalue[$newkey] = []; + } + $placetoputvalue =& $placetoputvalue[$newkey]; + } + } + } + return $parts; + } + + /** + * Data provider method for test_walkthrough_from_csv. Called by PHPUnit. + * + * @return array One array element for each run of the test. Each element contains an array with the params for + * test_walkthrough_from_csv. + */ + public static function get_data_for_walkthrough(): array { + $quizzes = self::load_csv_data_file('quizzes')['quizzes']; + $datasets = []; + foreach ($quizzes as $quizsettings) { + $dataset = []; + foreach (static::get_test_files() as $file) { + if (file_exists(static::get_full_path_of_csv_file($file, $quizsettings['testnumber']))) { + $dataset[$file] = self::load_csv_data_file($file, $quizsettings['testnumber'])[$file]; + } + } + $datasets[] = [$quizsettings, $dataset]; + } + return $datasets; + } + + /** + * @param array $steps the step data from the csv file. + * @return array attempt no as in csv file => the id of the quiz_attempt as stored in the db. + */ + protected function walkthrough_attempts(array $steps): array { + global $DB; + $attemptids = []; + foreach ($steps as $steprow) { + + $step = $this->explode_dot_separated_keys_to_make_subindexs($steprow); + // Find existing user or make a new user to do the quiz. + $username = ['firstname' => $step['firstname'], + 'lastname' => $step['lastname']]; + + if (!$user = $DB->get_record('user', $username)) { + $user = $this->getDataGenerator()->create_user($username); + } + + if (!isset($attemptids[$step['quizattempt']])) { + // Start the attempt. + $quizobj = quiz_settings::create($this->quiz->id, $user->id); + $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + $prevattempts = quiz_get_user_attempts($this->quiz->id, $user->id, 'all', true); + $attemptnumber = count($prevattempts) + 1; + $timenow = time(); + $attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $timenow, false, $user->id); + // Select variant and / or random sub question. + if (!isset($step['variants'])) { + $step['variants'] = []; + } + if (isset($step['randqs'])) { + // Replace 'names' with ids. + foreach ($step['randqs'] as $slotno => $randqname) { + $step['randqs'][$slotno] = $this->randqids[$slotno][$randqname]; + } + } else { + $step['randqs'] = []; + } + + quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, $step['randqs'], $step['variants']); + quiz_attempt_save_started($quizobj, $quba, $attempt); + $attemptid = $attemptids[$step['quizattempt']] = $attempt->id; + } else { + $attemptid = $attemptids[$step['quizattempt']]; + } + + // Process some responses from the student. + $attemptobj = quiz_attempt::create($attemptid); + $attemptobj->process_submitted_actions($timenow, false, $step['responses']); + + // Finish the attempt. + if (!isset($step['finished']) || ($step['finished'] == 1)) { + $attemptobj = quiz_attempt::create($attemptid); + $attemptobj->process_finish($timenow, false); + } + } + return $attemptids; + } + + /** + * @param array $results the results data from the csv file. + * @param array $attemptids attempt no as in csv file => the id of the quiz_attempt as stored in the db. + */ + protected function check_attempts_results(array $results, array $attemptids) { + foreach ($results as $resultrow) { + $result = $this->explode_dot_separated_keys_to_make_subindexs($resultrow); + // Re-load quiz attempt data. + $attemptobj = quiz_attempt::create($attemptids[$result['quizattempt']]); + $this->check_attempt_results($result, $attemptobj); + } + } + + /** + * Check that attempt results are as specified in $result. + * + * @param array $result row of data read from csv file. + * @param quiz_attempt $attemptobj the attempt object loaded from db. + */ + protected function check_attempt_results(array $result, quiz_attempt $attemptobj) { + foreach ($result as $fieldname => $value) { + if ($value === '!NULL!') { + $value = null; + } + switch ($fieldname) { + case 'quizattempt' : + break; + case 'attemptnumber' : + $this->assertEquals($value, $attemptobj->get_attempt_number()); + break; + case 'slots' : + foreach ($value as $slotno => $slottests) { + foreach ($slottests as $slotfieldname => $slotvalue) { + switch ($slotfieldname) { + case 'mark' : + $this->assertEquals(round($slotvalue, 2), $attemptobj->get_question_mark($slotno), + "Mark for slot $slotno of attempt {$result['quizattempt']}."); + break; + default : + throw new \coding_exception('Unknown slots sub field column in csv file ' + .s($slotfieldname)); + } + } + } + break; + case 'finished' : + $this->assertEquals((bool)$value, $attemptobj->is_finished()); + break; + case 'summarks' : + $this->assertEquals((float)$value, $attemptobj->get_sum_marks(), + "Sum of marks of attempt {$result['quizattempt']}."); + break; + case 'quizgrade' : + // Check quiz grades. + $grades = quiz_get_user_grades($attemptobj->get_quiz(), $attemptobj->get_userid()); + $grade = array_shift($grades); + $this->assertEquals($value, $grade->rawgrade, "Quiz grade for attempt {$result['quizattempt']}."); + break; + case 'gradebookgrade' : + // Check grade book. + $gradebookgrades = grade_get_grades($attemptobj->get_courseid(), + 'mod', 'quiz', + $attemptobj->get_quizid(), + $attemptobj->get_userid()); + $gradebookitem = array_shift($gradebookgrades->items); + $gradebookgrade = array_shift($gradebookitem->grades); + $this->assertEquals($value, $gradebookgrade->grade, "Gradebook grade for attempt {$result['quizattempt']}."); + break; + default : + throw new \coding_exception('Unknown column in csv file '.s($fieldname)); + } + } + } +} diff --git a/mod/quiz/tests/classes/question_helper_test_trait.php b/mod/quiz/tests/classes/question_helper_test_trait.php new file mode 100644 index 00000000000..ea6356d7d2e --- /dev/null +++ b/mod/quiz/tests/classes/question_helper_test_trait.php @@ -0,0 +1,221 @@ +. + +namespace mod_quiz\tests; + +use backup; +use backup_controller; +use component_generator_base; +use mod_quiz_generator; +use mod_quiz\quiz_attempt; +use mod_quiz\quiz_settings; +use restore_controller; +use stdClass; +use question_engine; + +/** + * Helper trait for quiz question unit tests. + * + * This trait helps to execute different tests for quiz, for example if it needs to create a quiz, add question + * to the question, add random quetion to the quiz, do a backup or restore. + * + * @package mod_quiz + * @category test + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait question_helper_test_trait { + /** @var stdClass $course Test course to contain quiz. */ + protected $course; + + /** @var stdClass $quiz A test quiz. */ + protected $quiz; + + /** @var stdClass $user A test logged-in user. */ + protected $user; + + /** + * Create a test quiz for the specified course. + * + * @param stdClass $course + * @return stdClass + */ + protected function create_test_quiz(stdClass $course): stdClass { + /** @var mod_quiz_generator $quizgenerator */ + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + + return $quizgenerator->create_instance([ + 'course' => $course->id, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + } + + /** + * Helper method to add regular questions in quiz. + * + * @param component_generator_base $questiongenerator + * @param stdClass $quiz + * @param array $override + */ + protected function add_two_regular_questions($questiongenerator, stdClass $quiz, $override = null): void { + // Create a couple of questions. + $cat = $questiongenerator->create_question_category($override); + + $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); + // Create another version. + $questiongenerator->update_question($saq); + quiz_add_quiz_question($saq->id, $quiz); + $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); + // Create two version. + $questiongenerator->update_question($numq); + $questiongenerator->update_question($numq); + quiz_add_quiz_question($numq->id, $quiz); + } + + /** + * Helper method to add random question to quiz. + * + * @param component_generator_base $questiongenerator + * @param stdClass $quiz + * @param array $override + */ + protected function add_one_random_question($questiongenerator, stdClass $quiz, $override = []): void { + // Create a random question. + $cat = $questiongenerator->create_question_category($override); + $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); + $questiongenerator->create_question('essay', null, ['category' => $cat->id]); + $this->add_random_questions($quiz->id, 0, $cat->id, 1); + } + + /** + * Attempt questions for a quiz and user. + * + * @param stdClass $quiz Quiz to attempt. + * @param stdClass $user A user to attempt the quiz. + * @param int $attemptnumber + * @return array + */ + protected function attempt_quiz(stdClass $quiz, stdClass $user, $attemptnumber = 1): array { + $this->setUser($user); + + $starttime = time(); + $quizobj = quiz_settings::create($quiz->id, $user->id); + + $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + // Start the attempt. + $attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $starttime, false, $user->id); + quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $starttime); + quiz_attempt_save_started($quizobj, $quba, $attempt); + + // Finish the attempt. + $attemptobj = quiz_attempt::create($attempt->id); + $attemptobj->process_finish($starttime, false); + + $this->setUser(); + return [$quizobj, $quba, $attemptobj]; + } + + /** + * A helper method to backup test quiz. + * + * @param stdClass $quiz Quiz to attempt. + * @param stdClass $user A user to attempt the quiz. + * @return string A backup ID ready to be restored. + */ + protected function backup_quiz(stdClass $quiz, stdClass $user): string { + global $CFG; + + // Get the necessary files to perform backup and restore. + require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + + $backupid = 'test-question-backup-restore'; + + $bc = new backup_controller( + backup::TYPE_1ACTIVITY, + $quiz->cmid, + backup::FORMAT_MOODLE, + backup::INTERACTIVE_NO, + backup::MODE_GENERAL, + $user->id, + ); + $bc->execute_plan(); + + $results = $bc->get_results(); + $file = $results['backup_destination']; + $fp = get_file_packer('application/vnd.moodle.backup'); + $filepath = $CFG->dataroot . '/temp/backup/' . $backupid; + $file->extract_to_pathname($fp, $filepath); + $bc->destroy(); + + return $backupid; + } + + /** + * A helper method to restore provided backup. + * + * @param string $backupid Backup ID to restore. + * @param stdClass $course + * @param stdClass $user + */ + protected function restore_quiz(string $backupid, stdClass $course, stdClass $user): void { + $rc = new restore_controller($backupid, $course->id, + backup::INTERACTIVE_NO, backup::MODE_GENERAL, $user->id, backup::TARGET_CURRENT_ADDING); + $this->assertTrue($rc->execute_precheck()); + $rc->execute_plan(); + $rc->destroy(); + } + + /** + * A helper method to emulate duplication of the quiz. + * + * @param stdClass $course + * @param stdClass $quiz + * @return \cm_info|null + */ + protected function duplicate_quiz($course, $quiz): ?\cm_info { + return duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid)); + } + + /** + * Add random questions to a quiz, with a filter condition based on a category ID. + * + * @param int $quizid The quiz to add the questions to. + * @param int $page The page number to add the questions to. + * @param int $categoryid The category ID to use for the filter condition. + * @param int $number The number of questions to add. + * @return void + */ + protected function add_random_questions(int $quizid, int $page, int $categoryid, int $number): void { + $quizobj = quiz_settings::create($quizid); + $structure = $quizobj->get_structure(); + $filtercondition = [ + 'filter' => [ + 'category' => [ + 'jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT, + 'values' => [$categoryid], + 'filteroptions' => ['includesubcategories' => false], + ], + ], + ]; + $structure->add_random_questions($page, $number, $filtercondition); + } +} diff --git a/mod/quiz/tests/quiz_question_helper_test_trait.php b/mod/quiz/tests/quiz_question_helper_test_trait.php index c5b2220c489..754a0580243 100644 --- a/mod/quiz/tests/quiz_question_helper_test_trait.php +++ b/mod/quiz/tests/quiz_question_helper_test_trait.php @@ -12,196 +12,16 @@ // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License -// along with Moodle. If not, see . -use mod_quiz\quiz_attempt; -use mod_quiz\quiz_settings; +// along with Moodle. If not, see . /** - * Helper trait for quiz question unit tests. + * Trait helper. * - * This trait helps to execute different tests for quiz, for example if it needs to create a quiz, add question - * to the question, add random quetion to the quiz, do a backup or restore. - * - * @package mod_quiz - * @category test - * @copyright 2021 Catalyst IT Australia Pty Ltd - * @author Safat Shahin - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package mod_quiz + * @category test + * @copyright 2013 The Open University + * @author Jamie Pratt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -trait quiz_question_helper_test_trait { - /** @var \stdClass $course Test course to contain quiz. */ - protected $course; - - /** @var \stdClass $quiz A test quiz. */ - protected $quiz; - - /** @var \stdClass $user A test logged-in user. */ - protected $user; - - /** - * Create a test quiz for the specified course. - * - * @param \stdClass $course - * @return \stdClass - */ - protected function create_test_quiz(\stdClass $course): \stdClass { - - /** @var mod_quiz_generator $quizgenerator */ - $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); - - return $quizgenerator->create_instance([ - 'course' => $course->id, - 'questionsperpage' => 0, - 'grade' => 100.0, - 'sumgrades' => 2, - ]); - } - - /** - * Helper method to add regular questions in quiz. - * - * @param component_generator_base $questiongenerator - * @param \stdClass $quiz - * @param array $override - */ - protected function add_two_regular_questions($questiongenerator, \stdClass $quiz, $override = null): void { - // Create a couple of questions. - $cat = $questiongenerator->create_question_category($override); - - $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); - // Create another version. - $questiongenerator->update_question($saq); - quiz_add_quiz_question($saq->id, $quiz); - $numq = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); - // Create two version. - $questiongenerator->update_question($numq); - $questiongenerator->update_question($numq); - quiz_add_quiz_question($numq->id, $quiz); - } - - /** - * Helper method to add random question to quiz. - * - * @param component_generator_base $questiongenerator - * @param \stdClass $quiz - * @param array $override - */ - protected function add_one_random_question($questiongenerator, \stdClass $quiz, $override = []): void { - // Create a random question. - $cat = $questiongenerator->create_question_category($override); - $questiongenerator->create_question('truefalse', null, ['category' => $cat->id]); - $questiongenerator->create_question('essay', null, ['category' => $cat->id]); - $this->add_random_questions($quiz->id, 0, $cat->id, 1); - } - - /** - * Attempt questions for a quiz and user. - * - * @param \stdClass $quiz Quiz to attempt. - * @param \stdClass $user A user to attempt the quiz. - * @param int $attemptnumber - * @return array - */ - protected function attempt_quiz(\stdClass $quiz, \stdClass $user, $attemptnumber = 1): array { - $this->setUser($user); - - $starttime = time(); - $quizobj = quiz_settings::create($quiz->id, $user->id); - - $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); - $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); - - // Start the attempt. - $attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $starttime, false, $user->id); - quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $starttime); - quiz_attempt_save_started($quizobj, $quba, $attempt); - - // Finish the attempt. - $attemptobj = quiz_attempt::create($attempt->id); - $attemptobj->process_finish($starttime, false); - - $this->setUser(); - return [$quizobj, $quba, $attemptobj]; - } - - /** - * A helper method to backup test quiz. - * - * @param \stdClass $quiz Quiz to attempt. - * @param \stdClass $user A user to attempt the quiz. - * @return string A backup ID ready to be restored. - */ - protected function backup_quiz(\stdClass $quiz, \stdClass $user): string { - global $CFG; - - // Get the necessary files to perform backup and restore. - require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); - require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); - - $backupid = 'test-question-backup-restore'; - - $bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE, - backup::INTERACTIVE_NO, backup::MODE_GENERAL, $user->id); - $bc->execute_plan(); - - $results = $bc->get_results(); - $file = $results['backup_destination']; - $fp = get_file_packer('application/vnd.moodle.backup'); - $filepath = $CFG->dataroot . '/temp/backup/' . $backupid; - $file->extract_to_pathname($fp, $filepath); - $bc->destroy(); - - return $backupid; - } - - /** - * A helper method to restore provided backup. - * - * @param string $backupid Backup ID to restore. - * @param stdClass $course - * @param stdClass $user - */ - protected function restore_quiz(string $backupid, stdClass $course, stdClass $user): void { - $rc = new restore_controller($backupid, $course->id, - backup::INTERACTIVE_NO, backup::MODE_GENERAL, $user->id, backup::TARGET_CURRENT_ADDING); - $this->assertTrue($rc->execute_precheck()); - $rc->execute_plan(); - $rc->destroy(); - } - - /** - * A helper method to emulate duplication of the quiz. - * - * @param stdClass $course - * @param stdClass $quiz - * @return \cm_info|null - */ - protected function duplicate_quiz($course, $quiz): ?\cm_info { - return duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid)); - } - - /** - * Add random questions to a quiz, with a filter condition based on a category ID. - * - * @param int $quizid The quiz to add the questions to. - * @param int $page The page number to add the questions to. - * @param int $categoryid The category ID to use for the filter condition. - * @param int $number The number of questions to add. - * @return void - */ - protected function add_random_questions(int $quizid, int $page, int $categoryid, int $number): void { - $quizobj = quiz_settings::create($quizid); - $structure = $quizobj->get_structure(); - $filtercondition = [ - 'filter' => [ - 'category' => [ - 'jointype' => \qbank_managecategories\category_condition::JOINTYPE_DEFAULT, - 'values' => [$categoryid], - 'filteroptions' => ['includesubcategories' => false], - ], - ], - ]; - $structure->add_random_questions($page, $number, $filtercondition); - } -} +class_alias(\mod_quiz\tests\question_helper_test_trait::class, \quiz_question_helper_test_trait::class); diff --git a/question/tests/local/statistics/statistics_bulk_loader_test.php b/question/tests/local/statistics/statistics_bulk_loader_test.php index 255cf9ead8d..1128d163c84 100644 --- a/question/tests/local/statistics/statistics_bulk_loader_test.php +++ b/question/tests/local/statistics/statistics_bulk_loader_test.php @@ -16,8 +16,6 @@ namespace core_question\local\statistics; -defined('MOODLE_INTERNAL') || die(); - use advanced_testcase; use context; use context_module; @@ -30,9 +28,6 @@ use mod_quiz\quiz_settings; use question_engine; use ReflectionMethod; -global $CFG; -require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); - /** * Tests for question statistics. * @@ -41,9 +36,8 @@ require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.ph * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \core_question\local\statistics\statistics_bulk_loader */ -class statistics_bulk_loader_test extends advanced_testcase { - - use \quiz_question_helper_test_trait; +final class statistics_bulk_loader_test extends advanced_testcase { + use \mod_quiz\tests\question_helper_test_trait; /** @var float Delta used when comparing statistics values out-of 1. */ protected const DELTA = 0.00005;