diff --git a/mod/quiz/classes/question/qubaids_for_users_attempts.php b/mod/quiz/classes/question/qubaids_for_users_attempts.php new file mode 100644 index 00000000000..88decd598f3 --- /dev/null +++ b/mod/quiz/classes/question/qubaids_for_users_attempts.php @@ -0,0 +1,74 @@ +. + +/** + * A {@link qubaid_condition} representing all the attempts by one user at a given quiz. + * + * @package mod_quiz + * @category question + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_quiz\question; +defined('MOODLE_INTERNAL') || die(); + + +/** + * A {@link qubaid_condition} representing all the attempts by one user at a given quiz. + * + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qubaids_for_users_attempts extends \qubaid_join { + /** + * Constructor. + * + * This takes the same arguments as {@link quiz_get_user_attempts()}. + * + * @param int $quizid the quiz id. + * @param int $userid the userid. + * @param string $status 'all', 'finished' or 'unfinished' to control + * @param bool $includepreviews defaults to false. + */ + public function __construct($quizid, $userid, $status = 'finished', $includepreviews = false) { + $where = 'quiza.quiz = :quizaquiz AND quiza.userid = :userid'; + $params = array('quizaquiz' => $quizid, 'userid' => $userid); + + if (!$includepreviews) { + $where .= ' AND preview = 0'; + } + + switch ($status) { + case 'all': + break; + + case 'finished': + $where .= ' AND state IN (:state1, :state2)'; + $params['state1'] = \quiz_attempt::FINISHED; + $params['state2'] = \quiz_attempt::ABANDONED; + break; + + case 'unfinished': + $where .= ' AND state IN (:state1, :state2)'; + $params['state1'] = \quiz_attempt::IN_PROGRESS; + $params['state2'] = \quiz_attempt::OVERDUE; + break; + } + + parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); + } +} diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 8ea4888f693..1cba6a72fe6 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -158,46 +158,88 @@ function quiz_create_attempt(quiz $quizobj, $attemptnumber, $lastattempt, $timen */ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timenow, $questionids = array(), $forcedvariantsbyslot = array()) { + + // Usages for this user's previous quiz attempts. + $qubaids = new \mod_quiz\question\qubaids_for_users_attempts( + $quizobj->get_quizid(), $attempt->userid); + // Fully load all the questions in this quiz. $quizobj->preload_questions(); $quizobj->load_questions(); - // Add them all to the $quba. - $questionsinuse = array_keys($quizobj->get_questions()); + // First load all the non-random questions. + $randomfound = false; + $slot = 0; + $questions = array(); + $maxmark = array(); foreach ($quizobj->get_questions() as $questiondata) { - if ($questiondata->qtype != 'random') { - if (!$quizobj->get_quiz()->shuffleanswers) { - $questiondata->options->shuffleanswers = false; - } - $question = question_bank::make_question($questiondata); + $slot += 1; + $maxmark[$slot] = $questiondata->maxmark; + if ($questiondata->qtype == 'random') { + $randomfound = true; + continue; + } + if (!$quizobj->get_quiz()->shuffleanswers) { + $questiondata->options->shuffleanswers = false; + } + $questions[$slot] = question_bank::make_question($questiondata); + } - } else { - if (!isset($questionids[$quba->next_slot_number()])) { - $forcequestionid = null; + // Then find a question to go in place of each random question. + if ($randomfound) { + $slot = 0; + $usedquestionids = array(); + foreach ($questions as $question) { + if (isset($usedquestions[$question->id])) { + $usedquestionids[$question->id] += 1; } else { - $forcequestionid = $questionids[$quba->next_slot_number()]; + $usedquestionids[$question->id] = 1; + } + } + $randomloader = new \core_question\bank\random_question_loader($qubaids, $usedquestionids); + + foreach ($quizobj->get_questions() as $questiondata) { + $slot += 1; + if ($questiondata->qtype != 'random') { + continue; } - $question = question_bank::get_qtype('random')->choose_other_question( - $questiondata, $questionsinuse, $quizobj->get_quiz()->shuffleanswers, $forcequestionid); - if (is_null($question)) { + // Deal with fixed random choices for testing. + if (isset($questionids[$quba->next_slot_number()])) { + if ($randomloader->is_question_available($questiondata->category, + (bool) $questiondata->questiontext, $questionids[$quba->next_slot_number()])) { + $questions[$slot] = question_bank::load_question( + $questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers); + continue; + } else { + throw new coding_exception('Forced question id not available.'); + } + } + + // Normal case, pick one at random. + $questionid = $randomloader->get_next_question_id($questiondata->category, + (bool) $questiondata->questiontext); + if ($questionid === null) { throw new moodle_exception('notenoughrandomquestions', 'quiz', $quizobj->view_url(), $questiondata); } - } - $quba->add_question($question, $questiondata->maxmark); - $questionsinuse[] = $question->id; + $questions[$slot] = question_bank::load_question($questionid, + $quizobj->get_quiz()->shuffleanswers); + } + } + + // Finally add them all to the usage. + ksort($questions); + foreach ($questions as $slot => $question) { + $newslot = $quba->add_question($question, $maxmark[$slot]); + if ($newslot != $slot) { + throw new coding_exception('Slot numbers have got confused.'); + } } // Start all the questions. - if ($attempt->preview) { - $variantoffset = rand(1, 100); - } else { - $variantoffset = $attemptnumber; - } - $variantstrategy = new question_variant_pseudorandom_no_repeats_strategy( - $variantoffset, $attempt->userid, $quizobj->get_quizid()); + $variantstrategy = new core_question\engine\variants\least_used_strategy($quba, $qubaids); if (!empty($forcedvariantsbyslot)) { $forcedvariantsbyseed = question_variant_forced_choices_selection_strategy::prepare_forced_choices_array( diff --git a/mod/quiz/tests/lib_test.php b/mod/quiz/tests/lib_test.php index 25684db32d7..75edda7fd73 100644 --- a/mod/quiz/tests/lib_test.php +++ b/mod/quiz/tests/lib_test.php @@ -205,6 +205,9 @@ class mod_quiz_lib_testcase extends advanced_testcase { $attemptobj->process_finish($timenow, false); // Start the failing attempt. + $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, $failstudent->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); diff --git a/question/classes/bank/random_question_loader.php b/question/classes/bank/random_question_loader.php new file mode 100644 index 00000000000..d897c81607e --- /dev/null +++ b/question/classes/bank/random_question_loader.php @@ -0,0 +1,223 @@ +. + +/** + * A class for efficiently finds questions at random from the question bank. + * + * @package core_question + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\bank; + + +/** + * This class efficiently finds questions at random from the question bank. + * + * You can ask for questions at random one at a time. Each time you ask, you + * pass a category id, and whether to pick from that category and all subcategories + * or just that category. + * + * The number of teams each question has been used is tracked, and we will always + * return a question from among those elegible that has been used the fewest times. + * So, if there are questions that have not been used yet in the category asked for, + * one of those will be returned. However, within one instantiation of this class, + * we will never return a given question more than once, and we will never return + * questions passed into the constructor as $usedquestions. + * + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class random_question_loader { + /** @var \qubaid_condition which usages to consider previous attempts from. */ + protected $qubaids; + + /** @var array qtypes that cannot be used by random questions. */ + protected $excludedqtypes; + + /** @var array categoryid & include subcategories => num previous uses => questionid => 1. */ + protected $availablequestionscache = array(); + + /** + * @var array questionid => num recent uses. Questions that have been used, + * but that is not yet recorded in the DB. + */ + protected $recentlyusedquestions; + + /** + * Constructor. + * @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question. + * @param array $usedquestions questionid => number of times used count. If we should allow for + * further existing uses of a question in addition to the ones in $qubaids. + */ + public function __construct(\qubaid_condition $qubaids, array $usedquestions = array()) { + $this->qubaids = $qubaids; + $this->recentlyusedquestions = $usedquestions; + + foreach (\question_bank::get_all_qtypes() as $qtype) { + if (!$qtype->is_usable_by_random()) { + $this->excludedqtypes[] = $qtype->name(); + } + } + } + + /** + * Pick a question at random from the given category, from among those with the fewest uses. + * + * It is up the the caller to verify that the cateogry exists. An unknown category + * behaves like an empty one. + * + * @param int $categoryid the id of a category in the question bank. + * @param bool $includesubcategories wether to pick a question from exactly + * that category, or that category and subcategories. + * @return int|null the id of the question picked, or null if there aren't any. + */ + public function get_next_question_id($categoryid, $includesubcategories) { + $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories); + + $categorykey = $this->get_category_key($categoryid, $includesubcategories); + if (empty($this->availablequestionscache[$categorykey])) { + return null; + } + + reset($this->availablequestionscache[$categorykey]); + $lowestcount = key($this->availablequestionscache[$categorykey]); + reset($this->availablequestionscache[$categorykey][$lowestcount]); + $questionid = key($this->availablequestionscache[$categorykey][$lowestcount]); + $this->use_question($questionid); + return $questionid; + } + + /** + * Get the key into {@link $availablequestionscache} for this combination of options. + * @param int $categoryid the id of a category in the question bank. + * @param bool $includesubcategories wether to pick a question from exactly + * that category, or that category and subcategories. + * @return string the cache key. + */ + protected function get_category_key($categoryid, $includesubcategories) { + if ($includesubcategories) { + return $categoryid . '|1'; + } else { + return $categoryid . '|0'; + } + } + + /** + * Populate {@link $availablequestionscache} for this combination of options. + * @param int $categoryid the id of a category in the question bank. + * @param bool $includesubcategories wether to pick a question from exactly + * that category, or that category and subcategories. + */ + protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories) { + global $DB; + + $categorykey = $this->get_category_key($categoryid, $includesubcategories); + + if (isset($this->availablequestionscache[$categorykey])) { + // Data is already in the cache, nothing to do. + return; + } + + // Load the available questions from the question bank. + if ($includesubcategories) { + $categoryids = question_categorylist($categoryid); + } else { + $categoryids = array($categoryid); + } + + list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes, + SQL_PARAMS_NAMED, 'excludedqtype', false); + + $questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_with_usage_counts( + $categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams); + if (!$questionidsandcounts) { + // No questions in this category. + $this->availablequestionscache[$categorykey] = array(); + return; + } + + // Put all the questions with each value of $prevusecount in separate arrays. + $idsbyusecount = array(); + foreach ($questionidsandcounts as $questionid => $prevusecount) { + if (isset($this->recentlyusedquestions[$questionid])) { + // Recently used questions are never returned. + continue; + } + $idsbyusecount[$prevusecount][] = $questionid; + } + + // Now put that data into our cache. For each count, we need to shuffle + // questionids, and make those the keys of an array. + $this->availablequestionscache[$categorykey] = array(); + foreach ($idsbyusecount as $prevusecount => $questionids) { + shuffle($questionids); + $this->availablequestionscache[$categorykey][$prevusecount] = array_combine( + $questionids, array_fill(0, count($questionids), 1)); + } + ksort($this->availablequestionscache[$categorykey]); + } + + /** + * Update the internal data structures to indicate that a given question has + * been used one more time. + * + * @param int $questionid the question that is being used. + */ + protected function use_question($questionid) { + if (isset($this->recentlyusedquestions[$questionid])) { + $this->recentlyusedquestions[$questionid] += 1; + } else { + $this->recentlyusedquestions[$questionid] = 1; + } + + foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) { + foreach ($questionsforcategory as $numuses => $questionids) { + if (!isset($questionids[$questionid])) { + continue; + } + unset($this->availablequestionscache[$categorykey][$numuses][$questionid]); + if (empty($this->availablequestionscache[$categorykey][$numuses])) { + unset($this->availablequestionscache[$categorykey][$numuses]); + } + } + } + } + + /** + * Check whether a given question is available in a given category. If so, mark it used. + * + * @param int $categoryid the id of a category in the question bank. + * @param bool $includesubcategories wether to pick a question from exactly + * that category, or that category and subcategories. + * @param int $questionid the question that is being used. + * @return bool whether the question is available in the requested category. + */ + public function is_question_available($categoryid, $includesubcategories, $questionid) { + $this->ensure_questions_for_category_loaded($categoryid, $includesubcategories); + $categorykey = $this->get_category_key($categoryid, $includesubcategories); + + foreach ($this->availablequestionscache[$categorykey] as $questionids) { + if (isset($questionids[$questionid])) { + $this->use_question($questionid); + return true; + } + } + + return false; + } +} diff --git a/question/classes/engine/variants/least_used_strategy.php b/question/classes/engine/variants/least_used_strategy.php new file mode 100644 index 00000000000..0772659ca11 --- /dev/null +++ b/question/classes/engine/variants/least_used_strategy.php @@ -0,0 +1,128 @@ +. + +/** + * A {@link \question_variant_selection_strategy} that randomly selects variants that were not used yet. + * + * @package core_question + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +namespace core_question\engine\variants; +defined('MOODLE_INTERNAL') || die(); + + +/** + * A {@link \question_variant_selection_strategy} that randomly selects variants that were not used yet. + * + * If all variants have been used at least once in the set of usages under + * consideration, then then it picks one of the least often used. + * + * Within one particular use of this class, each seed will always select the + * same variant. This is so that shared datasets work in calculated questions, + * and similar features in question types like varnumeric and STACK. + * + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class least_used_strategy implements \question_variant_selection_strategy { + + /** @var array seed => variant number => number of uses. */ + protected $variantsusecounts = array(); + + /** @var array seed => variant number. */ + protected $selectedvariant = array(); + + /** + * Constructor. + * @param question_usage_by_activity $quba the question usage we will be picking variants for. + * @param qubaid_condition $qubaids ids of the usages to consider when counting previous uses of each variant. + */ + public function __construct(\question_usage_by_activity $quba, \qubaid_condition $qubaids) { + $questionidtoseed = array(); + foreach ($quba->get_attempt_iterator() as $qa) { + $question = $qa->get_question(); + if ($question->get_num_variants() > 1) { + $questionidtoseed[$question->id] = $question->get_variants_selection_seed(); + } + } + + if (empty($questionidtoseed)) { + return; + } + + $this->variantsusecounts = array_fill_keys($questionidtoseed, array()); + + $variantsused = \question_engine::load_used_variants(array_keys($questionidtoseed), $qubaids); + foreach ($variantsused as $questionid => $usagecounts) { + $seed = $questionidtoseed[$questionid]; + foreach ($usagecounts as $variant => $count) { + if (isset($this->variantsusecounts[$seed][$variant])) { + $this->variantsusecounts[$seed][$variant] += $count; + } else { + $this->variantsusecounts[$seed][$variant] = $count; + } + } + } + } + + public function choose_variant($maxvariants, $seed) { + if ($maxvariants == 1) { + return 1; + } + + if (isset($this->selectedvariant[$seed])) { + return $this->selectedvariant[$seed]; + } + + if ($maxvariants > 2 * count($this->variantsusecounts[$seed])) { + // Many many more variants exist than have been used so far. + // It will be quicker to just pick until we miss a collision. + do { + $variant = rand(1, $maxvariants); + } while (isset($this->variantsusecounts[$seed][$variant])); + + } else { + // We need to work harder to find a least-used one. + $leastusedvariants = array(); + for ($variant = 1; $variant <= $maxvariants; ++$variant) { + if (!isset($this->variantsusecounts[$seed][$variant])) { + $leastusedvariants[$variant] = 1; + } + } + if (empty($leastusedvariants)) { + // All variants used at least once, try again. + $leastuses = min($this->variantsusecounts[$seed]); + foreach ($this->variantsusecounts[$seed] as $variant => $uses) { + if ($uses == $leastuses) { + $leastusedvariants[$variant] = 1; + } + } + } + $variant = array_rand($leastusedvariants); + } + + $this->selectedvariant[$seed] = $variant; + if (isset($variantsusecounts[$seed][$variant])) { + $variantsusecounts[$seed][$variant] += 1; + } else { + $variantsusecounts[$seed][$variant] = 1; + } + return $variant; + } +} diff --git a/question/engine/bank.php b/question/engine/bank.php index 11e3ddca862..04e598b9654 100644 --- a/question/engine/bank.php +++ b/question/engine/bank.php @@ -480,7 +480,7 @@ class question_finder implements cache_data_source { } /** - * Get the ids of all the questions in a list of categoryies. + * Get the ids of all the questions in a list of categories. * @param array $categoryids either a categoryid, or a comma-separated list * category ids, or an array of them. * @param string $extraconditions extra conditions to AND with the rest of @@ -505,6 +505,43 @@ class question_finder implements cache_data_source { {$extraconditions}", $qcparams + $extraparams, '', 'id,id AS id2'); } + /** + * Get the ids of all the questions in a list of categories, with the number + * of times they have already been used in a given set of usages. + * + * The result array is returned in order of increasing (count previous uses). + * + * @param array $categoryids an array question_category ids. + * @param qubaid_condition $qubaids which question_usages to count previous uses from. + * @param string $extraconditions extra conditions to AND with the rest of + * the where clause. Must use named parameters. + * @param array $extraparams any parameters used by $extraconditions. + * @return array questionid => count of number of previous uses. + */ + public function get_questions_from_categories_with_usage_counts($categoryids, + qubaid_condition $qubaids, $extraconditions = '', $extraparams = array()) { + global $DB; + + list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc'); + + if ($extraconditions) { + $extraconditions = ' AND (' . $extraconditions . ')'; + } + + return $DB->get_records_sql_menu(" + SELECT q.id, (SELECT COUNT(1) + FROM " . $qubaids->from_question_attempts('qa') . " + WHERE qa.questionid = q.id AND " . $qubaids->where() . " + ) AS previous_attempts + + FROM {question} q + + WHERE q.category $qcsql $extraconditions + + ORDER BY previous_attempts + ", $qubaids->from_where_params() + $qcparams + $extraparams); + } + /* See cache_data_source::load_for_cache. */ public function load_for_cache($questionid) { global $DB; diff --git a/question/engine/datalib.php b/question/engine/datalib.php index 1fbe2aac3d5..9574743e853 100644 --- a/question/engine/datalib.php +++ b/question/engine/datalib.php @@ -1198,6 +1198,32 @@ ORDER BY 'questionid ' . $test . ' AND questionusageid ' . $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params()); } + + /** + * Get the number of times each variant has been used for each question in a list + * in a set of usages. + * @param array $questionids of question ids. + * @param qubaid_condition $qubaids ids of the usages to consider. + * @return array questionid => variant number => num uses. + */ + public function load_used_variants(array $questionids, qubaid_condition $qubaids) { + list($test, $params) = $this->db->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'qid'); + $recordset = $this->db->get_recordset_sql(" + SELECT qa.questionid, qa.variant, COUNT(1) AS usescount + FROM " . $qubaids->from_question_attempts('qa') . " + WHERE qa.questionid $test + AND " . $qubaids->where() . " + GROUP BY qa.questionid, qa.variant + ORDER BY COUNT(1) ASC + ", $params + $qubaids->from_where_params()); + + $usedvariants = array_combine($questionids, array_fill(0, count($questionids), array())); + foreach ($recordset as $row) { + $usedvariants[$row->questionid][$row->variant] = $row->usescount; + } + $recordset->close(); + return $usedvariants; + } } diff --git a/question/engine/lib.php b/question/engine/lib.php index ef064263362..2ff5fd32edb 100644 --- a/question/engine/lib.php +++ b/question/engine/lib.php @@ -160,6 +160,18 @@ abstract class question_engine { return $dm->questions_in_use($questionids, $qubaids); } + /** + * Get the number of times each variant has been used for each question in a list + * in a set of usages. + * @param array $questionids of question ids. + * @param qubaid_condition $qubaids ids of the usages to consider. + * @return array questionid => variant number => num uses. + */ + public static function load_used_variants(array $questionids, qubaid_condition $qubaids) { + $dm = new question_engine_data_mapper(); + return $dm->load_used_variants($questionids, $qubaids); + } + /** * Create an archetypal behaviour for a particular question attempt. * Used by {@link question_definition::make_behaviour()}. diff --git a/question/engine/questionusage.php b/question/engine/questionusage.php index c4ce6e90489..536c8808c82 100644 --- a/question/engine/questionusage.php +++ b/question/engine/questionusage.php @@ -486,14 +486,15 @@ class question_usage_by_activity { * @param int $variant which variant of the question to use. Must be between * 1 and ->get_num_variants($slot) inclusive. If not give, a variant is * chosen at random. + * @param int $timestamp optional, the timstamp to record for this action. Defaults to now. */ - public function start_question($slot, $variant = null) { + public function start_question($slot, $variant = null, $timenow = null) { if (is_null($variant)) { $variant = rand(1, $this->get_num_variants($slot)); } $qa = $this->get_question_attempt($slot); - $qa->start($this->preferredbehaviour, $variant); + $qa->start($this->preferredbehaviour, $variant, array(), $timenow); $this->observer->notify_attempt_modified($qa); } diff --git a/question/engine/tests/datalib_test.php b/question/engine/tests/datalib_test.php index 9279626be67..0e0cbf08b71 100644 --- a/question/engine/tests/datalib_test.php +++ b/question/engine/tests/datalib_test.php @@ -126,4 +126,32 @@ class question_engine_data_mapper_testcase extends qbehaviour_walkthrough_test_b $this->assertEquals( 5, $quba2->get_question_max_mark(1)); $this->assertEquals( 2, $quba2->get_question_max_mark(2)); } + + public function test_load_used_variants() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $questiondata1 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $questiondata2 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $questiondata3 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + + $quba = question_engine::make_questions_usage_by_activity('test', context_system::instance()); + $quba->set_preferred_behaviour('deferredfeedback'); + $question1 = question_bank::load_question($questiondata1->id); + $question3 = question_bank::load_question($questiondata3->id); + $quba->add_question($question1); + $quba->add_question($question1); + $quba->add_question($question3); + $quba->start_all_questions(); + question_engine::save_questions_usage_by_activity($quba); + + $this->assertEquals(array( + $questiondata1->id => array(1 => 2), + $questiondata2->id => array(), + $questiondata3->id => array(1 => 1), + ), question_engine::load_used_variants( + array($questiondata1->id, $questiondata2->id, $questiondata3->id), + new qubaid_list(array($quba->get_id())))); + } } diff --git a/question/engine/tests/questionbank_test.php b/question/engine/tests/questionbank_test.php index 9c6275eb1d8..d7f78c12f5d 100644 --- a/question/engine/tests/questionbank_test.php +++ b/question/engine/tests/questionbank_test.php @@ -83,4 +83,31 @@ class question_bank_test extends advanced_testcase { $this->assertSame('-83.33333%', end($fractions)); $this->assertSame('-0.8333333', key($fractions)); } + + public function test_get_questions_from_categories_with_usage_counts() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $questiondata1 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $questiondata2 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $questiondata3 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + + $quba = question_engine::make_questions_usage_by_activity('test', context_system::instance()); + $quba->set_preferred_behaviour('deferredfeedback'); + $question1 = question_bank::load_question($questiondata1->id); + $question3 = question_bank::load_question($questiondata3->id); + $quba->add_question($question1); + $quba->add_question($question1); + $quba->add_question($question3); + $quba->start_all_questions(); + question_engine::save_questions_usage_by_activity($quba); + + $this->assertEquals(array( + $questiondata2->id => 0, + $questiondata3->id => 1, + $questiondata1->id => 2, + ), question_bank::get_finder()->get_questions_from_categories_with_usage_counts( + array($cat->id), new qubaid_list(array($quba->get_id())))); + } } diff --git a/question/engine/tests/questionusagebyactivity_data_test.php b/question/engine/tests/questionusagebyactivity_data_test.php new file mode 100644 index 00000000000..27fcfdf029e --- /dev/null +++ b/question/engine/tests/questionusagebyactivity_data_test.php @@ -0,0 +1,153 @@ +. + +/** + * This file contains tests for the question_usage_by_activity class. + * + * @package core_question + * @copyright 2012 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once(dirname(__FILE__) . '/../lib.php'); +require_once(dirname(__FILE__) . '/helpers.php'); + + +/** + * Unit tests for loading data into the {@link question_usage_by_activity} class. + * + * @copyright 2012 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_usage_db_test extends data_loading_method_test_base { + public function test_load() { + $scid = context_system::instance()->id; + $records = new question_test_recordset(array( + array('qubaid', 'contextid', 'component', 'preferredbehaviour', + 'questionattemptid', 'questionusageid', 'slot', + 'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged', + 'questionsummary', 'rightanswer', 'responsesummary', 'timemodified', + 'attemptstepid', 'sequencenumber', 'state', 'fraction', + 'timecreated', 'userid', 'name', 'value'), + array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null), + array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'todo', null, 1256233705, 1, 'answer', '1'), + array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 2, 'gradedright', 1.0000000, 1256233720, 1, '-finish', '1'), + )); + + $question = test_question_maker::make_question('truefalse', 'true'); + $question->id = -1; + + question_bank::start_unit_test(); + question_bank::load_test_question_data($question); + $quba = question_usage_by_activity::load_from_records($records, 1); + question_bank::end_unit_test(); + + $this->assertEquals('unit_test', $quba->get_owning_component()); + $this->assertEquals(1, $quba->get_id()); + $this->assertInstanceOf('question_engine_unit_of_work', $quba->get_observer()); + $this->assertEquals('interactive', $quba->get_preferred_behaviour()); + + $qa = $quba->get_question_attempt(1); + + $this->assertEquals($question->questiontext, $qa->get_question()->questiontext); + + $this->assertEquals(3, $qa->get_num_steps()); + + $step = $qa->get_step(0); + $this->assertEquals(question_state::$todo, $step->get_state()); + $this->assertNull($step->get_fraction()); + $this->assertEquals(1256233700, $step->get_timecreated()); + $this->assertEquals(1, $step->get_user_id()); + $this->assertEquals(array(), $step->get_all_data()); + + $step = $qa->get_step(1); + $this->assertEquals(question_state::$todo, $step->get_state()); + $this->assertNull($step->get_fraction()); + $this->assertEquals(1256233705, $step->get_timecreated()); + $this->assertEquals(1, $step->get_user_id()); + $this->assertEquals(array('answer' => '1'), $step->get_all_data()); + + $step = $qa->get_step(2); + $this->assertEquals(question_state::$gradedright, $step->get_state()); + $this->assertEquals(1, $step->get_fraction()); + $this->assertEquals(1256233720, $step->get_timecreated()); + $this->assertEquals(1, $step->get_user_id()); + $this->assertEquals(array('-finish' => '1'), $step->get_all_data()); + } + + public function test_load_data_no_steps() { + // The code had a bug where if one question_attempt had no steps, + // load_from_records got stuck in an infinite loop. This test is to + // verify that no longer happens. + $scid = context_system::instance()->id; + $records = new question_test_recordset(array( + array('qubaid', 'contextid', 'component', 'preferredbehaviour', + 'questionattemptid', 'questionusageid', 'slot', + 'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged', + 'questionsummary', 'rightanswer', 'responsesummary', 'timemodified', + 'attemptstepid', 'sequencenumber', 'state', 'fraction', + 'timecreated', 'userid', 'name', 'value'), + array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', 0, 1, 1.0000000, 0.0000000, 1.0000000, 0, 'This question is missing. Unable to display anything.', '', '', 0, null, null, null, null, null, null, null, null), + array(1, $scid, 'unit_test', 'interactive', 2, 1, 2, 'interactive', 0, 1, 1.0000000, 0.0000000, 1.0000000, 0, 'This question is missing. Unable to display anything.', '', '', 0, null, null, null, null, null, null, null, null), + array(1, $scid, 'unit_test', 'interactive', 3, 1, 3, 'interactive', 0, 1, 1.0000000, 0.0000000, 1.0000000, 0, 'This question is missing. Unable to display anything.', '', '', 0, null, null, null, null, null, null, null, null), + )); + + question_bank::start_unit_test(); + $quba = question_usage_by_activity::load_from_records($records, 1); + question_bank::end_unit_test(); + + $this->assertEquals('unit_test', $quba->get_owning_component()); + $this->assertEquals(1, $quba->get_id()); + $this->assertInstanceOf('question_engine_unit_of_work', $quba->get_observer()); + $this->assertEquals('interactive', $quba->get_preferred_behaviour()); + + $this->assertEquals(array(1, 2, 3), $quba->get_slots()); + + $qa = $quba->get_question_attempt(1); + $this->assertEquals(0, $qa->get_num_steps()); + } + + public function test_load_data_no_qas() { + // The code had a bug where if a question_usage had no question_attempts, + // load_from_records got stuck in an infinite loop. This test is to + // verify that no longer happens. + $scid = context_system::instance()->id; + $records = new question_test_recordset(array( + array('qubaid', 'contextid', 'component', 'preferredbehaviour', + 'questionattemptid', 'questionusageid', 'slot', + 'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged', + 'questionsummary', 'rightanswer', 'responsesummary', 'timemodified', + 'attemptstepid', 'sequencenumber', 'state', 'fraction', + 'timecreated', 'userid', 'name', 'value'), + array(1, $scid, 'unit_test', 'interactive', null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null), + )); + + question_bank::start_unit_test(); + $quba = question_usage_by_activity::load_from_records($records, 1); + question_bank::end_unit_test(); + + $this->assertEquals('unit_test', $quba->get_owning_component()); + $this->assertEquals(1, $quba->get_id()); + $this->assertInstanceOf('question_engine_unit_of_work', $quba->get_observer()); + $this->assertEquals('interactive', $quba->get_preferred_behaviour()); + + $this->assertEquals(array(), $quba->get_slots()); + } +} diff --git a/question/engine/tests/questionusagebyactivity_test.php b/question/engine/tests/questionusagebyactivity_test.php index a268c86fec6..c0b38a24f09 100644 --- a/question/engine/tests/questionusagebyactivity_test.php +++ b/question/engine/tests/questionusagebyactivity_test.php @@ -17,10 +17,9 @@ /** * This file contains tests for the question_usage_by_activity class. * - * @package moodlecore - * @subpackage questionengine - * @copyright 2009 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package core_question + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -160,125 +159,3 @@ class question_usage_by_activity_test extends advanced_testcase { $quba->process_all_actions($slot, $postdata); } } - -/** - * Unit tests for loading data into the {@link question_usage_by_activity} class. - * - * @copyright 2012 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class question_usage_db_test extends data_loading_method_test_base { - public function test_load() { - $scid = context_system::instance()->id; - $records = new question_test_recordset(array( - array('qubaid', 'contextid', 'component', 'preferredbehaviour', - 'questionattemptid', 'questionusageid', 'slot', - 'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged', - 'questionsummary', 'rightanswer', 'responsesummary', 'timemodified', - 'attemptstepid', 'sequencenumber', 'state', 'fraction', - 'timecreated', 'userid', 'name', 'value'), - array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null), - array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'todo', null, 1256233705, 1, 'answer', '1'), - array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 2, 'gradedright', 1.0000000, 1256233720, 1, '-finish', '1'), - )); - - $question = test_question_maker::make_question('truefalse', 'true'); - $question->id = -1; - - question_bank::start_unit_test(); - question_bank::load_test_question_data($question); - $quba = question_usage_by_activity::load_from_records($records, 1); - question_bank::end_unit_test(); - - $this->assertEquals('unit_test', $quba->get_owning_component()); - $this->assertEquals(1, $quba->get_id()); - $this->assertInstanceOf('question_engine_unit_of_work', $quba->get_observer()); - $this->assertEquals('interactive', $quba->get_preferred_behaviour()); - - $qa = $quba->get_question_attempt(1); - - $this->assertEquals($question->questiontext, $qa->get_question()->questiontext); - - $this->assertEquals(3, $qa->get_num_steps()); - - $step = $qa->get_step(0); - $this->assertEquals(question_state::$todo, $step->get_state()); - $this->assertNull($step->get_fraction()); - $this->assertEquals(1256233700, $step->get_timecreated()); - $this->assertEquals(1, $step->get_user_id()); - $this->assertEquals(array(), $step->get_all_data()); - - $step = $qa->get_step(1); - $this->assertEquals(question_state::$todo, $step->get_state()); - $this->assertNull($step->get_fraction()); - $this->assertEquals(1256233705, $step->get_timecreated()); - $this->assertEquals(1, $step->get_user_id()); - $this->assertEquals(array('answer' => '1'), $step->get_all_data()); - - $step = $qa->get_step(2); - $this->assertEquals(question_state::$gradedright, $step->get_state()); - $this->assertEquals(1, $step->get_fraction()); - $this->assertEquals(1256233720, $step->get_timecreated()); - $this->assertEquals(1, $step->get_user_id()); - $this->assertEquals(array('-finish' => '1'), $step->get_all_data()); - } - - public function test_load_data_no_steps() { - // The code had a bug where if one question_attempt had no steps, - // load_from_records got stuck in an infinite loop. This test is to - // verify that no longer happens. - $scid = context_system::instance()->id; - $records = new question_test_recordset(array( - array('qubaid', 'contextid', 'component', 'preferredbehaviour', - 'questionattemptid', 'questionusageid', 'slot', - 'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged', - 'questionsummary', 'rightanswer', 'responsesummary', 'timemodified', - 'attemptstepid', 'sequencenumber', 'state', 'fraction', - 'timecreated', 'userid', 'name', 'value'), - array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', 0, 1, 1.0000000, 0.0000000, 1.0000000, 0, 'This question is missing. Unable to display anything.', '', '', 0, null, null, null, null, null, null, null, null), - array(1, $scid, 'unit_test', 'interactive', 2, 1, 2, 'interactive', 0, 1, 1.0000000, 0.0000000, 1.0000000, 0, 'This question is missing. Unable to display anything.', '', '', 0, null, null, null, null, null, null, null, null), - array(1, $scid, 'unit_test', 'interactive', 3, 1, 3, 'interactive', 0, 1, 1.0000000, 0.0000000, 1.0000000, 0, 'This question is missing. Unable to display anything.', '', '', 0, null, null, null, null, null, null, null, null), - )); - - question_bank::start_unit_test(); - $quba = question_usage_by_activity::load_from_records($records, 1); - question_bank::end_unit_test(); - - $this->assertEquals('unit_test', $quba->get_owning_component()); - $this->assertEquals(1, $quba->get_id()); - $this->assertInstanceOf('question_engine_unit_of_work', $quba->get_observer()); - $this->assertEquals('interactive', $quba->get_preferred_behaviour()); - - $this->assertEquals(array(1, 2, 3), $quba->get_slots()); - - $qa = $quba->get_question_attempt(1); - $this->assertEquals(0, $qa->get_num_steps()); - } - - public function test_load_data_no_qas() { - // The code had a bug where if a question_usage had no question_attempts, - // load_from_records got stuck in an infinite loop. This test is to - // verify that no longer happens. - $scid = context_system::instance()->id; - $records = new question_test_recordset(array( - array('qubaid', 'contextid', 'component', 'preferredbehaviour', - 'questionattemptid', 'questionusageid', 'slot', - 'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged', - 'questionsummary', 'rightanswer', 'responsesummary', 'timemodified', - 'attemptstepid', 'sequencenumber', 'state', 'fraction', - 'timecreated', 'userid', 'name', 'value'), - array(1, $scid, 'unit_test', 'interactive', null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null), - )); - - question_bank::start_unit_test(); - $quba = question_usage_by_activity::load_from_records($records, 1); - question_bank::end_unit_test(); - - $this->assertEquals('unit_test', $quba->get_owning_component()); - $this->assertEquals(1, $quba->get_id()); - $this->assertInstanceOf('question_engine_unit_of_work', $quba->get_observer()); - $this->assertEquals('interactive', $quba->get_preferred_behaviour()); - - $this->assertEquals(array(), $quba->get_slots()); - } -} diff --git a/question/tests/least_used_variant_strategy_test.php b/question/tests/least_used_variant_strategy_test.php new file mode 100644 index 00000000000..f1b626781ef --- /dev/null +++ b/question/tests/least_used_variant_strategy_test.php @@ -0,0 +1,124 @@ +. + +/** + * Tests for the {@link core_question\engine\variants\least_used_strategy} class. + * + * @package core_question + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); + + +/** + * Tests for the {@link core_question\engine\variants\least_used_strategy} class. + * + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class least_used_variant_strategy_testcase extends advanced_testcase { + + public function test_question_with_one_variant_always_picks_that() { + $question = test_question_maker::make_question('shortanswer'); + $quba = question_engine::make_questions_usage_by_activity('test', context_system::instance()); + $quba->set_preferred_behaviour('deferredfeedback'); + $slot = $quba->add_question($question); + $quba->start_all_questions(new core_question\engine\variants\least_used_strategy( + $quba, new qubaid_list(array()))); + $this->assertEquals(1, $quba->get_variant($slot)); + } + + public function test_synchronised_question_should_use_the_same_dataset() { + // Actually, we cheat here. We use the same question twice, not two different synchronised questions. + $question = test_question_maker::make_question('calculated'); + $quba = question_engine::make_questions_usage_by_activity('test', context_system::instance()); + $quba->set_preferred_behaviour('deferredfeedback'); + $slot1 = $quba->add_question($question); + $slot2 = $quba->add_question($question); + $quba->start_all_questions(new core_question\engine\variants\least_used_strategy( + $quba, new qubaid_list(array()))); + $this->assertEquals($quba->get_variant($slot1), $quba->get_variant($slot2)); + } + + public function test_second_attempt_uses_other_dataset() { + global $DB; + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $questiondata = $generator->create_question('calculated', null, array('category' => $cat->id)); + + // Create two dataset items. + $adefinitionid = $DB->get_field_sql(" + SELECT qdd.id + FROM {question_dataset_definitions} qdd + JOIN {question_datasets} qd ON qd.datasetdefinition = qdd.id + WHERE qd.question = ? + AND qdd.name = ?", array($questiondata->id, 'a')); + $bdefinitionid = $DB->get_field_sql(" + SELECT qdd.id + FROM {question_dataset_definitions} qdd + JOIN {question_datasets} qd ON qd.datasetdefinition = qdd.id + WHERE qd.question = ? + AND qdd.name = ?", array($questiondata->id, 'b')); + $DB->set_field('question_dataset_definitions', 'itemcount', 2, array('id' => $adefinitionid)); + $DB->set_field('question_dataset_definitions', 'itemcount', 2, array('id' => $bdefinitionid)); + $DB->insert_record('question_dataset_items', array('definition' => $adefinitionid, + 'itemnumber' => 1, 'value' => 3)); + $DB->insert_record('question_dataset_items', array('definition' => $bdefinitionid, + 'itemnumber' => 1, 'value' => 7)); + $DB->insert_record('question_dataset_items', array('definition' => $adefinitionid, + 'itemnumber' => 2, 'value' => 6)); + $DB->insert_record('question_dataset_items', array('definition' => $bdefinitionid, + 'itemnumber' => 2, 'value' => 4)); + + $question = question_bank::load_question($questiondata->id); + + $quba1 = question_engine::make_questions_usage_by_activity('test', context_system::instance()); + $quba1->set_preferred_behaviour('deferredfeedback'); + $slot1 = $quba1->add_question($question); + $quba1->start_all_questions(new core_question\engine\variants\least_used_strategy( + $quba1, new qubaid_list(array()))); + question_engine::save_questions_usage_by_activity($quba1); + $variant1 = $quba1->get_variant($slot1); + + // Second attempt should use the other variant. + $quba2 = question_engine::make_questions_usage_by_activity('test', context_system::instance()); + $quba2->set_preferred_behaviour('deferredfeedback'); + $slot2 = $quba2->add_question($question); + $quba2->start_all_questions(new core_question\engine\variants\least_used_strategy( + $quba1, new qubaid_list(array($quba1->get_id())))); + question_engine::save_questions_usage_by_activity($quba2); + $variant2 = $quba2->get_variant($slot2); + + $this->assertNotEquals($variant1, $variant2); + + // Third attempt uses either variant at random. + $quba3 = question_engine::make_questions_usage_by_activity('test', context_system::instance()); + $quba3->set_preferred_behaviour('deferredfeedback'); + $slot3 = $quba3->add_question($question); + $quba3->start_all_questions(new core_question\engine\variants\least_used_strategy( + $quba1, new qubaid_list(array($quba1->get_id(), $quba2->get_id())))); + $variant3 = $quba3->get_variant($slot3); + + $this->assertTrue($variant3 == $variant1 || $variant3 == $variant2); + } +} diff --git a/question/tests/random_question_loader_test.php b/question/tests/random_question_loader_test.php new file mode 100644 index 00000000000..335afa454e5 --- /dev/null +++ b/question/tests/random_question_loader_test.php @@ -0,0 +1,182 @@ +. + +/** + * Tests for the {@link core_question\bank\random_question_loader} class. + * + * @package core_question + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + + +/** + * Tests for the {@link core_question\bank\random_question_loader} class. + * + * @copyright 2015 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class random_question_loader_testcase extends advanced_testcase { + + public function test_empty_category_gives_null() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array())); + + $this->assertNull($loader->get_next_question_id($cat->id, 0)); + $this->assertNull($loader->get_next_question_id($cat->id, 1)); + } + + public function test_unknown_category_behaves_like_empty() { + // It is up the caller to make sure the category id is valid. + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array())); + $this->assertNull($loader->get_next_question_id(-1, 1)); + } + + public function test_descriptions_not_returned() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $info = $generator->create_question('description', null, array('category' => $cat->id)); + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array())); + + $this->assertNull($loader->get_next_question_id($cat->id, 0)); + } + + public function test_one_question_category_returns_that_q_then_null() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array())); + + $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 1)); + $this->assertNull($loader->get_next_question_id($cat->id, 0)); + } + + public function test_two_question_category_returns_both_then_null() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $question2 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array())); + + $questionids = array(); + $questionids[] = $loader->get_next_question_id($cat->id, 0); + $questionids[] = $loader->get_next_question_id($cat->id, 0); + sort($questionids); + $this->assertEquals(array($question1->id, $question2->id), $questionids); + + $this->assertNull($loader->get_next_question_id($cat->id, 1)); + } + + public function test_nested_categories() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat1 = $generator->create_question_category(); + $cat2 = $generator->create_question_category(array('parent' => $cat1->id)); + $question1 = $generator->create_question('shortanswer', null, array('category' => $cat1->id)); + $question2 = $generator->create_question('shortanswer', null, array('category' => $cat2->id)); + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array())); + + $this->assertEquals($question2->id, $loader->get_next_question_id($cat2->id, 1)); + $this->assertEquals($question1->id, $loader->get_next_question_id($cat1->id, 1)); + + $this->assertNull($loader->get_next_question_id($cat1->id, 0)); + } + + public function test_used_question_not_returned_until_later() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $question2 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()), + array($question2->id => 2)); + + $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 0)); + $this->assertNull($loader->get_next_question_id($cat->id, 0)); + } + + public function test_previously_used_question_not_returned_until_later() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $question2 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $quba = question_engine::make_questions_usage_by_activity('test', context_system::instance()); + $quba->set_preferred_behaviour('deferredfeedback'); + $question = question_bank::load_question($question2->id); + $quba->add_question($question); + $quba->add_question($question); + $quba->start_all_questions(); + question_engine::save_questions_usage_by_activity($quba); + + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array($quba->get_id()))); + + $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 0)); + $this->assertEquals($question2->id, $loader->get_next_question_id($cat->id, 0)); + $this->assertNull($loader->get_next_question_id($cat->id, 0)); + } + + public function test_empty_category_does_not_have_question_available() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array())); + + $this->assertFalse($loader->is_question_available($cat->id, 0, 1)); + $this->assertFalse($loader->is_question_available($cat->id, 1, 1)); + } + + public function test_descriptions_not_available() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $info = $generator->create_question('description', null, array('category' => $cat->id)); + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array())); + + $this->assertFalse($loader->is_question_available($cat->id, 0, $info->id)); + $this->assertFalse($loader->is_question_available($cat->id, 1, $info->id)); + } + + public function test_existing_question_is_available_but_then_marked_used() { + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + + $cat = $generator->create_question_category(); + $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id)); + $loader = new \core_question\bank\random_question_loader(new qubaid_list(array())); + + $this->assertTrue($loader->is_question_available($cat->id, 0, $question1->id)); + $this->assertFalse($loader->is_question_available($cat->id, 0, $question1->id)); + + $this->assertFalse($loader->is_question_available($cat->id, 0, -1)); + } +} diff --git a/question/type/calculated/tests/helper.php b/question/type/calculated/tests/helper.php index 8910ad4b96a..d0bfb942834 100644 --- a/question/type/calculated/tests/helper.php +++ b/question/type/calculated/tests/helper.php @@ -120,6 +120,74 @@ class qtype_calculated_test_helper extends question_test_helper { return $qdata; } + + /** + * Makes a calculated question about summing two numbers. + * @return qtype_calculated_question + */ + public function get_calculated_question_form_data_sum() { + question_bank::load_question_definition_classes('calculated'); + $fromform = new stdClass(); + + $fromform->name = 'Simple sum'; + $fromform->questiontext = 'What is {a} + {b}?'; + $fromform->defaultmark = 1.0; + $fromform->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.'; + + $fromform->unitrole = '3'; + $fromform->unitpenalty = 0.1; + $fromform->unitgradingtypes = '1'; + $fromform->unitsleft = '0'; + $fromform->nounits = 1; + $fromform->multiplier = array(); + $fromform->multiplier[0] = '1.0'; + $fromform->synchronize = 0; + $fromform->answernumbering = 0; + $fromform->shuffleanswers = 0; + + $fromform->noanswers = 6; + $fromform->answer = array(); + $fromform->answer[0] = '{a} + {b}'; + $fromform->answer[1] = '{a} - {b}'; + $fromform->answer[2] = '*'; + + $fromform->fraction = array(); + $fromform->fraction[0] = '1.0'; + $fromform->fraction[1] = '0.0'; + $fromform->fraction[2] = '0.0'; + + $fromform->tolerance = array(); + $fromform->tolerance[0] = 0.001; + $fromform->tolerance[1] = 0.001; + $fromform->tolerance[2] = 0; + + $fromform->tolerancetype[0] = 1; + $fromform->tolerancetype[1] = 1; + $fromform->tolerancetype[2] = 1; + + $fromform->correctanswerlength[0] = 2; + $fromform->correctanswerlength[1] = 2; + $fromform->correctanswerlength[2] = 2; + + $fromform->correctanswerformat[0] = 1; + $fromform->correctanswerformat[1] = 1; + $fromform->correctanswerformat[2] = 1; + + $fromform->feedback = array(); + $fromform->feedback[0] = array(); + $fromform->feedback[0]['format'] = FORMAT_HTML; + $fromform->feedback[0]['text'] = 'Very good.'; + + $fromform->feedback[1] = array(); + $fromform->feedback[1]['format'] = FORMAT_HTML; + $fromform->feedback[1]['text'] = 'Add. not subtract!'; + + $fromform->feedback[2] = array(); + $fromform->feedback[2]['format'] = FORMAT_HTML; + $fromform->feedback[2]['text'] = 'Completely wrong.'; + + return $fromform; + } }