MDL-74762 qbank_statistics: improve performance loading the data

This issue greatly improves the performance of displaying statistics in
the question bank.

1. The required quiz statistics are now pre-computed by a scheduled task.

2. Cached statistics in the database are now never cleaned up, so the
   pre-computed stats are always available.

3. The way the cached statistics are loaded for each question
   that is being displayed is now a bit more efficient.

4. Related to that, there is a new callback which activities can implement,
   if they want their question statistics to be included in the ones shown
   in the question bank.

Note, there is still further improvement possible to load the statistics
for all questions being displayed in bulk. However, that must wait for a
future issue, MDL-75576. The other improvements in this issue are
significant and we did not want to delay releasing them.

Co-authored-by: Jonathan Champ <jrchamp@ncsu.edu>
Co-authored-by: Tim Hunt <t.j.hunt@open.ac.uk>
This commit is contained in:
Nathan Nguyen 2022-05-30 20:15:10 +10:00
parent b077af7e89
commit f02c16c445
11 changed files with 324 additions and 227 deletions

View File

@ -29,6 +29,7 @@
defined('MOODLE_INTERNAL') || die();
use mod_quiz\question\bank\custom_view;
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
require_once($CFG->dirroot . '/calendar/lib.php');
@ -2485,3 +2486,19 @@ function quiz_delete_references($quizid): void {
$DB->delete_records('question_references', $params);
}
}
/**
* Implement the calculate_question_stats callback.
*
* This enables quiz statistics to be shown in statistics columns in the database.
*
* @param context $context return the statistics related to this context (which will be a quiz context).
* @return all_calculated_for_qubaid_condition|null The statistics for this quiz, if any, else null.
*/
function mod_quiz_calculate_question_stats(context $context): ?all_calculated_for_qubaid_condition {
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php');
$cm = get_coursemodule_from_id('quiz', $context->instanceid);
$report = new quiz_statistics_report();
return $report->calculate_questions_stats_for_question_bank($cm->instance);
}

View File

@ -26,6 +26,7 @@
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/quiz/lib.php');
require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
require_once($CFG->libdir . '/filelib.php');
require_once($CFG->dirroot . '/mod/quiz/accessmanager.php');

View File

@ -1,55 +0,0 @@
<?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/>.
/**
* Legacy Cron Quiz Reports Task
*
* @package quiz_statistics
* @copyright 2017 Michael Hughes, University of Strathclyde
* @author Michael Hughes <michaelhughes@strath.ac.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*
*/
namespace quiz_statistics\task;
defined('MOODLE_INTERNAL') || die();
/**
* Legacy Cron Quiz Reports Task
*
* @package quiz_statistics
* @copyright 2017 Michael Hughes
* @author Michael Hughes
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*
*/
class quiz_statistics_cleanup extends \core\task\scheduled_task {
public function get_name() {
return get_string('quizstatisticscleanuptask', 'quiz_statistics');
}
/**
* Run the clean up task.
*/
public function execute() {
global $DB;
$expiretime = time() - 4 * HOURSECS;
$DB->delete_records_select('quiz_statistics', 'timemodified < ?', array($expiretime));
return true;
}
}

View File

@ -0,0 +1,88 @@
<?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 quiz_statistics\task;
use quiz_attempt;
use quiz;
use quiz_statistics_report;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php');
/**
* Re-calculate question statistics.
*
* @package quiz_statistics
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @author Nathan Nguyen <nathannguyen@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class recalculate extends \core\task\scheduled_task {
public function get_name() {
return get_string('recalculatetask', 'quiz_statistics');
}
public function execute() {
global $DB;
// TODO: MDL-75197, add quizid in quiz_statistics so that it is simpler to find quizzes for stats calculation.
// Only calculate stats for quizzes which have recently finished attempt.
$sql = "
SELECT qa.quiz, MAX(qa.timefinish) as timefinish
FROM {quiz_attempts} qa
WHERE qa.preview = 0
AND qa.state = :quizstatefinished
GROUP BY qa.quiz
";
$params = [
"quizstatefinished" => quiz_attempt::FINISHED,
];
$latestattempts = $DB->get_records_sql($sql, $params);
foreach ($latestattempts as $attempt) {
$quizobj = quiz::create($attempt->quiz);
$quiz = $quizobj->get_quiz();
// Hash code for question stats option in question bank.
$qubaids = quiz_statistics_qubaids_condition($quiz->id, new \core\dml\sql_join(), $quiz->grademethod);
// Check if there is any existing question stats, and it has been calculated after latest quiz attempt.
$records = $DB->get_records_select(
'quiz_statistics',
'hashcode = :hashcode AND timemodified > :timefinish',
[
'hashcode' => $qubaids->get_hash_code(),
'timefinish' => $attempt->timefinish
]
);
if (empty($records)) {
$report = new quiz_statistics_report();
// Clear old cache.
$report->clear_cached_data($qubaids);
// Calculate new stats.
$report->calculate_questions_stats_for_question_bank($quiz->id);
}
}
return true;
}
}

View File

@ -28,10 +28,10 @@ defined('MOODLE_INTERNAL') || die();
$tasks = [
[
'classname' => 'quiz_statistics\task\quiz_statistics_cleanup',
'classname' => 'quiz_statistics\task\recalculate',
'blocking' => 0,
'minute' => 'R',
'hour' => '*/5',
'hour' => '*/4',
'day' => '*',
'dayofweek' => '*',
'month' => '*'

View File

@ -93,7 +93,6 @@ $string['questioninformation'] = 'Question information';
$string['questionname'] = 'Question name';
$string['questionnumber'] = 'Q#';
$string['questionstatistics'] = 'Question statistics';
$string['quizstatisticscleanuptask'] = 'Clean up old quiz statistics cache records';
$string['questionstatsfilename'] = 'questionstats';
$string['questiontype'] = 'Question type';
$string['quizinformation'] = 'Quiz information';
@ -104,6 +103,7 @@ $string['random_guess_score'] = 'Random guess score';
$string['rangeofvalues'] = 'Range of statistics for these questions';
$string['rangebetween'] = '{$a->min} {$a->max}';
$string['recalculatenow'] = 'Recalculate now';
$string['recalculatetask'] = 'Re-calculate question statistics';
$string['reportsettings'] = 'Statistics calculation settings';
$string['response'] = 'Response';
$string['slotstructureanalysis'] = 'Structural analysis for question number {$a}';

View File

@ -25,10 +25,15 @@
defined('MOODLE_INTERNAL') || die();
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
require_once($CFG->dirroot . '/mod/quiz/report/default.php');
require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
/**
* The quiz statistics report provides summary information about each question in
* a quiz, compared to the whole quiz. It also provides a drill-down to more
@ -814,7 +819,7 @@ class quiz_statistics_report extends quiz_default_report {
*
* @param $qubaids qubaid_condition
*/
protected function clear_cached_data($qubaids) {
public function clear_cached_data($qubaids) {
global $DB;
$DB->delete_records('quiz_statistics', array('hashcode' => $qubaids->get_hash_code()));
$DB->delete_records('question_statistics', array('hashcode' => $qubaids->get_hash_code()));
@ -921,4 +926,21 @@ class quiz_statistics_report extends quiz_default_report {
}
}
}
/**
* Load question stats for a quiz
*
* @param int $quizid question usage
* @return all_calculated_for_qubaid_condition question stats
*/
public function calculate_questions_stats_for_question_bank(int $quizid): all_calculated_for_qubaid_condition {
global $DB;
$quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST);
$questions = $this->load_and_initialise_questions_for_calculations($quiz);
[, $questionstats] = $this->get_all_stats_and_analysis($quiz,
$quiz->grademethod, question_attempt::ALL_TRIES, new \core\dml\sql_join(), $questions);
return $questionstats;
}
}

View File

@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2022041900;
$plugin->version = 2022041901;
$plugin->requires = 2022041200;
$plugin->component = 'quiz_statistics';

View File

@ -13,6 +13,9 @@ information provided here is intended especially for developers.
in returning inaccurate data in this course format, therefore it is advisable to use $settingsnavigation->get_page().
* A new style of icons has been created for activities. When creating an icon in the new style it should be named
'monologo' and can site alongside the legacy icon if desired. Only the new logo types will be used.
* There is a new callback ..._calculate_question_stats which needs to be implemented by components which want
to contribute statistics to the display in the question bank. There is an example implementation in mod_quiz.
(Added in 4.0.4 / 4.1.)
=== 3.9 ===

View File

@ -17,15 +17,7 @@
namespace qbank_statistics;
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
use quiz_statistics_report;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
require_once($CFG->dirroot . '/mod/quiz/report/default.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php');
require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
use core_component;
/**
* Helper for statistics
@ -48,106 +40,119 @@ class helper {
private const NEED_FOR_REVISION_UPPER_THRESHOLD = 50;
/**
* Return ids of all quizzes that use the question
* For a list of questions find all the places (defined by (component, contextid) where there are attempts.
*
* @param int $questionid id of the question
* @return array list of quizids
* @throws \dml_exception
* @param int[] $questionids array of question ids that we are interested in.
* @return \stdClass[] list of objects with fields ->component and ->contextid.
*/
public static function get_quizzes(int $questionid): array {
private static function get_all_places_where_questions_were_attempted(array $questionids): array {
global $DB;
$quizzes = $DB->get_fieldset_sql("
SELECT DISTINCT qa.quiz as id
FROM {quiz_attempts} qa
JOIN {question_usages} qu ON qu.id = qa.uniqueid
[$questionidcondition, $params] = $DB->get_in_or_equal($questionids);
// The MIN(qu.id) is just to ensure that the rows have a unique key.
$places = $DB->get_records_sql("
SELECT MIN(qu.id) AS somethingunique, qu.component, qu.contextid
FROM {question_usages} qu
JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id
WHERE qatt.questionid = :questionid",
['questionid' => $questionid]
);
return $quizzes;
WHERE qatt.questionid $questionidcondition
GROUP BY qu.component, qu.contextid
", $params);
// Strip out the unwanted ids.
$places = array_values($places);
foreach ($places as $place) {
unset($place->somethingunique);
}
return $places;
}
/**
* Load question stats from a quiz
* Load the question statistics for all the attempts belonging to a particular component in a particular context.
*
* @param int $quizid quiz object or its id
* @return all_calculated_for_qubaid_condition
* @param string $component frankenstyle component name, e.g. 'mod_quiz'.
* @param \context $context the context to load the statistics for.
* @return all_calculated_for_qubaid_condition|null question statistics.
*/
private static function load_question_stats(int $quizid): all_calculated_for_qubaid_condition {
// Turn to quiz object.
$quiz = new \stdClass();
$quiz->id = $quizid;
// All questions, no groups.
$report = new quiz_statistics_report();
$questions = $report->load_and_initialise_questions_for_calculations($quiz);
$qubaids = quiz_statistics_qubaids_condition($quiz->id, new \core\dml\sql_join());
$progress = new \core\progress\none();
$qcalc = new \core_question\statistics\questions\calculator($questions, $progress);
$quizcalc = new \quiz_statistics\calculator($progress);
if ($quizcalc->get_last_calculated_time($qubaids) === false) {
$questionstats = $qcalc->calculate($qubaids);
} else {
$questionstats = $qcalc->get_cached($qubaids);
private static function load_statistics_for_place(string $component, \context $context): ?all_calculated_for_qubaid_condition {
// This check is basically if (component_exists).
if (empty(core_component::get_component_directory($component))) {
return null;
}
return $questionstats;
if (!component_callback_exists($component, 'calculate_question_stats')) {
return null;
}
return component_callback($component, 'calculate_question_stats', [$context]);
}
/**
* Load a specified stats item for a question
* Extract the value for one question and one type of statistic from a set of statistics.
*
* @param int $quizid quiz id
* @param int $questionid question id
* @param string $item a stats item
* @return float|int
* @param all_calculated_for_qubaid_condition $statistics the batch of statistics.
* @param int $questionid a question id.
* @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'.
* @return float|null the required value.
*/
public static function load_question_stats_item(int $quizid, int $questionid, string $item): ?float {
$questionstats = self::load_question_stats($quizid);
// Find in main question.
foreach ($questionstats->questionstats as $stats) {
private static function extract_item_value(all_calculated_for_qubaid_condition $statistics,
int $questionid, string $item): ?float {
// Look in main questions.
foreach ($statistics->questionstats as $stats) {
if ($stats->questionid == $questionid && isset($stats->$item)) {
return $stats->$item;
}
}
// If not found, find in sub questions.
foreach ($questionstats->subquestionstats as $stats) {
// If not found, look in sub questions.
foreach ($statistics->subquestionstats as $stats) {
if ($stats->questionid == $questionid && isset($stats->$item)) {
return $stats->$item;
}
}
return null;
}
/**
* Calculate average for a stats item on a question.
* Calculate average for a stats item on a list of questions.
*
* @param int $questionid id of the question
* @param string $item stats item
* @return float|null
* @param int[] $questionids list of ids of the questions we are interested in.
* @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'.
* @return array array keys are question ids and the corresponding values are the average values.
* Only questions for which there are data are included.
*/
private static function calculate_average_question_stats_item(int $questionid, string $item): ?float {
$quizzes = self::get_quizzes($questionid);
private static function calculate_average_question_stats_item(array $questionids, string $item): array {
$places = self::get_all_places_where_questions_were_attempted($questionids);
$sum = 0;
$quizcount = count($quizzes);
foreach ($quizzes as $quizid) {
$value = self::load_question_stats_item($quizid, $questionid, $item);
if (!is_null($value)) {
$sum += $value;
} else {
// Exclude this value when it is null.
$quizcount--;
$counts = [];
$sums = [];
foreach ($places as $place) {
$statistics = self::load_statistics_for_place($place->component,
\context::instance_by_id($place->contextid));
if ($statistics === null) {
continue;
}
foreach ($questionids as $questionid) {
$value = self::extract_item_value($statistics, $questionid, $item);
if ($value === null) {
continue;
}
$counts[$questionid] = ($counts[$questionid] ?? 0) + 1;
$sums[$questionid] = ($sums[$questionid] ?? 0) + $value;
}
}
// Return null if there is no quizzes.
if (empty($quizcount)) {
return null;
$averages = [];
foreach ($sums as $questionid => $sum) {
$averages[$questionid] = $sum / $counts[$questionid];
}
// Average value per quiz.
$average = $sum / $quizcount;
return $average;
return $averages;
}
/**
@ -157,7 +162,8 @@ class helper {
* @return float|null
*/
public static function calculate_average_question_facility(int $questionid): ?float {
return self::calculate_average_question_stats_item($questionid, 'facility');
$averages = self::calculate_average_question_stats_item([$questionid], 'facility');
return $averages[$questionid] ?? null;
}
/**
@ -167,7 +173,8 @@ class helper {
* @return float|null
*/
public static function calculate_average_question_discriminative_efficiency(int $questionid): ?float {
return self::calculate_average_question_stats_item($questionid, 'discriminativeefficiency');
$averages = self::calculate_average_question_stats_item([$questionid], 'discriminativeefficiency');
return $averages[$questionid] ?? null;
}
/**
@ -177,7 +184,8 @@ class helper {
* @return float|null
*/
public static function calculate_average_question_discrimination_index(int $questionid): ?float {
return self::calculate_average_question_stats_item($questionid, 'discriminationindex');
$averages = self::calculate_average_question_stats_item([$questionid], 'discriminationindex');
return $averages[$questionid] ?? null;
}
/**

View File

@ -16,13 +16,11 @@
namespace qbank_statistics;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
use quiz;
use question_engine;
use quiz_attempt;
/**
* Tests for question statistics.
*
@ -36,103 +34,84 @@ class helper_test extends \advanced_testcase {
/**
* Test quizzes that contain a specified question.
*
* @covers ::get_all_places_where_questions_were_attempted
* @throws \coding_exception
* @throws \dml_exception
*/
public function test_get_quizziess(): void {
global $DB;
public function test_get_all_places_where_questions_were_attempted(): void {
$this->resetAfterTest();
$this->setAdminUser();
$rcm = new \ReflectionMethod(helper::class, 'get_all_places_where_questions_were_attempted');
$rcm->setAccessible(true);
// Create a course.
$course = $this->getDataGenerator()->create_course();
// Create quizzes.
// Create three quizzes.
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$quiz1 = $quizgenerator->create_instance([
'course' => $course->id,
'grade' => 100.0, 'sumgrades' => 2,
'layout' => '1,2,0'
]);
$quiz1context = \context_module::instance($quiz1->cmid);
$quiz2 = $quizgenerator->create_instance([
'course' => $course->id,
'grade' => 100.0, 'sumgrades' => 2,
'layout' => '1,2,0'
]);
$quiz2context = \context_module::instance($quiz2->cmid);
$quiz3 = $quizgenerator->create_instance([
'course' => $course->id,
'grade' => 100.0, 'sumgrades' => 2,
'layout' => '1,2,0'
]);
$this->assertEquals(3, $DB->count_records('quiz'));
$quiz3context = \context_module::instance($quiz3->cmid);
// Create questions.
/** @var \core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$question1 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$question2 = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
// Add question 1 to quiz 1, 2.
// Add question 1 to quiz 1 and make an attempt.
quiz_add_quiz_question($question1->id, $quiz1);
quiz_add_quiz_question($question1->id, $quiz2);
// Quiz 1 attempt.
$attempt = ['answer' => 'frog', 'answer' => 10];
$this->submit_quiz($quiz1, $attempt);
$this->submit_quiz($quiz1, [1 => ['answer' => 'frog']]);
// Add question 2 to quiz 2.
// Add questions 1 and 2 to quiz 2.
quiz_add_quiz_question($question1->id, $quiz2);
quiz_add_quiz_question($question2->id, $quiz2);
$this->submit_quiz($quiz2, $attempt);
$this->submit_quiz($quiz2, [1 => ['answer' => 'frog'], 2 => ['answer' => 10]]);
// Checking quizzes that use question 1.
$question1quizzes = helper::get_quizzes($question1->id);
$this->assertCount(2, $question1quizzes);
$this->assertContains($quiz1->id, $question1quizzes);
$this->assertContains($quiz2->id, $question1quizzes);
$q1places = $rcm->invoke(null, [$question1->id]);
$this->assertCount(2, $q1places);
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz1context->id], $q1places[0]);
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q1places[1]);
// Checking quizzes that contain question 2.
$question2quizzes = helper::get_quizzes($question2->id);
$this->assertCount(1, $question2quizzes);
$this->assertContains($quiz2->id, $question2quizzes);
$q2places = $rcm->invoke(null, [$question2->id]);
$this->assertCount(1, $q2places);
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q2places[0]);
// Add random question to quiz3.
// Add a random question to quiz3.
quiz_add_random_questions($quiz3, 0, $cat->id, 1, false);
$this->submit_quiz($quiz3, $attempt);
// Quiz 3 will be in one of these arrays.
$question1quizzes = helper::get_quizzes($question1->id);
$question2quizzes = helper::get_quizzes($question2->id);
$this->assertContains($quiz3->id, array_merge($question1quizzes, $question2quizzes));
}
$this->submit_quiz($quiz3, [1 => ['answer' => 'willbewrong']]);
/**
* Load facility for a question
*
* @param object $quiz quiz object
* @param int $questionid question id
* @return float|int
*/
private function load_question_facility(object $quiz, int $questionid): ?float {
return helper::load_question_stats_item($quiz->id, $questionid, 'facility');
// Quiz 3 will now be in one of these arrays.
$q1places = $rcm->invoke(null, [$question1->id]);
$q2places = $rcm->invoke(null, [$question2->id]);
if (count($q1places) == 3) {
$newplace = end($q1places);
} else {
$newplace = end($q2places);
}
/**
* Load discriminative efficiency for a question
*
* @param object $quiz quiz object
* @param int $questionid question id
* @return float|int
*/
private function load_question_discriminative_efficiency(object $quiz, int $questionid): ?float {
return helper::load_question_stats_item($quiz->id, $questionid, 'discriminativeefficiency');
}
/**
* Load discrimination index for a question
*
* @param object $quiz quiz object
* @param int $questionid question id
* @return float|int
*/
private function load_question_discrimination_index(object $quiz, int $questionid): ?float {
return helper::load_question_stats_item($quiz->id, $questionid, 'discriminationindex');
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz3context->id], $newplace);
}
/**
@ -160,6 +139,7 @@ class helper_test extends \advanced_testcase {
'layout' => $layout
]);
/** @var \core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
@ -195,7 +175,7 @@ class helper_test extends \advanced_testcase {
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
$timenow = time();
$attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user->id);
$attempt = quiz_create_attempt($quizobj, 1, null, $timenow, false, $user->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
quiz_attempt_save_started($quizobj, $quba, $attempt);
// Submit attempt.
@ -248,6 +228,33 @@ class helper_test extends \advanced_testcase {
return [$quiz1, $quiz2, $questions];
}
/**
* To use private helper::extract_item_value function.
*
* @param all_calculated_for_qubaid_condition $statistics the batch of statistics.
* @param int $questionid a question id.
* @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'.
* @return float|null the required value.
*/
private function extract_item_value(all_calculated_for_qubaid_condition $statistics,
int $questionid, string $item): ?float {
$rcm = new \ReflectionMethod(helper::class, 'extract_item_value');
$rcm->setAccessible(true);
return $rcm->invoke(null, $statistics, $questionid, $item);
}
/**
* To use private helper::load_statistics_for_place function (with mod_quiz component).
*
* @param \context $context the context to load the statistics for.
* @return all_calculated_for_qubaid_condition|null question statistics.
*/
private function load_quiz_statistics_for_place(\context $context): ?all_calculated_for_qubaid_condition {
$rcm = new \ReflectionMethod(helper::class, 'load_statistics_for_place');
$rcm->setAccessible(true);
return $rcm->invoke(null, 'mod_quiz', $context);
}
/**
* Data provider for {@see test_load_question_facility()}.
*
@ -307,10 +314,11 @@ class helper_test extends \advanced_testcase {
list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
// Quiz 1 facilities.
$quiz1facility1 = $this->load_question_facility($quiz1, $questions[1]->id);
$quiz1facility2 = $this->load_question_facility($quiz1, $questions[2]->id);
$quiz1facility3 = $this->load_question_facility($quiz1, $questions[3]->id);
$quiz1facility4 = $this->load_question_facility($quiz1, $questions[4]->id);
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz1->cmid));
$quiz1facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility');
$quiz1facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility');
$quiz1facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility');
$quiz1facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility');
$this->assertEquals($expectedquiz1facilities[0], helper::format_percentage($quiz1facility1));
$this->assertEquals($expectedquiz1facilities[1], helper::format_percentage($quiz1facility2));
@ -318,10 +326,11 @@ class helper_test extends \advanced_testcase {
$this->assertEquals($expectedquiz1facilities[3], helper::format_percentage($quiz1facility4));
// Quiz 2 facilities.
$quiz2facility1 = $this->load_question_facility($quiz2, $questions[1]->id);
$quiz2facility2 = $this->load_question_facility($quiz2, $questions[2]->id);
$quiz2facility3 = $this->load_question_facility($quiz2, $questions[3]->id);
$quiz2facility4 = $this->load_question_facility($quiz2, $questions[4]->id);
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz2->cmid));
$quiz2facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility');
$quiz2facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility');
$quiz2facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility');
$quiz2facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility');
$this->assertEquals($expectedquiz2facilities[0], helper::format_percentage($quiz2facility1));
$this->assertEquals($expectedquiz2facilities[1], helper::format_percentage($quiz2facility2));
@ -344,7 +353,7 @@ class helper_test extends \advanced_testcase {
* Data provider for {@see test_load_question_discriminative_efficiency()}.
* @return \Generator
*/
public function load_question_discriminative_efficiency_provider() {
public function load_question_discriminative_efficiency_provider(): \Generator {
yield 'Discriminative efficiency' => [
'Quiz 1 attempts' => [
$this->generate_attempt_answers([1, 0, 0, 0]),
@ -387,10 +396,11 @@ class helper_test extends \advanced_testcase {
list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
// Quiz 1 discriminative efficiency.
$discriminativeefficiency1 = $this->load_question_discriminative_efficiency($quiz1, $questions[1]->id);
$discriminativeefficiency2 = $this->load_question_discriminative_efficiency($quiz1, $questions[2]->id);
$discriminativeefficiency3 = $this->load_question_discriminative_efficiency($quiz1, $questions[3]->id);
$discriminativeefficiency4 = $this->load_question_discriminative_efficiency($quiz1, $questions[4]->id);
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz1->cmid));
$discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency');
$discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency');
$discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency');
$discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency');
$this->assertEquals($expectedquiz1discriminativeefficiency[0],
helper::format_percentage($discriminativeefficiency1, false),
@ -406,10 +416,11 @@ class helper_test extends \advanced_testcase {
"Failure in quiz 1 - question 4 discriminative efficiency");
// Quiz 2 discriminative efficiency.
$discriminativeefficiency1 = $this->load_question_discriminative_efficiency($quiz2, $questions[1]->id);
$discriminativeefficiency2 = $this->load_question_discriminative_efficiency($quiz2, $questions[2]->id);
$discriminativeefficiency3 = $this->load_question_discriminative_efficiency($quiz2, $questions[3]->id);
$discriminativeefficiency4 = $this->load_question_discriminative_efficiency($quiz2, $questions[4]->id);
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz2->cmid));
$discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency');
$discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency');
$discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency');
$discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency');
$this->assertEquals($expectedquiz2discriminativeefficiency[0],
helper::format_percentage($discriminativeefficiency1, false),
@ -448,7 +459,7 @@ class helper_test extends \advanced_testcase {
* Data provider for {@see test_load_question_discrimination_index()}.
* @return \Generator
*/
public function load_question_discrimination_index_provider() {
public function load_question_discrimination_index_provider(): \Generator {
yield 'Discrimination Index' => [
'Quiz 1 attempts' => [
$this->generate_attempt_answers([1, 0, 0, 0]),
@ -491,10 +502,11 @@ class helper_test extends \advanced_testcase {
list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
// Quiz 1 discrimination index.
$discriminationindex1 = $this->load_question_discrimination_index($quiz1, $questions[1]->id);
$discriminationindex2 = $this->load_question_discrimination_index($quiz1, $questions[2]->id);
$discriminationindex3 = $this->load_question_discrimination_index($quiz1, $questions[3]->id);
$discriminationindex4 = $this->load_question_discrimination_index($quiz1, $questions[4]->id);
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz1->cmid));
$discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex');
$discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex');
$discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex');
$discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex');
$this->assertEquals($expectedquiz1discriminationindex[0],
helper::format_percentage($discriminationindex1, false),
@ -510,10 +522,11 @@ class helper_test extends \advanced_testcase {
"Failure in quiz 1 - question 4 discrimination index");
// Quiz 2 discrimination index.
$discriminationindex1 = $this->load_question_discrimination_index($quiz2, $questions[1]->id);
$discriminationindex2 = $this->load_question_discrimination_index($quiz2, $questions[2]->id);
$discriminationindex3 = $this->load_question_discrimination_index($quiz2, $questions[3]->id);
$discriminationindex4 = $this->load_question_discrimination_index($quiz2, $questions[4]->id);
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz2->cmid));
$discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex');
$discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex');
$discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex');
$discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex');
$this->assertEquals($expectedquiz2discriminationindex[0],
helper::format_percentage($discriminationindex1, false),