Merge branch 'MDL-66801' of https://github.com/timhunt/moodle
[moodle.git] / question / tests / random_question_loader_test.php
blobeec3dde5083cd1a2acb21ad749390bf7ae552d9b
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17 /**
18 * Tests for the {@link core_question\bank\random_question_loader} class.
20 * @package core_question
21 * @copyright 2015 The Open University
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 defined('MOODLE_INTERNAL') || die();
28 /**
29 * Tests for the {@link core_question\bank\random_question_loader} class.
31 * @copyright 2015 The Open University
32 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34 class random_question_loader_testcase extends advanced_testcase {
36 public function test_empty_category_gives_null() {
37 $this->resetAfterTest();
38 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
40 $cat = $generator->create_question_category();
41 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
43 $this->assertNull($loader->get_next_question_id($cat->id, 0));
44 $this->assertNull($loader->get_next_question_id($cat->id, 1));
47 public function test_unknown_category_behaves_like_empty() {
48 // It is up the caller to make sure the category id is valid.
49 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
50 $this->assertNull($loader->get_next_question_id(-1, 1));
53 public function test_descriptions_not_returned() {
54 $this->resetAfterTest();
55 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
57 $cat = $generator->create_question_category();
58 $info = $generator->create_question('description', null, array('category' => $cat->id));
59 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
61 $this->assertNull($loader->get_next_question_id($cat->id, 0));
64 public function test_hidden_questions_not_returned() {
65 global $DB;
66 $this->resetAfterTest();
67 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
69 $cat = $generator->create_question_category();
70 $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
71 $DB->set_field('question', 'hidden', 1, array('id' => $question1->id));
72 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
74 $this->assertNull($loader->get_next_question_id($cat->id, 0));
77 public function test_cloze_subquestions_not_returned() {
78 $this->resetAfterTest();
79 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
81 $cat = $generator->create_question_category();
82 $question1 = $generator->create_question('multianswer', null, array('category' => $cat->id));
83 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
85 $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 0));
86 $this->assertNull($loader->get_next_question_id($cat->id, 0));
89 public function test_random_questions_not_returned() {
90 $this->resetAfterTest();
91 $this->setAdminUser();
92 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
94 $cat = $generator->create_question_category();
95 $course = $this->getDataGenerator()->create_course();
96 $quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course));
97 quiz_add_random_questions($quiz, 1, $cat->id, 1, false);
98 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
100 $this->assertNull($loader->get_next_question_id($cat->id, 0));
103 public function test_one_question_category_returns_that_q_then_null() {
104 $this->resetAfterTest();
105 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
107 $cat = $generator->create_question_category();
108 $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
109 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
111 $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 1));
112 $this->assertNull($loader->get_next_question_id($cat->id, 0));
115 public function test_two_question_category_returns_both_then_null() {
116 $this->resetAfterTest();
117 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
119 $cat = $generator->create_question_category();
120 $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
121 $question2 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
122 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
124 $questionids = array();
125 $questionids[] = $loader->get_next_question_id($cat->id, 0);
126 $questionids[] = $loader->get_next_question_id($cat->id, 0);
127 sort($questionids);
128 $this->assertEquals(array($question1->id, $question2->id), $questionids);
130 $this->assertNull($loader->get_next_question_id($cat->id, 1));
133 public function test_nested_categories() {
134 $this->resetAfterTest();
135 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
137 $cat1 = $generator->create_question_category();
138 $cat2 = $generator->create_question_category(array('parent' => $cat1->id));
139 $question1 = $generator->create_question('shortanswer', null, array('category' => $cat1->id));
140 $question2 = $generator->create_question('shortanswer', null, array('category' => $cat2->id));
141 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
143 $this->assertEquals($question2->id, $loader->get_next_question_id($cat2->id, 1));
144 $this->assertEquals($question1->id, $loader->get_next_question_id($cat1->id, 1));
146 $this->assertNull($loader->get_next_question_id($cat1->id, 0));
149 public function test_used_question_not_returned_until_later() {
150 $this->resetAfterTest();
151 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
153 $cat = $generator->create_question_category();
154 $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
155 $question2 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
156 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()),
157 array($question2->id => 2));
159 $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 0));
160 $this->assertNull($loader->get_next_question_id($cat->id, 0));
163 public function test_previously_used_question_not_returned_until_later() {
164 $this->resetAfterTest();
165 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
167 $cat = $generator->create_question_category();
168 $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
169 $question2 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
170 $quba = question_engine::make_questions_usage_by_activity('test', context_system::instance());
171 $quba->set_preferred_behaviour('deferredfeedback');
172 $question = question_bank::load_question($question2->id);
173 $quba->add_question($question);
174 $quba->add_question($question);
175 $quba->start_all_questions();
176 question_engine::save_questions_usage_by_activity($quba);
178 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array($quba->get_id())));
180 $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 0));
181 $this->assertEquals($question2->id, $loader->get_next_question_id($cat->id, 0));
182 $this->assertNull($loader->get_next_question_id($cat->id, 0));
185 public function test_empty_category_does_not_have_question_available() {
186 $this->resetAfterTest();
187 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
189 $cat = $generator->create_question_category();
190 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
192 $this->assertFalse($loader->is_question_available($cat->id, 0, 1));
193 $this->assertFalse($loader->is_question_available($cat->id, 1, 1));
196 public function test_descriptions_not_available() {
197 $this->resetAfterTest();
198 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
200 $cat = $generator->create_question_category();
201 $info = $generator->create_question('description', null, array('category' => $cat->id));
202 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
204 $this->assertFalse($loader->is_question_available($cat->id, 0, $info->id));
205 $this->assertFalse($loader->is_question_available($cat->id, 1, $info->id));
208 public function test_existing_question_is_available_but_then_marked_used() {
209 $this->resetAfterTest();
210 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
212 $cat = $generator->create_question_category();
213 $question1 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
214 $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
216 $this->assertTrue($loader->is_question_available($cat->id, 0, $question1->id));
217 $this->assertFalse($loader->is_question_available($cat->id, 0, $question1->id));
219 $this->assertFalse($loader->is_question_available($cat->id, 0, -1));
223 * Data provider for the get_questions test.
225 public function get_questions_test_cases() {
226 return [
227 'empty category' => [
228 'categoryindex' => 'emptycat',
229 'includesubcategories' => false,
230 'usetagnames' => [],
231 'expectedquestionindexes' => []
233 'single category' => [
234 'categoryindex' => 'cat1',
235 'includesubcategories' => false,
236 'usetagnames' => [],
237 'expectedquestionindexes' => ['cat1q1', 'cat1q2']
239 'include sub category' => [
240 'categoryindex' => 'cat1',
241 'includesubcategories' => true,
242 'usetagnames' => [],
243 'expectedquestionindexes' => ['cat1q1', 'cat1q2', 'subcatq1', 'subcatq2']
245 'single category with tags' => [
246 'categoryindex' => 'cat1',
247 'includesubcategories' => false,
248 'usetagnames' => ['cat1'],
249 'expectedquestionindexes' => ['cat1q1']
251 'include sub category with tag on parent' => [
252 'categoryindex' => 'cat1',
253 'includesubcategories' => true,
254 'usetagnames' => ['cat1'],
255 'expectedquestionindexes' => ['cat1q1']
257 'include sub category with tag on sub' => [
258 'categoryindex' => 'cat1',
259 'includesubcategories' => true,
260 'usetagnames' => ['subcat'],
261 'expectedquestionindexes' => ['subcatq1']
263 'include sub category with same tag on parent and sub' => [
264 'categoryindex' => 'cat1',
265 'includesubcategories' => true,
266 'usetagnames' => ['foo'],
267 'expectedquestionindexes' => ['cat1q1', 'subcatq1']
269 'include sub category with tag not matching' => [
270 'categoryindex' => 'cat1',
271 'includesubcategories' => true,
272 'usetagnames' => ['cat1', 'cat2'],
273 'expectedquestionindexes' => []
279 * Test the get_questions function with various parameter combinations.
281 * This function creates a data set as follows:
282 * Category: cat1
283 * Question: cat1q1
284 * Tags: 'cat1', 'foo'
285 * Question: cat1q2
286 * Category: cat2
287 * Question: cat2q1
288 * Tags: 'cat2', 'foo'
289 * Question: cat2q2
290 * Category: subcat
291 * Question: subcatq1
292 * Tags: 'subcat', 'foo'
293 * Question: subcatq2
294 * Parent: cat1
295 * Category: emptycat
297 * @dataProvider get_questions_test_cases()
298 * @param string $categoryindex The named index for the category to use
299 * @param bool $includesubcategories If the search should include subcategories
300 * @param string[] $usetagnames The tag names to include in the search
301 * @param string[] $expectedquestionindexes The questions expected in the result
303 public function test_get_questions_variations(
304 $categoryindex,
305 $includesubcategories,
306 $usetagnames,
307 $expectedquestionindexes
309 $this->resetAfterTest();
311 $categories = [];
312 $questions = [];
313 $tagnames = [
314 'cat1',
315 'cat2',
316 'subcat',
317 'foo'
319 $collid = core_tag_collection::get_default();
320 $tags = core_tag_tag::create_if_missing($collid, $tagnames);
321 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
323 // First category and questions.
324 list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat1', 'foo']);
325 $categories['cat1'] = $category;
326 $questions['cat1q1'] = $categoryquestions[0];
327 $questions['cat1q2'] = $categoryquestions[1];
328 // Second category and questions.
329 list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat2', 'foo']);
330 $categories['cat2'] = $category;
331 $questions['cat2q1'] = $categoryquestions[0];
332 $questions['cat2q2'] = $categoryquestions[1];
333 // Sub category and questions.
334 list($category, $categoryquestions) = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']);
335 $categories['subcat'] = $category;
336 $questions['subcatq1'] = $categoryquestions[0];
337 $questions['subcatq2'] = $categoryquestions[1];
338 // Empty category.
339 list($category, $categoryquestions) = $this->create_category_and_questions(0);
340 $categories['emptycat'] = $category;
342 // Generate the arguments for the get_questions function.
343 $category = $categories[$categoryindex];
344 $tagids = array_map(function($tagname) use ($tags) {
345 return $tags[$tagname]->id;
346 }, $usetagnames);
348 $loader = new \core_question\bank\random_question_loader(new qubaid_list([]));
349 $result = $loader->get_questions($category->id, $includesubcategories, $tagids);
350 // Generate the expected question set.
351 $expectedquestions = array_map(function($index) use ($questions) {
352 return $questions[$index];
353 }, $expectedquestionindexes);
355 // Ensure the result matches what was expected.
356 $this->assertCount(count($expectedquestions), $result);
357 foreach ($expectedquestions as $question) {
358 $this->assertEquals($result[$question->id]->id, $question->id);
359 $this->assertEquals($result[$question->id]->category, $question->category);
364 * get_questions should allow limiting and offsetting of the result set.
366 public function test_get_questions_with_limit_and_offset() {
367 $this->resetAfterTest();
368 $numberofquestions = 5;
369 $includesubcategories = false;
370 $tagids = [];
371 $limit = 1;
372 $offset = 0;
373 $loader = new \core_question\bank\random_question_loader(new qubaid_list([]));
374 list($category, $questions) = $this->create_category_and_questions($numberofquestions);
376 // Sort the questions by id to match the ordering of the get_questions
377 // function.
378 usort($questions, function($a, $b) {
379 $aid = $a->id;
380 $bid = $b->id;
382 if ($aid == $bid) {
383 return 0;
385 return $aid < $bid ? -1 : 1;
388 for ($i = 0; $i < $numberofquestions; $i++) {
389 $result = $loader->get_questions(
390 $category->id,
391 $includesubcategories,
392 $tagids,
393 $limit,
394 $offset
397 $this->assertCount($limit, $result);
398 $actual = array_shift($result);
399 $expected = $questions[$i];
400 $this->assertEquals($expected->id, $actual->id);
401 $offset++;
406 * get_questions should allow retrieving questions with only a subset of
407 * fields populated.
409 public function test_get_questions_with_restricted_fields() {
410 $this->resetAfterTest();
411 $includesubcategories = false;
412 $tagids = [];
413 $limit = 10;
414 $offset = 0;
415 $fields = ['id', 'name'];
416 $loader = new \core_question\bank\random_question_loader(new qubaid_list([]));
417 list($category, $questions) = $this->create_category_and_questions(1);
419 $result = $loader->get_questions(
420 $category->id,
421 $includesubcategories,
422 $tagids,
423 $limit,
424 $offset,
425 $fields
428 $expectedquestion = array_shift($questions);
429 $actualquestion = array_shift($result);
430 $actualfields = get_object_vars($actualquestion);
431 $actualfields = array_keys($actualfields);
432 sort($actualfields);
433 sort($fields);
435 $this->assertEquals($fields, $actualfields);
439 * Data provider for the count_questions test.
441 public function count_questions_test_cases() {
442 return [
443 'empty category' => [
444 'categoryindex' => 'emptycat',
445 'includesubcategories' => false,
446 'usetagnames' => [],
447 'expectedcount' => 0
449 'single category' => [
450 'categoryindex' => 'cat1',
451 'includesubcategories' => false,
452 'usetagnames' => [],
453 'expectedcount' => 2
455 'include sub category' => [
456 'categoryindex' => 'cat1',
457 'includesubcategories' => true,
458 'usetagnames' => [],
459 'expectedcount' => 4
461 'single category with tags' => [
462 'categoryindex' => 'cat1',
463 'includesubcategories' => false,
464 'usetagnames' => ['cat1'],
465 'expectedcount' => 1
467 'include sub category with tag on parent' => [
468 'categoryindex' => 'cat1',
469 'includesubcategories' => true,
470 'usetagnames' => ['cat1'],
471 'expectedcount' => 1
473 'include sub category with tag on sub' => [
474 'categoryindex' => 'cat1',
475 'includesubcategories' => true,
476 'usetagnames' => ['subcat'],
477 'expectedcount' => 1
479 'include sub category with same tag on parent and sub' => [
480 'categoryindex' => 'cat1',
481 'includesubcategories' => true,
482 'usetagnames' => ['foo'],
483 'expectedcount' => 2
485 'include sub category with tag not matching' => [
486 'categoryindex' => 'cat1',
487 'includesubcategories' => true,
488 'usetagnames' => ['cat1', 'cat2'],
489 'expectedcount' => 0
495 * Test the count_questions function with various parameter combinations.
497 * This function creates a data set as follows:
498 * Category: cat1
499 * Question: cat1q1
500 * Tags: 'cat1', 'foo'
501 * Question: cat1q2
502 * Category: cat2
503 * Question: cat2q1
504 * Tags: 'cat2', 'foo'
505 * Question: cat2q2
506 * Category: subcat
507 * Question: subcatq1
508 * Tags: 'subcat', 'foo'
509 * Question: subcatq2
510 * Parent: cat1
511 * Category: emptycat
513 * @dataProvider count_questions_test_cases()
514 * @param string $categoryindex The named index for the category to use
515 * @param bool $includesubcategories If the search should include subcategories
516 * @param string[] $usetagnames The tag names to include in the search
517 * @param int $expectedcount The number of questions expected in the result
519 public function test_count_questions_variations(
520 $categoryindex,
521 $includesubcategories,
522 $usetagnames,
523 $expectedcount
525 $this->resetAfterTest();
527 $categories = [];
528 $questions = [];
529 $tagnames = [
530 'cat1',
531 'cat2',
532 'subcat',
533 'foo'
535 $collid = core_tag_collection::get_default();
536 $tags = core_tag_tag::create_if_missing($collid, $tagnames);
537 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
539 // First category and questions.
540 list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat1', 'foo']);
541 $categories['cat1'] = $category;
542 $questions['cat1q1'] = $categoryquestions[0];
543 $questions['cat1q2'] = $categoryquestions[1];
544 // Second category and questions.
545 list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat2', 'foo']);
546 $categories['cat2'] = $category;
547 $questions['cat2q1'] = $categoryquestions[0];
548 $questions['cat2q2'] = $categoryquestions[1];
549 // Sub category and questions.
550 list($category, $categoryquestions) = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']);
551 $categories['subcat'] = $category;
552 $questions['subcatq1'] = $categoryquestions[0];
553 $questions['subcatq2'] = $categoryquestions[1];
554 // Empty category.
555 list($category, $categoryquestions) = $this->create_category_and_questions(0);
556 $categories['emptycat'] = $category;
558 // Generate the arguments for the get_questions function.
559 $category = $categories[$categoryindex];
560 $tagids = array_map(function($tagname) use ($tags) {
561 return $tags[$tagname]->id;
562 }, $usetagnames);
564 $loader = new \core_question\bank\random_question_loader(new qubaid_list([]));
565 $result = $loader->count_questions($category->id, $includesubcategories, $tagids);
567 // Ensure the result matches what was expected.
568 $this->assertEquals($expectedcount, $result);
572 * Create a question category and create questions in that category. Tag
573 * the first question in each category with the given tags.
575 * @param int $questioncount How many questions to create.
576 * @param string[] $tagnames The list of tags to use.
577 * @param stdClass|null $parentcategory The category to set as the parent of the created category.
578 * @return array The category and questions.
580 protected function create_category_and_questions($questioncount, $tagnames = [], $parentcategory = null) {
581 $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
583 if ($parentcategory) {
584 $catparams = ['parent' => $parentcategory->id];
585 } else {
586 $catparams = [];
589 $category = $generator->create_question_category($catparams);
590 $questions = [];
592 for ($i = 0; $i < $questioncount; $i++) {
593 $questions[] = $generator->create_question('shortanswer', null, ['category' => $category->id]);
596 if (!empty($tagnames) && !empty($questions)) {
597 $context = context::instance_by_id($category->contextid);
598 core_tag_tag::set_item_tags('core_question', 'question', $questions[0]->id, $context, $tagnames);
601 return [$category, $questions];