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.
13//
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/>.
16
17/**
18 * Tests for the {@link core_question\bank\random_question_loader} class.
19 *
20 * @package   core_question
21 * @copyright 2015 The Open University
22 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25defined('MOODLE_INTERNAL') || die();
26
27
28/**
29 * Tests for the {@link core_question\bank\random_question_loader} class.
30 *
31 * @copyright  2015 The Open University
32 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
33 */
34class random_question_loader_testcase extends advanced_testcase {
35
36    public function test_empty_category_gives_null() {
37        $this->resetAfterTest();
38        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
39
40        $cat = $generator->create_question_category();
41        $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
42
43        $this->assertNull($loader->get_next_question_id($cat->id, 0));
44        $this->assertNull($loader->get_next_question_id($cat->id, 1));
45    }
46
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));
51    }
52
53    public function test_descriptions_not_returned() {
54        $this->resetAfterTest();
55        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
56
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()));
60
61        $this->assertNull($loader->get_next_question_id($cat->id, 0));
62    }
63
64    public function test_hidden_questions_not_returned() {
65        global $DB;
66        $this->resetAfterTest();
67        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
68
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()));
73
74        $this->assertNull($loader->get_next_question_id($cat->id, 0));
75    }
76
77    public function test_cloze_subquestions_not_returned() {
78        $this->resetAfterTest();
79        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
80
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()));
84
85        $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 0));
86        $this->assertNull($loader->get_next_question_id($cat->id, 0));
87    }
88
89    public function test_random_questions_not_returned() {
90        $this->resetAfterTest();
91        $this->setAdminUser();
92        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
93
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()));
99
100        $this->assertNull($loader->get_next_question_id($cat->id, 0));
101    }
102
103    public function test_one_question_category_returns_that_q_then_null() {
104        $this->resetAfterTest();
105        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
106
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()));
110
111        $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 1));
112        $this->assertNull($loader->get_next_question_id($cat->id, 0));
113    }
114
115    public function test_two_question_category_returns_both_then_null() {
116        $this->resetAfterTest();
117        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
118
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()));
123
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);
129
130        $this->assertNull($loader->get_next_question_id($cat->id, 1));
131    }
132
133    public function test_nested_categories() {
134        $this->resetAfterTest();
135        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
136
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()));
142
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));
145
146        $this->assertNull($loader->get_next_question_id($cat1->id, 0));
147    }
148
149    public function test_used_question_not_returned_until_later() {
150        $this->resetAfterTest();
151        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
152
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));
158
159        $this->assertEquals($question1->id, $loader->get_next_question_id($cat->id, 0));
160        $this->assertNull($loader->get_next_question_id($cat->id, 0));
161    }
162
163    public function test_previously_used_question_not_returned_until_later() {
164        $this->resetAfterTest();
165        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
166
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);
177
178        $loader = new \core_question\bank\random_question_loader(new qubaid_list(array($quba->get_id())));
179
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));
183    }
184
185    public function test_empty_category_does_not_have_question_available() {
186        $this->resetAfterTest();
187        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
188
189        $cat = $generator->create_question_category();
190        $loader = new \core_question\bank\random_question_loader(new qubaid_list(array()));
191
192        $this->assertFalse($loader->is_question_available($cat->id, 0, 1));
193        $this->assertFalse($loader->is_question_available($cat->id, 1, 1));
194    }
195
196    public function test_descriptions_not_available() {
197        $this->resetAfterTest();
198        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
199
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()));
203
204        $this->assertFalse($loader->is_question_available($cat->id, 0, $info->id));
205        $this->assertFalse($loader->is_question_available($cat->id, 1, $info->id));
206    }
207
208    public function test_existing_question_is_available_but_then_marked_used() {
209        $this->resetAfterTest();
210        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
211
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()));
215
216        $this->assertTrue($loader->is_question_available($cat->id, 0, $question1->id));
217        $this->assertFalse($loader->is_question_available($cat->id, 0, $question1->id));
218
219        $this->assertFalse($loader->is_question_available($cat->id, 0, -1));
220    }
221
222    /**
223     * Data provider for the get_questions test.
224     */
225    public function get_questions_test_cases() {
226        return [
227            'empty category' => [
228                'categoryindex' => 'emptycat',
229                'includesubcategories' => false,
230                'usetagnames' => [],
231                'expectedquestionindexes' => []
232            ],
233            'single category' => [
234                'categoryindex' => 'cat1',
235                'includesubcategories' => false,
236                'usetagnames' => [],
237                'expectedquestionindexes' => ['cat1q1', 'cat1q2']
238            ],
239            'include sub category' => [
240                'categoryindex' => 'cat1',
241                'includesubcategories' => true,
242                'usetagnames' => [],
243                'expectedquestionindexes' => ['cat1q1', 'cat1q2', 'subcatq1', 'subcatq2']
244            ],
245            'single category with tags' => [
246                'categoryindex' => 'cat1',
247                'includesubcategories' => false,
248                'usetagnames' => ['cat1'],
249                'expectedquestionindexes' => ['cat1q1']
250            ],
251            'include sub category with tag on parent' => [
252                'categoryindex' => 'cat1',
253                'includesubcategories' => true,
254                'usetagnames' => ['cat1'],
255                'expectedquestionindexes' => ['cat1q1']
256            ],
257            'include sub category with tag on sub' => [
258                'categoryindex' => 'cat1',
259                'includesubcategories' => true,
260                'usetagnames' => ['subcat'],
261                'expectedquestionindexes' => ['subcatq1']
262            ],
263            'include sub category with same tag on parent and sub' => [
264                'categoryindex' => 'cat1',
265                'includesubcategories' => true,
266                'usetagnames' => ['foo'],
267                'expectedquestionindexes' => ['cat1q1', 'subcatq1']
268            ],
269            'include sub category with tag not matching' => [
270                'categoryindex' => 'cat1',
271                'includesubcategories' => true,
272                'usetagnames' => ['cat1', 'cat2'],
273                'expectedquestionindexes' => []
274            ]
275        ];
276    }
277
278    /**
279     * Test the get_questions function with various parameter combinations.
280     *
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
296     *
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
302     */
303    public function test_get_questions_variations(
304        $categoryindex,
305        $includesubcategories,
306        $usetagnames,
307        $expectedquestionindexes
308    ) {
309        $this->resetAfterTest();
310
311        $categories = [];
312        $questions = [];
313        $tagnames = [
314            'cat1',
315            'cat2',
316            'subcat',
317            'foo'
318        ];
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');
322
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;
341
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);
347
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);
354
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);
360        }
361    }
362
363    /**
364     * get_questions should allow limiting and offsetting of the result set.
365     */
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);
375
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;
381
382            if ($aid == $bid) {
383                return 0;
384            }
385            return $aid < $bid ? -1 : 1;
386        });
387
388        for ($i = 0; $i < $numberofquestions; $i++) {
389            $result = $loader->get_questions(
390                $category->id,
391                $includesubcategories,
392                $tagids,
393                $limit,
394                $offset
395            );
396
397            $this->assertCount($limit, $result);
398            $actual = array_shift($result);
399            $expected = $questions[$i];
400            $this->assertEquals($expected->id, $actual->id);
401            $offset++;
402        }
403    }
404
405    /**
406     * get_questions should allow retrieving questions with only a subset of
407     * fields populated.
408     */
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);
418
419        $result = $loader->get_questions(
420            $category->id,
421            $includesubcategories,
422            $tagids,
423            $limit,
424            $offset,
425            $fields
426        );
427
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);
434
435        $this->assertEquals($fields, $actualfields);
436    }
437
438    /**
439     * Data provider for the count_questions test.
440     */
441    public function count_questions_test_cases() {
442        return [
443            'empty category' => [
444                'categoryindex' => 'emptycat',
445                'includesubcategories' => false,
446                'usetagnames' => [],
447                'expectedcount' => 0
448            ],
449            'single category' => [
450                'categoryindex' => 'cat1',
451                'includesubcategories' => false,
452                'usetagnames' => [],
453                'expectedcount' => 2
454            ],
455            'include sub category' => [
456                'categoryindex' => 'cat1',
457                'includesubcategories' => true,
458                'usetagnames' => [],
459                'expectedcount' => 4
460            ],
461            'single category with tags' => [
462                'categoryindex' => 'cat1',
463                'includesubcategories' => false,
464                'usetagnames' => ['cat1'],
465                'expectedcount' => 1
466            ],
467            'include sub category with tag on parent' => [
468                'categoryindex' => 'cat1',
469                'includesubcategories' => true,
470                'usetagnames' => ['cat1'],
471                'expectedcount' => 1
472            ],
473            'include sub category with tag on sub' => [
474                'categoryindex' => 'cat1',
475                'includesubcategories' => true,
476                'usetagnames' => ['subcat'],
477                'expectedcount' => 1
478            ],
479            'include sub category with same tag on parent and sub' => [
480                'categoryindex' => 'cat1',
481                'includesubcategories' => true,
482                'usetagnames' => ['foo'],
483                'expectedcount' => 2
484            ],
485            'include sub category with tag not matching' => [
486                'categoryindex' => 'cat1',
487                'includesubcategories' => true,
488                'usetagnames' => ['cat1', 'cat2'],
489                'expectedcount' => 0
490            ]
491        ];
492    }
493
494    /**
495     * Test the count_questions function with various parameter combinations.
496     *
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
512     *
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
518     */
519    public function test_count_questions_variations(
520        $categoryindex,
521        $includesubcategories,
522        $usetagnames,
523        $expectedcount
524    ) {
525        $this->resetAfterTest();
526
527        $categories = [];
528        $questions = [];
529        $tagnames = [
530            'cat1',
531            'cat2',
532            'subcat',
533            'foo'
534        ];
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');
538
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;
557
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);
563
564        $loader = new \core_question\bank\random_question_loader(new qubaid_list([]));
565        $result = $loader->count_questions($category->id, $includesubcategories, $tagids);
566
567        // Ensure the result matches what was expected.
568        $this->assertEquals($expectedcount, $result);
569    }
570
571    /**
572     * Create a question category and create questions in that category. Tag
573     * the first question in each category with the given tags.
574     *
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.
579     */
580    protected function create_category_and_questions($questioncount, $tagnames = [], $parentcategory = null) {
581        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
582
583        if ($parentcategory) {
584            $catparams = ['parent' => $parentcategory->id];
585        } else {
586            $catparams = [];
587        }
588
589        $category = $generator->create_question_category($catparams);
590        $questions = [];
591
592        for ($i = 0; $i < $questioncount; $i++) {
593            $questions[] = $generator->create_question('shortanswer', null, ['category' => $category->id]);
594        }
595
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);
599        }
600
601        return [$category, $questions];
602    }
603}
604