MDL-81521 mod_quiz: Fix attempt walkthrough tests

This commit is contained in:
Andrew Nicols 2024-10-14 23:20:00 +08:00
parent a6acb015a3
commit 9adfbcde4e
No known key found for this signature in database
GPG Key ID: 6D1E3157C8CFBF14
9 changed files with 688 additions and 578 deletions

View File

@ -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

View File

@ -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 <me@jamiep.org>
* @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) {

View File

@ -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 <me@jamiep.org>
* @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.

View File

@ -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 <me@jamiep.org>
* @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'];
}
}

View File

@ -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]];
}

View File

@ -0,0 +1,387 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// 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 <http://www.gnu.org/licenses/>.
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 <me@jamiep.org>
* @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));
}
}
}
}

View File

@ -0,0 +1,221 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// 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 <http://www.gnu.org/licenses/>.
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 <safatshahin@catalyst-au.net>
* @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);
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
// along with Moodle. If not, see <https://www.gnu.org/licenses/>.
/**
* 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 <safatshahin@catalyst-au.net>
* @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 <me@jamiep.org>
* @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);

View File

@ -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;