diff --git a/question/bank/statistics/classes/columns/discrimination_index.php b/question/bank/statistics/classes/columns/discrimination_index.php index 6f565798abc..054e53851e5 100644 --- a/question/bank/statistics/classes/columns/discrimination_index.php +++ b/question/bank/statistics/classes/columns/discrimination_index.php @@ -17,7 +17,6 @@ namespace qbank_statistics\columns; use core_question\local\bank\column_base; -use qbank_statistics\helper; /** * This columns shows a message about whether this question is OK or needs revision. @@ -43,10 +42,14 @@ class discrimination_index extends column_base { return 'discrimination_index'; } + public function get_required_statistics_fields(): array { + return ['discriminationindex']; + } + protected function display_content($question, $rowclasses) { global $PAGE; - // Average discrimination index per quiz. - $discriminationindex = helper::calculate_average_question_discrimination_index($question->id); + + $discriminationindex = $this->qbank->get_aggregate_statistic($question->id, 'discriminationindex'); echo $PAGE->get_renderer('qbank_statistics')->render_discrimination_index($discriminationindex); } diff --git a/question/bank/statistics/classes/columns/discriminative_efficiency.php b/question/bank/statistics/classes/columns/discriminative_efficiency.php index 05e93fdda21..82dd400e9fd 100644 --- a/question/bank/statistics/classes/columns/discriminative_efficiency.php +++ b/question/bank/statistics/classes/columns/discriminative_efficiency.php @@ -17,7 +17,6 @@ namespace qbank_statistics\columns; use core_question\local\bank\column_base; -use qbank_statistics\helper; /** * This column show the average discriminative efficiency for this question. @@ -41,10 +40,14 @@ class discriminative_efficiency extends column_base { return 'discriminative_efficiency'; } + public function get_required_statistics_fields(): array { + return ['discriminativeefficiency']; + } + protected function display_content($question, $rowclasses) { global $PAGE; - // Average discriminative efficiency per quiz. - $discriminativeefficiency = helper::calculate_average_question_discriminative_efficiency($question->id); + + $discriminativeefficiency = $this->qbank->get_aggregate_statistic($question->id, 'discriminativeefficiency'); echo $PAGE->get_renderer('qbank_statistics')->render_discriminative_efficiency($discriminativeefficiency); } diff --git a/question/bank/statistics/classes/columns/facility_index.php b/question/bank/statistics/classes/columns/facility_index.php index 98d050b4493..cae02d81df5 100644 --- a/question/bank/statistics/classes/columns/facility_index.php +++ b/question/bank/statistics/classes/columns/facility_index.php @@ -41,15 +41,18 @@ class facility_index extends column_base { return 'facility_index'; } + public function get_required_statistics_fields(): array { + return ['facility']; + } + protected function display_content($question, $rowclasses) { global $PAGE; - // Average facility index per quiz. - $facility = helper::calculate_average_question_facility($question->id); + + $facility = $this->qbank->get_aggregate_statistic($question->id, 'facility'); echo $PAGE->get_renderer('qbank_statistics')->render_facility_index($facility); } public function get_extra_classes(): array { return ['pr-3']; } - } diff --git a/question/bank/statistics/classes/helper.php b/question/bank/statistics/classes/helper.php index e694a484e15..5832c72834c 100644 --- a/question/bank/statistics/classes/helper.php +++ b/question/bank/statistics/classes/helper.php @@ -40,7 +40,7 @@ class helper { private const NEED_FOR_REVISION_UPPER_THRESHOLD = 50; /** - * For a list of questions find all the places (defined by (component, contextid) where there are attempts. + * For a list of questions find all the places, defined by (component, contextid) where there are attempts. * * @param int[] $questionids array of question ids that we are interested in. * @return \stdClass[] list of objects with fields ->component and ->contextid. diff --git a/question/bank/upgrade.txt b/question/bank/upgrade.txt index ca0a69ec0da..59ff3c79d8b 100644 --- a/question/bank/upgrade.txt +++ b/question/bank/upgrade.txt @@ -1,7 +1,17 @@ This file describes core qbank plugin changes in /question/bank/*, information provided here is intended especially for developers. +=== 4.2.1 === + +* There is a new more effecient way to display statistics in the question bank, you should now override + the get_required_statistics_fields() method in your column class, and then + the values you need will be available from $this->qbank->get_aggregate_statistic(...). + If you are not in a question_bank_column class, you can directly access efficient + statistics-loading from the core_question\local\statistics\statistics_bulk_loader class. + The old method will be deprecated in 4.3. + === 4.1 === + * New functions qbank_usage\helper::get_question_bank_usage_sql and qbank_usage\helper::get_question_attempt_usage_sql have been implemented. When calling a query with the SQL those methods returned, you have to be sure diff --git a/question/classes/local/bank/column_base.php b/question/classes/local/bank/column_base.php index 3fa1c86dc46..3a4898ddae0 100644 --- a/question/classes/local/bank/column_base.php +++ b/question/classes/local/bank/column_base.php @@ -325,6 +325,20 @@ abstract class column_base { return []; } + /** + * If this column requires any aggregated statistics, it should declare that here. + * + * This is those statistics can be efficiently loaded in bulk. + * + * The statistics are all loaded just before load_additional_data is called on each column. + * The values are then available from $this->qbank->get_aggregate_statistic(...); + * + * @return string[] the names of the required statistics fields. E.g. ['facility']. + */ + public function get_required_statistics_fields(): array { + return []; + } + /** * If this column needs extra data (e.g. tags) then load that here. * @@ -332,7 +346,7 @@ abstract class column_base { * Probably a good idea to check that another column has not already * loaded the data you want. * - * @param \stdClass[] $questions the questions that will be displayed. + * @param \stdClass[] $questions the questions that will be displayed, indexed by question id. */ public function load_additional_data(array $questions) { } diff --git a/question/classes/local/bank/view.php b/question/classes/local/bank/view.php index ed3ed159505..722c8a90226 100644 --- a/question/classes/local/bank/view.php +++ b/question/classes/local/bank/view.php @@ -24,14 +24,15 @@ namespace core_question\local\bank; +use core_plugin_manager; +use core_question\bank\search\condition; +use core_question\local\statistics\statistics_bulk_loader; +use qbank_columnsortorder\column_manager; +use qbank_editquestion\editquestion_helper; + defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/editlib.php'); -use core_plugin_manager; -use core_question\bank\search\condition; -use qbank_columnsortorder\column_manager; -use qbank_editquestion\editquestion_helper; -use qbank_managecategories\helper; /** * This class prints a view of the question bank. @@ -90,19 +91,19 @@ class view { public $course; /** - * @var \question_bank_column_base[] these are all the 'columns' that are + * @var column_base[] these are all the 'columns' that are * part of the display. Array keys are the class name. */ protected $requiredcolumns; /** - * @var \question_bank_column_base[] these are the 'columns' that are + * @var column_base[] these are the 'columns' that are * actually displayed as a column, in order. Array keys are the class name. */ protected $visiblecolumns; /** - * @var \question_bank_column_base[] these are the 'columns' that are + * @var column_base[] these are the 'columns' that are * actually displayed as an additional row (e.g. question text), in order. * Array keys are the class name. */ @@ -139,6 +140,15 @@ class view { */ protected $sqlparams; + /** + * @var ?array Stores all the average statistics that this question bank view needs. + * + * This field gets initialised in {@see display_question_list()}. It is a two dimensional + * $this->loadedstatistics[$questionid][$fieldname] = $average value of that statistics for that question. + * Column classes in qbank plugins can access these values using {@see get_aggregate_statistic()}. + */ + protected $loadedstatistics = null; + /** * @var condition[] search conditions. */ @@ -979,6 +989,11 @@ class view { } } $questionsrs->close(); + + // Bulk load any required statistics. + $this->load_required_statistics($questions); + + // Bulk load any extra data that any column requires. foreach ($this->requiredcolumns as $name => $column) { $column->load_additional_data($questions); } @@ -1005,6 +1020,60 @@ class view { echo \html_writer::end_tag('form'); } + /** + * Work out the list of all the required statistics fields for this question bank view. + * + * This gathers all the required fields from all columns, so they can all be loaded at once. + * + * @return string[] the names of all the required fields for this question bank view. + */ + protected function determine_required_statistics(): array { + $requiredfields = []; + foreach ($this->requiredcolumns as $column) { + $requiredfields = array_merge($requiredfields, $column->get_required_statistics_fields()); + } + + return array_unique($requiredfields); + } + + /** + * Load the aggregate statistics that all the columns require. + * + * @param \stdClass[] $questions the questions that will be displayed indexed by question id. + */ + protected function load_required_statistics(array $questions): void { + $requiredstatistics = $this->determine_required_statistics(); + $this->loadedstatistics = statistics_bulk_loader::load_aggregate_statistics( + array_keys($questions), $requiredstatistics); + } + + /** + * Get the aggregated value of a particular statistic for a particular question. + * + * You can only get values for the questions on the current page of the question bank view, + * and only if you declared the need for this statistic in the get_required_statistics_fields() + * method of your question bank column. + * + * @param int $questionid the id of a question + * @param string $fieldname the name of a statistics field, e.g. 'facility'. + * @return float|null the average (across all users) of this statistic for this question. + * Null if the value is not available right now. + */ + public function get_aggregate_statistic(int $questionid, string $fieldname): ?float { + if (!array_key_exists($questionid, $this->loadedstatistics)) { + throw new \coding_exception('Question ' . $questionid . ' is not on the current page of ' . + 'this question bank view, so its statistics are not available.'); + } + + // Must be array_key_exists, not isset, because we care about null values. + if (!array_key_exists($fieldname, $this->loadedstatistics[$questionid])) { + throw new \coding_exception('Statistics field ' . $fieldname . ' was not requested by any ' . + 'question bank column in this view, so it is not available.'); + } + + return $this->loadedstatistics[$questionid][$fieldname]; + } + /** * Display the top pagination bar. * diff --git a/question/classes/local/statistics/statistics_bulk_loader.php b/question/classes/local/statistics/statistics_bulk_loader.php new file mode 100644 index 00000000000..60ad643a255 --- /dev/null +++ b/question/classes/local/statistics/statistics_bulk_loader.php @@ -0,0 +1,170 @@ +. + +namespace core_question\local\statistics; + +use core_question\local\bank\column_base; +use core_question\statistics\questions\all_calculated_for_qubaid_condition; +use core_component; + +/** + * Helper to efficiently load all the statistics for a set of questions. + * + * If you are implementing a question bank column, do not use this method directly. + * Instead, override the {@see column_base::get_required_statistics_fields()} method + * in your column class, and the question bank view will take care of it for you. + * + * @package core_question + * @copyright 2023 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class statistics_bulk_loader { + + /** + * Load and aggregate the requested statistics for all the places where the given questions are used. + * + * The returned array will contain a values for each questionid and field, which will be null if the value is not available. + * + * @param int[] $questionids array of question ids. + * @param string[] $requiredstatistics array of the fields required, e.g. ['facility', 'discriminationindex']. + * @return float[][] if a value is not available, it will be set to null. + */ + public static function load_aggregate_statistics(array $questionids, array $requiredstatistics): array { + $places = self::get_all_places_where_questions_were_attempted($questionids); + + // Set up blank two-dimensional arrays to store the running totals. Indexed by questionid and field name. + $zerovaluesforonequestion = array_combine($requiredstatistics, array_fill(0, count($requiredstatistics), 0)); + $counts = array_combine($questionids, array_fill(0, count($questionids), $zerovaluesforonequestion)); + $sums = array_combine($questionids, array_fill(0, count($questionids), $zerovaluesforonequestion)); + + // Load the data for each place, and add to the running totals. + 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) { + foreach ($requiredstatistics as $item) { + $value = self::extract_item_value($statistics, $questionid, $item); + if ($value === null) { + continue; + } + + $counts[$questionid][$item] += 1; + $sums[$questionid][$item] += $value; + } + } + } + + // Compute the averages from the final totals. + $aggregates = []; + foreach ($questionids as $questionid) { + $aggregates[$questionid] = []; + foreach ($requiredstatistics as $item) { + if ($counts[$questionid][$item] > 0) { + $aggregates[$questionid][$item] = $sums[$questionid][$item] / $counts[$questionid][$item]; + } else { + $aggregates[$questionid][$item] = null; + } + + } + } + + return $aggregates; + } + + /** + * For a list of questions find all the places, defined by (component, contextid), where there are attempts. + * + * @param int[] $questionids array of question ids that we are interested in. + * @return \stdClass[] list of objects with fields ->component and ->contextid. + */ + protected static function get_all_places_where_questions_were_attempted(array $questionids): array { + global $DB; + + [$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 $questionidcondition + GROUP BY qu.component, qu.contextid + ORDER BY qu.contextid ASC + ", $params); + + // Strip out the unwanted ids. + $places = array_values($places); + foreach ($places as $place) { + unset($place->somethingunique); + } + + return $places; + } + + /** + * Load the question statistics for all the attempts belonging to a particular component in a particular context. + * + * @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. + */ + protected 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; + } + + if (!component_callback_exists($component, 'calculate_question_stats')) { + return null; + } + + return component_callback($component, 'calculate_question_stats', [$context]); + } + + /** + * Extract the value for one question and one type of statistic from a set of statistics. + * + * @param all_calculated_for_qubaid_condition $statistics the batch of statistics. + * @param int $questionid a question id. + * @param string $item one of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'. + * @return float|null the required value. + */ + protected 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, look in sub questions. + foreach ($statistics->subquestionstats as $stats) { + if ($stats->questionid == $questionid && isset($stats->$item)) { + return $stats->$item; + } + } + + return null; + } +} diff --git a/question/tests/local/statistics/statistics_bulk_loader_test.php b/question/tests/local/statistics/statistics_bulk_loader_test.php new file mode 100644 index 00000000000..60f3d236e10 --- /dev/null +++ b/question/tests/local/statistics/statistics_bulk_loader_test.php @@ -0,0 +1,550 @@ +. + +namespace core_question\local\statistics; + +use advanced_testcase; +use context; +use context_module; +use core_question\statistics\questions\all_calculated_for_qubaid_condition; +use core_question_generator; +use Generator; +use mod_quiz\quiz_settings; +use mod_quiz\quiz_attempt; +use question_engine; +use ReflectionMethod; + +/** + * Tests for question statistics. + * + * @package core_question + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @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 { + + /** @var float Delta used when comparing statistics values out-of 1. */ + protected const DELTA = 0.00005; + + /** @var float Delta used when comparing statistics values out-of 100. */ + protected const PERCENT_DELTA = 0.005; + + /** + * Test quizzes that contain a specified question. + * + * @covers ::get_all_places_where_questions_were_attempted + */ + public function test_get_all_places_where_questions_were_attempted(): void { + $this->resetAfterTest(); + $this->setAdminUser(); + + $rcm = new ReflectionMethod(statistics_bulk_loader::class, 'get_all_places_where_questions_were_attempted'); + $rcm->setAccessible(true); + + // Create a course. + $course = $this->getDataGenerator()->create_course(); + + // 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' + ]); + $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, ['category' => $cat->id]); + $question2 = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]); + + // Add question 1 to quiz 1 and make an attempt. + quiz_add_quiz_question($question1->id, $quiz1); + // Quiz 1 attempt. + $this->submit_quiz($quiz1, [1 => ['answer' => 'frog']]); + + // 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, [1 => ['answer' => 'frog'], 2 => ['answer' => 10]]); + + // Checking quizzes that use question 1. + $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. + $q2places = $rcm->invoke(null, [$question2->id]); + $this->assertCount(1, $q2places); + $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q2places[0]); + + // Add a random question to quiz3. + quiz_add_random_questions($quiz3, 0, $cat->id, 1, false); + $this->submit_quiz($quiz3, [1 => ['answer' => 'willbewrong']]); + + // 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); + } + $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz3context->id], $newplace); + } + + /** + * Create 2 quizzes. + * + * @return array return 2 quizzes + */ + private function prepare_quizzes(): array { + // Create a course. + $course = $this->getDataGenerator()->create_course(); + + // Make 2 quizzes. + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + $layout = '1,2,0,3,4,0'; + $quiz1 = $quizgenerator->create_instance([ + 'course' => $course->id, + 'grade' => 100.0, 'sumgrades' => 2, + 'layout' => $layout + ]); + + $quiz2 = $quizgenerator->create_instance([ + 'course' => $course->id, + 'grade' => 100.0, 'sumgrades' => 2, + 'layout' => $layout + ]); + + /** @var core_question_generator $questiongenerator */ + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(); + + $page = 1; + $questions = []; + foreach (explode(',', $layout) as $slot) { + if ($slot == 0) { + $page += 1; + continue; + } + + $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); + $questions[$slot] = $question; + quiz_add_quiz_question($question->id, $quiz1, $page); + quiz_add_quiz_question($question->id, $quiz2, $page); + } + + return [$quiz1, $quiz2, $questions]; + } + + /** + * Submit quiz answers + * + * @param object $quiz + * @param array $answers + */ + private function submit_quiz(object $quiz, array $answers): void { + // Create user. + $user = $this->getDataGenerator()->create_user(); + // Create attempt. + $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); + $timenow = time(); + $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. + $attemptobj = quiz_attempt::create($attempt->id); + $attemptobj->process_submitted_actions($timenow, false, $answers); + $attemptobj->process_finish($timenow, false); + } + + /** + * Generate attempt answers. + * + * @param array $correctanswerflags array of 1 or 0 + * 1 : generate correct answer + * 0 : generate wrong answer + * + * @return array + */ + private function generate_attempt_answers(array $correctanswerflags): array { + $attempt = []; + for ($i = 1; $i <= 4; $i++) { + if (isset($correctanswerflags) && $correctanswerflags[$i - 1] == 1) { + // Correct answer. + $attempt[$i] = ['answer' => 'frog']; + } else { + $attempt[$i] = ['answer' => 'false']; + } + } + return $attempt; + } + + /** + * Generate quizzes and submit answers. + * + * @param array $quiz1attempts quiz 1 attempts + * @param array $quiz2attempts quiz 2 attempts + * + * @return array + */ + private function prepare_and_submit_quizzes(array $quiz1attempts, array $quiz2attempts): array { + list($quiz1, $quiz2, $questions) = $this->prepare_quizzes(); + // Submit attempts of quiz1. + foreach ($quiz1attempts as $attempt) { + $this->submit_quiz($quiz1, $attempt); + } + // Submit attempts of quiz2. + foreach ($quiz2attempts as $attempt) { + $this->submit_quiz($quiz2, $attempt); + } + 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 one 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(statistics_bulk_loader::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(statistics_bulk_loader::class, 'load_statistics_for_place'); + $rcm->setAccessible(true); + return $rcm->invoke(null, 'mod_quiz', $context); + } + + /** + * Data provider for {@see test_load_question_facility()}. + * + * @return Generator + */ + public function load_question_facility_provider(): Generator { + yield 'Facility case 1' => [ + 'Quiz 1 attempts' => [ + $this->generate_attempt_answers([1, 0, 0, 0]), + ], + 'Expected quiz 1 facilities' => [1.0, 0.0, 0.0, 0.0], + 'Quiz 2 attempts' => [ + $this->generate_attempt_answers([1, 0, 0, 0]), + $this->generate_attempt_answers([1, 1, 0, 0]), + ], + 'Expected quiz 2 facilities' => [1.0, 0.5, 0.0, 0.0], + 'Expected average facilities' => [1.0, 0.25, 0.0, 0.0], + ]; + yield 'Facility case 2' => [ + 'Quiz 1 attempts' => [ + $this->generate_attempt_answers([1, 0, 0, 0]), + $this->generate_attempt_answers([1, 1, 0, 0]), + $this->generate_attempt_answers([1, 1, 1, 0]), + ], + 'Expected quiz 1 facilities' => [1.0, 0.6667, 0.3333, 0.0], + 'Quiz 2 attempts' => [ + $this->generate_attempt_answers([1, 0, 0, 0]), + $this->generate_attempt_answers([1, 1, 0, 0]), + $this->generate_attempt_answers([1, 1, 1, 0]), + $this->generate_attempt_answers([1, 1, 1, 1]), + ], + 'Expected quiz 2 facilities' => [1.0, 0.75, 0.5, 0.25], + 'Expected average facilities' => [1.0, 0.7083, 0.4167, 0.1250], + ]; + } + + /** + * Test question facility + * + * @dataProvider load_question_facility_provider + * + * @param array $quiz1attempts quiz 1 attempts + * @param array $expectedquiz1facilities expected quiz 1 facilities + * @param array $quiz2attempts quiz 2 attempts + * @param array $expectedquiz2facilities expected quiz 2 facilities + * @param array $expectedaveragefacilities expected average facilities + */ + public function test_load_question_facility( + array $quiz1attempts, + array $expectedquiz1facilities, + array $quiz2attempts, + array $expectedquiz2facilities, + array $expectedaveragefacilities) + : void { + $this->resetAfterTest(); + + list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); + + // Quiz 1 facilities. + $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->assertEqualsWithDelta($expectedquiz1facilities[0], $quiz1facility1, self::DELTA); + $this->assertEqualsWithDelta($expectedquiz1facilities[1], $quiz1facility2, self::DELTA); + $this->assertEqualsWithDelta($expectedquiz1facilities[2], $quiz1facility3, self::DELTA); + $this->assertEqualsWithDelta($expectedquiz1facilities[3], $quiz1facility4, self::DELTA); + + // Quiz 2 facilities. + $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->assertEqualsWithDelta($expectedquiz2facilities[0], $quiz2facility1, self::DELTA); + $this->assertEqualsWithDelta($expectedquiz2facilities[1], $quiz2facility2, self::DELTA); + $this->assertEqualsWithDelta($expectedquiz2facilities[2], $quiz2facility3, self::DELTA); + $this->assertEqualsWithDelta($expectedquiz2facilities[3], $quiz2facility4, self::DELTA); + + // Average question facilities. + $stats = statistics_bulk_loader::load_aggregate_statistics( + [$questions[1]->id, $questions[2]->id, $questions[3]->id, $questions[4]->id], + ['facility'] + ); + + $this->assertEqualsWithDelta($expectedaveragefacilities[0], + $stats[$questions[1]->id]['facility'], self::DELTA); + $this->assertEqualsWithDelta($expectedaveragefacilities[1], + $stats[$questions[2]->id]['facility'], self::DELTA); + $this->assertEqualsWithDelta($expectedaveragefacilities[2], + $stats[$questions[3]->id]['facility'], self::DELTA); + $this->assertEqualsWithDelta($expectedaveragefacilities[3], + $stats[$questions[4]->id]['facility'], self::DELTA); + } + + /** + * Data provider for {@see test_load_question_discriminative_efficiency()}. + * @return Generator + */ + public function load_question_discriminative_efficiency_provider(): Generator { + yield 'Discriminative efficiency' => [ + 'Quiz 1 attempts' => [ + $this->generate_attempt_answers([1, 0, 0, 0]), + $this->generate_attempt_answers([1, 1, 0, 0]), + $this->generate_attempt_answers([1, 0, 1, 0]), + $this->generate_attempt_answers([1, 1, 1, 1]), + ], + 'Expected quiz 1 discriminative efficiency' => [null, 33.33, 33.33, 100.00], + 'Quiz 2 attempts' => [ + $this->generate_attempt_answers([1, 1, 1, 1]), + $this->generate_attempt_answers([0, 0, 0, 0]), + $this->generate_attempt_answers([1, 0, 0, 1]), + $this->generate_attempt_answers([0, 1, 1, 0]), + ], + 'Expected quiz 2 discriminative efficiency' => [50.00, 50.00, 50.00, 50.00], + 'Expected average discriminative efficiency' => [50.00, 41.67, 41.67, 75.00], + ]; + } + + /** + * Test discriminative efficiency + * + * @dataProvider load_question_discriminative_efficiency_provider + * + * @param array $quiz1attempts quiz 1 attempts + * @param array $expectedquiz1discriminativeefficiency expected quiz 1 discriminative efficiency + * @param array $quiz2attempts quiz 2 attempts + * @param array $expectedquiz2discriminativeefficiency expected quiz 2 discriminative efficiency + * @param array $expectedaveragediscriminativeefficiency expected average discriminative efficiency + */ + public function test_load_question_discriminative_efficiency( + array $quiz1attempts, + array $expectedquiz1discriminativeefficiency, + array $quiz2attempts, + array $expectedquiz2discriminativeefficiency, + array $expectedaveragediscriminativeefficiency + ): void { + $this->resetAfterTest(); + + list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); + + // Quiz 1 discriminative efficiency. + $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->assertEqualsWithDelta($expectedquiz1discriminativeefficiency[0], + $discriminativeefficiency1, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz1discriminativeefficiency[1], + $discriminativeefficiency2, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz1discriminativeefficiency[2], + $discriminativeefficiency3, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz1discriminativeefficiency[3], + $discriminativeefficiency4, self::PERCENT_DELTA); + + // Quiz 2 discriminative efficiency. + $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->assertEqualsWithDelta($expectedquiz2discriminativeefficiency[0], + $discriminativeefficiency1, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz2discriminativeefficiency[1], + $discriminativeefficiency2, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz2discriminativeefficiency[2], + $discriminativeefficiency3, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz2discriminativeefficiency[3], + $discriminativeefficiency4, self::PERCENT_DELTA); + + // Average question discriminative efficiency. + $stats = statistics_bulk_loader::load_aggregate_statistics( + [$questions[1]->id, $questions[2]->id, $questions[3]->id, $questions[4]->id], + ['discriminativeefficiency'] + ); + + $this->assertEqualsWithDelta($expectedaveragediscriminativeefficiency[0], + $stats[$questions[1]->id]['discriminativeefficiency'], self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedaveragediscriminativeefficiency[1], + $stats[$questions[2]->id]['discriminativeefficiency'], self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedaveragediscriminativeefficiency[2], + $stats[$questions[3]->id]['discriminativeefficiency'], self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedaveragediscriminativeefficiency[3], + $stats[$questions[4]->id]['discriminativeefficiency'], self::PERCENT_DELTA); + } + + /** + * Data provider for {@see test_load_question_discrimination_index()}. + * @return Generator + */ + public function load_question_discrimination_index_provider(): Generator { + yield 'Discrimination Index' => [ + 'Quiz 1 attempts' => [ + $this->generate_attempt_answers([1, 0, 0, 0]), + $this->generate_attempt_answers([1, 1, 0, 0]), + $this->generate_attempt_answers([1, 0, 1, 0]), + $this->generate_attempt_answers([1, 1, 1, 1]), + ], + 'Expected quiz 1 Discrimination Index' => [null, 30.15, 30.15, 81.65], + 'Quiz 2 attempts' => [ + $this->generate_attempt_answers([1, 1, 1, 1]), + $this->generate_attempt_answers([0, 0, 0, 0]), + $this->generate_attempt_answers([1, 0, 0, 1]), + $this->generate_attempt_answers([0, 1, 1, 0]), + ], + 'Expected quiz 2 discrimination Index' => [44.72, 44.72, 44.72, 44.72], + 'Expected average discrimination Index' => [44.72, 37.44, 37.44, 63.19], + ]; + } + + /** + * Test discrimination index + * + * @dataProvider load_question_discrimination_index_provider + * + * @param array $quiz1attempts quiz 1 attempts + * @param array $expectedquiz1discriminationindex expected quiz 1 discrimination index + * @param array $quiz2attempts quiz 2 attempts + * @param array $expectedquiz2discriminationindex expected quiz 2 discrimination index + * @param array $expectedaveragediscriminationindex expected average discrimination index + */ + public function test_load_question_discrimination_index( + array $quiz1attempts, + array $expectedquiz1discriminationindex, + array $quiz2attempts, + array $expectedquiz2discriminationindex, + array $expectedaveragediscriminationindex + ): void { + $this->resetAfterTest(); + + list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); + + // Quiz 1 discrimination index. + $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->assertEqualsWithDelta($expectedquiz1discriminationindex[0], + $discriminationindex1, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz1discriminationindex[1], + $discriminationindex2, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz1discriminationindex[2], + $discriminationindex3, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz1discriminationindex[3], + $discriminationindex4, self::PERCENT_DELTA); + + // Quiz 2 discrimination index. + $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->assertEqualsWithDelta($expectedquiz2discriminationindex[0], + $discriminationindex1, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz2discriminationindex[1], + $discriminationindex2, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz2discriminationindex[2], + $discriminationindex3, self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedquiz2discriminationindex[3], + $discriminationindex4, self::PERCENT_DELTA); + + // Average question discrimination index. + $stats = statistics_bulk_loader::load_aggregate_statistics( + [$questions[1]->id, $questions[2]->id, $questions[3]->id, $questions[4]->id], + ['discriminationindex'] + ); + + $this->assertEqualsWithDelta($expectedaveragediscriminationindex[0], + $stats[$questions[1]->id]['discriminationindex'], self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedaveragediscriminationindex[1], + $stats[$questions[2]->id]['discriminationindex'], self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedaveragediscriminationindex[2], + $stats[$questions[3]->id]['discriminationindex'], self::PERCENT_DELTA); + $this->assertEqualsWithDelta($expectedaveragediscriminationindex[3], + $stats[$questions[4]->id]['discriminationindex'], self::PERCENT_DELTA); + } +}