first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
+393
View File
@@ -0,0 +1,393 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
/**
* Class core_question_backup_testcase
*
* @package core_question
* @category test
* @copyright 2018 Shamim Rezaie <shamim@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class backup_test extends \advanced_testcase {
/**
* Makes a backup of the course.
*
* @param \stdClass $course The course object.
* @return string Unique identifier for this backup.
*/
protected function backup_course($course) {
global $CFG, $USER;
// Turn off file logging, otherwise it can't delete the file (Windows).
$CFG->backup_file_logger_level = \backup::LOG_NONE;
// Do backup with default settings. MODE_IMPORT means it will just
// create the directory and not zip it.
$bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id,
\backup::FORMAT_MOODLE, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT,
$USER->id);
$backupid = $bc->get_backupid();
$bc->execute_plan();
$bc->destroy();
return $backupid;
}
/**
* Restores a backup that has been made earlier.
*
* @param string $backupid The unique identifier of the backup.
* @param string $fullname Full name of the new course that is going to be created.
* @param string $shortname Short name of the new course that is going to be created.
* @param int $categoryid The course category the backup is going to be restored in.
* @param string[] $expectedprecheckwarning
* @return int The new course id.
*/
protected function restore_course($backupid, $fullname, $shortname, $categoryid, $expectedprecheckwarning = []) {
global $CFG, $USER;
// Turn off file logging, otherwise it can't delete the file (Windows).
$CFG->backup_file_logger_level = \backup::LOG_NONE;
// Do restore to new course with default settings.
$newcourseid = \restore_dbops::create_new_course($fullname, $shortname, $categoryid);
$rc = new \restore_controller($backupid, $newcourseid,
\backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id,
\backup::TARGET_NEW_COURSE);
$precheck = $rc->execute_precheck();
if (!$expectedprecheckwarning) {
$this->assertTrue($precheck);
} else {
$precheckresults = $rc->get_precheck_results();
$this->assertEqualsCanonicalizing($expectedprecheckwarning, $precheckresults['warnings']);
$this->assertCount(1, $precheckresults);
}
$rc->execute_plan();
$rc->destroy();
return $newcourseid;
}
/**
* This function tests backup and restore of question tags and course level question tags.
*/
public function test_backup_question_tags(): void {
global $DB;
$this->resetAfterTest();
$this->setAdminUser();
// Create a new course category and and a new course in that.
$category1 = $this->getDataGenerator()->create_category();
$course = $this->getDataGenerator()->create_course(['category' => $category1->id]);
$courseshortname = $course->shortname;
$coursefullname = $course->fullname;
// Create 2 questions.
$qgen = $this->getDataGenerator()->get_plugin_generator('core_question');
$context = \context_coursecat::instance($category1->id);
$qcat = $qgen->create_question_category(['contextid' => $context->id]);
$question1 = $qgen->create_question('shortanswer', null, ['category' => $qcat->id, 'idnumber' => 'q1']);
$question2 = $qgen->create_question('shortanswer', null, ['category' => $qcat->id, 'idnumber' => 'q2']);
// Tag the questions with 2 question tags and 2 course level question tags.
$qcontext = \context::instance_by_id($qcat->contextid);
$coursecontext = \context_course::instance($course->id);
\core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['qtag1', 'qtag2']);
\core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['qtag3', 'qtag4']);
\core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['ctag1', 'ctag2']);
\core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['ctag3', 'ctag4']);
// Create a quiz and add one of the questions to that.
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
quiz_add_quiz_question($question1->id, $quiz);
// Backup the course twice for future use.
$backupid1 = $this->backup_course($course);
$backupid2 = $this->backup_course($course);
// Now delete almost everything.
delete_course($course, false);
question_delete_question($question1->id);
question_delete_question($question2->id);
// Restore the backup we had made earlier into a new course.
$courseid2 = $this->restore_course($backupid1, $coursefullname, $courseshortname . '_2', $category1->id);
// The questions should remain in the question category they were which is
// a question category belonging to a course category context.
$sql = 'SELECT q.*,
qbe.idnumber
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid = ?
ORDER BY qbe.idnumber';
$questions = $DB->get_records_sql($sql, [$qcat->id]);
$this->assertCount(2, $questions);
// Retrieve tags for each question and check if they are assigned at the right context.
$qcount = 1;
foreach ($questions as $question) {
$tags = \core_tag_tag::get_item_tags('core_question', 'question', $question->id);
// Each question is tagged with 4 tags (2 question tags + 2 course tags).
$this->assertCount(4, $tags);
foreach ($tags as $tag) {
if (in_array($tag->name, ['ctag1', 'ctag2', 'ctag3', 'ctag4'])) {
$expected = \context_course::instance($courseid2)->id;
} else if (in_array($tag->name, ['qtag1', 'qtag2', 'qtag3', 'qtag4'])) {
$expected = $qcontext->id;
}
$this->assertEquals($expected, $tag->taginstancecontextid);
}
// Also check idnumbers have been backed up and restored.
$this->assertEquals('q' . $qcount, $question->idnumber);
$qcount++;
}
// Now, again, delete everything including the course category.
delete_course($courseid2, false);
foreach ($questions as $question) {
question_delete_question($question->id);
}
$category1->delete_full(false);
// Create a new course category to restore the backup file into it.
$category2 = $this->getDataGenerator()->create_category();
$expectedwarnings = [
get_string('qcategory2coursefallback', 'backup', (object) ['name' => 'top']),
get_string('qcategory2coursefallback', 'backup', (object) ['name' => $qcat->name])
];
// Restore to a new course in the new course category.
$courseid3 = $this->restore_course($backupid2, $coursefullname, $courseshortname . '_3', $category2->id, $expectedwarnings);
$coursecontext3 = \context_course::instance($courseid3);
// The questions should have been moved to a question category that belongs to a course context.
$questions = $DB->get_records_sql("SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = ?", [$coursecontext3->id]);
$this->assertCount(2, $questions);
// Now, retrieve tags for each question and check if they are assigned at the right context.
foreach ($questions as $question) {
$tags = \core_tag_tag::get_item_tags('core_question', 'question', $question->id);
// Each question is tagged with 4 tags (all are course tags now).
$this->assertCount(4, $tags);
foreach ($tags as $tag) {
$this->assertEquals($coursecontext3->id, $tag->taginstancecontextid);
}
}
}
/**
* Test that the question author is retained when they are enrolled in to the course.
*/
public function test_backup_question_author_retained_when_enrolled(): void {
global $DB, $USER, $CFG;
$this->resetAfterTest();
$this->setAdminUser();
// Create a course, a category and a user.
$course = $this->getDataGenerator()->create_course();
$category = $this->getDataGenerator()->create_category();
$user = $this->getDataGenerator()->create_user();
// Create a question.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$questioncategory = $questiongenerator->create_question_category();
$overrides = ['name' => 'Test question', 'category' => $questioncategory->id,
'createdby' => $user->id, 'modifiedby' => $user->id];
$question = $questiongenerator->create_question('truefalse', null, $overrides);
// Create a quiz and a questions.
$quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id));
quiz_add_quiz_question($question->id, $quiz);
// Enrol user with a teacher role.
$teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
$this->getDataGenerator()->enrol_user($user->id, $course->id, $teacherrole->id, 'manual');
// Backup the course.
$bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
\backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id);
$backupid = $bc->get_backupid();
$bc->execute_plan();
$results = $bc->get_results();
$file = $results['backup_destination'];
$fp = get_file_packer('application/vnd.moodle.backup');
$filepath = $CFG->dataroot . '/temp/backup/' . $backupid;
$file->extract_to_pathname($fp, $filepath);
$bc->destroy();
// Delete the original course and related question.
delete_course($course, false);
question_delete_question($question->id);
// Restore the course.
$restoredcourseid = \restore_dbops::create_new_course($course->fullname, $course->shortname . '_1', $category->id);
$rc = new \restore_controller($backupid, $restoredcourseid, \backup::INTERACTIVE_NO,
\backup::MODE_GENERAL, $USER->id, \backup::TARGET_NEW_COURSE);
$rc->execute_precheck();
$rc->execute_plan();
$rc->destroy();
// Test the question author.
$questions = $DB->get_records('question', ['name' => 'Test question']);
$this->assertCount(1, $questions);
$question3 = array_shift($questions);
$this->assertEquals($user->id, $question3->createdby);
$this->assertEquals($user->id, $question3->modifiedby);
}
/**
* Test that the question author is retained when they are not enrolled in to the course,
* but we are restoring the backup at the same site.
*/
public function test_backup_question_author_retained_when_not_enrolled(): void {
global $DB, $USER, $CFG;
$this->resetAfterTest();
$this->setAdminUser();
// Create a course, a category and a user.
$course = $this->getDataGenerator()->create_course();
$category = $this->getDataGenerator()->create_category();
$user = $this->getDataGenerator()->create_user();
// Create a question.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$questioncategory = $questiongenerator->create_question_category();
$overrides = ['name' => 'Test question', 'category' => $questioncategory->id,
'createdby' => $user->id, 'modifiedby' => $user->id];
$question = $questiongenerator->create_question('truefalse', null, $overrides);
// Create a quiz and a questions.
$quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id));
quiz_add_quiz_question($question->id, $quiz);
// Backup the course.
$bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
\backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id);
$backupid = $bc->get_backupid();
$bc->execute_plan();
$results = $bc->get_results();
$file = $results['backup_destination'];
$fp = get_file_packer('application/vnd.moodle.backup');
$filepath = $CFG->dataroot . '/temp/backup/' . $backupid;
$file->extract_to_pathname($fp, $filepath);
$bc->destroy();
// Delete the original course and related question.
delete_course($course, false);
question_delete_question($question->id);
// Restore the course.
$restoredcourseid = \restore_dbops::create_new_course($course->fullname, $course->shortname . '_1', $category->id);
$rc = new \restore_controller($backupid, $restoredcourseid, \backup::INTERACTIVE_NO,
\backup::MODE_GENERAL, $USER->id, \backup::TARGET_NEW_COURSE);
$rc->execute_precheck();
$rc->execute_plan();
$rc->destroy();
// Test the question author.
$questions = $DB->get_records('question', ['name' => 'Test question']);
$this->assertCount(1, $questions);
$question = array_shift($questions);
$this->assertEquals($user->id, $question->createdby);
$this->assertEquals($user->id, $question->modifiedby);
}
/**
* Test that the current user is set as a question author when we are restoring the backup
* at the another site and the question author is not enrolled in to the course.
*/
public function test_backup_question_author_reset(): void {
global $DB, $USER, $CFG;
$this->resetAfterTest();
$this->setAdminUser();
// Create a course, a category and a user.
$course = $this->getDataGenerator()->create_course();
$category = $this->getDataGenerator()->create_category();
$user = $this->getDataGenerator()->create_user();
// Create a question.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$questioncategory = $questiongenerator->create_question_category();
$overrides = ['name' => 'Test question', 'category' => $questioncategory->id,
'createdby' => $user->id, 'modifiedby' => $user->id];
$question = $questiongenerator->create_question('truefalse', null, $overrides);
// Create a quiz and a questions.
$quiz = $this->getDataGenerator()->create_module('quiz', array('course' => $course->id));
quiz_add_quiz_question($question->id, $quiz);
// Backup the course.
$bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
\backup::INTERACTIVE_NO, \backup::MODE_SAMESITE, $USER->id);
$backupid = $bc->get_backupid();
$bc->execute_plan();
$results = $bc->get_results();
$file = $results['backup_destination'];
$fp = get_file_packer('application/vnd.moodle.backup');
$filepath = $CFG->dataroot . '/temp/backup/' . $backupid;
$file->extract_to_pathname($fp, $filepath);
$bc->destroy();
// Delete the original course and related question.
delete_course($course, false);
question_delete_question($question->id);
// Emulate restoring to a different site.
set_config('siteidentifier', random_string(32) . 'not the same site');
// Restore the course.
$restoredcourseid = \restore_dbops::create_new_course($course->fullname, $course->shortname . '_1', $category->id);
$rc = new \restore_controller($backupid, $restoredcourseid, \backup::INTERACTIVE_NO,
\backup::MODE_SAMESITE, $USER->id, \backup::TARGET_NEW_COURSE);
$rc->execute_precheck();
$rc->execute_plan();
$rc->destroy();
// Test the question author.
$questions = $DB->get_records('question', ['name' => 'Test question']);
$this->assertCount(1, $questions);
$question = array_shift($questions);
$this->assertEquals($USER->id, $question->createdby);
$this->assertEquals($USER->id, $question->modifiedby);
}
}
+48
View File
@@ -0,0 +1,48 @@
@core @core_question
Feature: A bank view with questions can be managed
In order to manage a question bank from the course
As a teacher
I need to be able to view and manage questions
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
@javascript
Scenario: Viewing question bank should not load individual questions
When the following "questions" exist:
| questioncategory | qtype | name | questiontext | idnumber |
| Test questions | essay | Essay test question | Write about whatever you want | qid |
| Test questions | numerical | Numerical test question | Write about whatever you want | qid |
And I am on the "C1" "Course" page logged in as "teacher1"
And I navigate to "Question bank" in current page administration
And I should see "Essay test question"
And I should see "Numerical test question"
And I choose "Delete" action for "Essay test question" in the question bank
And I press "Delete"
And I should not see "Essay test question"
And I choose "Delete" action for "Numerical test question" in the question bank
And I press "Delete"
@javascript
Scenario: Unknown qtype does not break the view
When the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | missingtype | Unknown type question | Write about whatever you want |
| Test questions | truefalse | Truefalse type question | Write about whatever you want |
| Test questions | essay | Essay type question | Write about whatever you want |
And I am on the "C1" "Course" page logged in as "teacher1"
And I navigate to "Question bank" in current page administration
And I should see "Unknown type question"
And I should see "Truefalse type question"
And I should see "Essay type question"
@@ -0,0 +1,367 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
require_once(__DIR__ . '/behat_question_base.php');
use Behat\Gherkin\Node\TableNode as TableNode;
use Behat\Mink\Exception\ExpectationException as ExpectationException;
use Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
/**
* Steps definitions related with the question bank management.
*
* @package core_question
* @category test
* @copyright 2013 David Monllaó
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class behat_core_question extends behat_question_base {
/**
* Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
*
* Recognised page names are:
* | None so far! | |
*
* @param string $page name of the page, with the component name removed e.g. 'Admin notification'.
* @return moodle_url the corresponding URL.
* @throws Exception with a meaningful error message if the specified page cannot be found.
*/
protected function resolve_page_url(string $page): moodle_url {
switch (strtolower($page)) {
default:
throw new Exception('Unrecognised core_question page type "' . $page . '."');
}
}
/**
* Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
*
* Recognised page names are:
* | pagetype | name meaning | description |
* | course question bank | Course name | The question bank for a course |
* | course question import | Course name | The import questions screen for a course |
* | course question export | Course name | The export questions screen for a course |
* | preview | Question name | The screen to preview a question |
* | edit | Question name | The screen to edit a question |
*
* @param string $type identifies which type of page this is, e.g. 'Preview'.
* @param string $identifier identifies the particular page, e.g. 'My question'.
* @return moodle_url the corresponding URL.
* @throws Exception with a meaningful error message if the specified page cannot be found.
*/
protected function resolve_page_instance_url(string $type, string $identifier): moodle_url {
switch (strtolower($type)) {
case 'course question bank':
// The question bank does not handle fields at the edge of the viewport well.
// Increase the size to avoid this.
$this->execute('behat_general::i_change_window_size_to', ['window', 'large']);
return new moodle_url('/question/edit.php', [
'courseid' => $this->get_course_id($identifier),
]);
case 'course question categories':
return new moodle_url('/question/bank/managecategories/category.php',
['courseid' => $this->get_course_id($identifier)]);
case 'course question import':
return new moodle_url('/question/bank/importquestions/import.php',
['courseid' => $this->get_course_id($identifier)]);
case 'course question export':
return new moodle_url('/question/bank/exportquestions/export.php',
['courseid' => $this->get_course_id($identifier)]);
case 'preview':
[$questionid, $otheridtype, $otherid] = $this->find_question_by_name($identifier);
return new moodle_url('/question/bank/previewquestion/preview.php',
['id' => $questionid, $otheridtype => $otherid]);
case 'edit':
[$questionid, $otheridtype, $otherid] = $this->find_question_by_name($identifier);
return new moodle_url('/question/bank/editquestion/question.php',
['id' => $questionid, $otheridtype => $otherid]);
default:
throw new Exception('Unrecognised core_question page type "' . $type . '."');
}
}
/**
* Find a question, and where it is, from the question name.
*
* This is a helper used by resolve_page_instance_url.
*
* @param string $questionname
* @return array with three elemnets, int question id, a string 'cmid' or 'courseid',
* and int either cmid or courseid as applicable.
*/
protected function find_question_by_name(string $questionname): array {
global $DB;
$questionid = $DB->get_field('question', 'id', ['name' => $questionname], MUST_EXIST);
$question = question_bank::load_question_data($questionid);
$context = context_helper::instance_by_id($question->contextid);
if ($context->contextlevel == CONTEXT_MODULE) {
return [$questionid, 'cmid', $context->instanceid];
} else if ($context->contextlevel == CONTEXT_COURSE) {
return [$questionid, 'courseid', $context->instanceid];
} else {
throw new coding_exception('Unsupported context level ' . $context->contextlevel);
}
}
/**
* Creates a question in the current course questions bank with the provided data.
* This step can only be used when creating question types composed by a single form.
*
* @Given /^I add a "(?P<question_type_name_string>(?:[^"]|\\")*)" question filling the form with:$/
* @param string $questiontypename The question type name
* @param TableNode $questiondata The data to fill the question type form.
*/
public function i_add_a_question_filling_the_form_with($questiontypename, TableNode $questiondata) {
// Click on create question.
$this->execute('behat_forms::press_button', get_string('createnewquestion', 'question'));
// Add question.
$this->finish_adding_question($questiontypename, $questiondata);
}
/**
* Checks the state of the specified question.
*
* @Then /^the state of "(?P<question_description_string>(?:[^"]|\\")*)" question is shown as "(?P<state_string>(?:[^"]|\\")*)"$/
* @throws ExpectationException
* @throws ElementNotFoundException
* @param string $questiondescription
* @param string $state
*/
public function the_state_of_question_is_shown_as($questiondescription, $state) {
// Using xpath literal to avoid quotes problems.
$questiondescriptionliteral = behat_context_helper::escape($questiondescription);
$stateliteral = behat_context_helper::escape($state);
// Split in two checkings to give more feedback in case of exception.
$exception = new ElementNotFoundException($this->getSession(), 'Question "' . $questiondescription . '" ');
$questionxpath = "//div[contains(concat(' ', normalize-space(@class), ' '), ' que ')]" .
"[contains(div[@class='content']/div[contains(concat(' ', normalize-space(@class), ' '), ' formulation ')]," .
"{$questiondescriptionliteral})]";
$this->find('xpath', $questionxpath, $exception);
$exception = new ExpectationException('Question "' . $questiondescription .
'" state is not "' . $state . '"', $this->getSession());
$xpath = $questionxpath . "/div[@class='info']/div[@class='state' and contains(., {$stateliteral})]";
$this->find('xpath', $xpath, $exception);
}
/**
* Activates a particular action on a particular question in the question bank UI.
*
* @When I choose :action action for :questionname in the question bank
* @param string $action the label for the action you want to activate.
* @param string $questionname the question name.
*/
public function i_action_the_question($action, $questionname) {
if ($this->running_javascript()) {
// This method isn't allowed unless Javascript is running.
$this->execute('behat_action_menu::i_open_the_action_menu_in', [
$questionname,
'table_row',
]);
$this->execute('behat_action_menu::i_choose_in_the_open_action_menu', [
$action
]);
} else {
// This method doesn't open the menu correctly when Javascript is running.
$this->execute('behat_action_menu::i_choose_in_the_named_menu_in_container', [
$action,
get_string('edit', 'core'),
$questionname,
'table_row',
]);
}
}
/**
* Checks that action does exist for a question.
*
* @Then the :action action should exist for the :questionname question in the question bank
* @param string $action the label for the action you want to activate.
* @param string $questionname the question name.
*/
public function action_exists($action, $questionname) {
$this->execute('behat_action_menu::item_should_exist_in_the', [
$action,
get_string('edit', 'core'),
$questionname,
'table_row',
]);
}
/**
* Checks that action does not exist for a question.
*
* @Then the :action action should not exist for the :questionname question in the question bank
* @param string $action the label for the action you want to activate.
* @param string $questionname the question name.
*/
public function action_not_exists($action, $questionname) {
$this->execute('behat_action_menu::item_should_not_exist_in_the', [
$action,
get_string('edit', 'core'),
$questionname,
'table_row',
]);
}
/**
* A particular bulk action is visible in the question bank UI.
*
* @When I should see question bulk action :action
* @param string $action the value of the input for the action.
*/
public function i_should_see_question_bulk_action($action) {
// Check if its visible.
$this->execute("behat_general::should_be_visible",
["#bulkactionsui-container input[name='$action']", "css_element"]);
}
/**
* A particular bulk action should not be visible in the question bank UI.
*
* @When I should not see question bulk action :action
* @param string $action the value of the input for the action.
*/
public function i_should_not_see_question_bulk_action($action) {
// Check if its visible.
$this->execute("behat_general::should_not_be_visible",
["#bulkactionsui-container input[name='$action']", "css_element"]);
}
/**
* A click on a particular bulk action in the question bank UI.
*
* @When I click on question bulk action :action
* @param string $action the value of the input for the action.
*/
public function i_click_on_question_bulk_action($action) {
// Click the bulk action.
$this->execute("behat_general::i_click_on",
["#bulkactionsui-container input[name='$action']", "css_element"]);
}
/**
* Change the question type of the give question to a type that does not exist.
*
* This is useful for testing robustness of the code when a question type
* has been uninstalled, even though there are still questions of that type
* or attempts at them.
*
* In order to set things up, you probably need to start by generating
* questions of a valid type, then using this to change the type once the
* data is created.
*
* @Given question :questionname is changed to simulate being of an uninstalled type
* @param string $questionname the question name.
*/
public function change_question_to_nonexistant_type($questionname) {
global $DB;
[$id] = $this->find_question_by_name($questionname);
// Check our assumption.
$nonexistanttype = 'invalidqtype';
if (question_bank::is_qtype_installed($nonexistanttype)) {
throw new coding_exception('This code assumes that the qtype_' . $nonexistanttype .
' is not a valid plugin name, but that plugin now seems to exist!');
}
$DB->set_field('question', 'qtype', $nonexistanttype, ['id' => $id]);
question_bank::notify_question_edited($id);
}
/**
* Forcibly delete a question from the database.
*
* This is useful for testing robustness of the code when a question
* record is no longer in the database, even though it is referred to.
* Obviously, this should never happen, but it has been known to in the past
* and so we sometimes need to be able to test the code can handle this situation.
*
* In order to set things up, you probably need to start by generating
* a valid questions, then using this to remove it once the data is created.
*
* @Given question :questionname no longer exists in the database
* @param string $questionname the question name.
*/
public function remove_question_from_db($questionname) {
global $DB;
[$id] = $this->find_question_by_name($questionname);
$DB->delete_records('question', ['id' => $id]);
question_bank::notify_question_edited($id);
}
/**
* Add a question bank filter
*
* This will add the filter if it does not exist, but leave the value empty.
*
* @When I add question bank filter :filtertype
* @param string $filtertype The filter we are adding
*/
public function i_add_question_bank_filter(string $filtertype) {
$filter = $this->getSession()->getPage()->find('css',
'[data-filterregion=filter] [data-field-title="' . $filtertype . '"]');
if ($filter === null) {
$this->execute('behat_forms::press_button', [get_string('addcondition')]);
$this->execute('behat_forms::i_set_the_field_in_container_to', [
"type",
"[data-filterregion=filter]:last-child fieldset",
"css_element",
$filtertype
]);
}
}
/**
* Apply question bank filter.
*
* This will change the existing value of the specified filter, or add the filter and set its value if it doesn't already
* exist.
*
* @When I apply question bank filter :filtertype with value :value
* @param string $filtertype The filter to apply. This should match the get_title() return value from the
* filter's condition class.
* @param string $value The value to set for the condition.
*/
public function i_apply_question_bank_filter(string $filtertype, string $value) {
// Add the filter if needed.
$this->execute('behat_core_question::i_add_question_bank_filter', [
$filtertype,
]);
// Set the filter value.
$this->execute('behat_forms::i_set_the_field_to', [
$filtertype,
$value
]);
// Apply filters.
$this->execute("behat_forms::press_button", [get_string('applyfilters')]);
}
}
@@ -0,0 +1,59 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Behat question-related helper code.
*
* @package core_question
* @category test
* @copyright 2013 David Monllaó
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
use Behat\Gherkin\Node\TableNode as TableNode,
Behat\Mink\Exception\ExpectationException as ExpectationException,
Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
/**
* Steps definitions related with the question bank management.
*
* @package core_question
* @category test
* @copyright 2013 David Monllaó
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class behat_question_base extends behat_base {
/**
* Helper used by {@link i_add_a_question_filling_the_form_with()} and
* {@link behat_mod_quiz::i_add_question_to_the_quiz_with to finish creating()}.
*
* @param string $questiontypename The question type name
* @param TableNode $questiondata The data to fill the question type form
*/
protected function finish_adding_question($questiontypename, TableNode $questiondata) {
$this->execute('behat_forms::i_set_the_field_to', [$this->escape($questiontypename), 1]);
$this->execute("behat_general::i_click_on", ['.submitbutton', "css_element"]);
$this->execute("behat_forms::i_set_the_following_fields_to_these_values", $questiondata);
$this->execute("behat_forms::press_button", 'id_submitbutton');
}
}
@@ -0,0 +1,37 @@
@core @core_question
Feature: An activity module instance with questions in its context can be deleted
In order to delete an activity module from the course
As a teacher
I need to be able to delete the activity even if it has questions created in its context
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
Scenario: Synchronously deleting a quiz with existing questions in its context
Given the following config values are set as admin:
| coursebinenable | 0 | tool_recyclebin |
And the following "activity" exists:
| activity | quiz |
| course | C1 |
| name | Test quiz Q001 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Activity module | Test quiz Q001 | Default for Test quiz Q001 |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Default for Test quiz Q001 | truefalse | Test used question to be deleted | Write about whatever you want |
And quiz "Test quiz Q001" contains the following questions:
| question | page |
| Test used question to be deleted | 1 |
And I am on the "Course 1" course page logged in as teacher1
And I am on "Course 1" course homepage with editing mode on
When I delete "Test quiz Q001" activity
Then I should not see "Test quiz Q001"
@@ -0,0 +1,86 @@
@core @core_question
Feature: A teacher can delete questions in the question bank
In order to remove unused questions from the question bank
As a teacher
I need to delete questions
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | essay | Test question to be deleted | Write about whatever you want |
And I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
@javascript
Scenario: A question not used anywhere can really be deleted
When I choose "Delete" action for "Test question to be deleted" in the question bank
And I press "Delete"
And I apply question bank filter "Show hidden questions" with value "Yes"
Then I should not see "Test question to be deleted"
Scenario: Deleting a question can be cancelled
When I choose "Delete" action for "Test question to be deleted" in the question bank
And I press "Cancel"
Then I should see "Test question to be deleted"
@javascript
Scenario: Delete a question used in a quiz
Given the following "activity" exists:
| course | C1 |
| activity | quiz |
| idnumber | Test quiz |
| name | Test quiz |
And the following "question" exists:
| questioncategory | Test questions |
| qtype | truefalse |
| name | Test used question to be deleted |
| questiontext | Write about whatever you want |
And quiz "Test quiz" contains the following questions:
| question | page | requireprevious |
| Test used question to be deleted | 1 | 0 |
When I am on the "Course 1" "core_question > course question bank" page
And I choose "Delete" action for "Test used question to be deleted" in the question bank
And I should see "This will delete the following question and all its versions:"
And I should see "* Denotes questions which can't be deleted because they are in use. Instead, they will be hidden in the question bank unless you set 'Show hidden questions' to 'Yes'."
And I press "Delete"
Then I should not see "Test used question to be deleted"
And I apply question bank filter "Show hidden questions" with value "Yes"
And I should see "Test used question to be deleted"
And I am on the "Test quiz" "quiz activity" page
And I click on "Preview quiz" "button"
And I should see "Write about whatever you want"
@javascript
Scenario: A question can be deleted even if that question type is no longer installed
Given the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | missingtype | Broken question | Write something |
And I reload the page
When I choose "Delete" action for "Broken question" in the question bank
And I press "Delete"
And I apply question bank filter "Show hidden questions" with value "Yes"
Then I should not see "Broken question"
@javascript
Scenario: Delete question has multiple versions in question bank page
Given I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
When the following "core_question > updated questions" exist:
| questioncategory | question | questiontext |
| Test questions | Test question to be deleted | Test question to be deleted version 2 |
And I choose "Delete" action for "Test question to be deleted" in the question bank
And I should see "This will delete the following question and all its versions:"
And I should not see "* Denotes questions which can't be deleted because they are in use. Instead, they will be hidden in the question bank unless you set 'Show hidden questions' to 'Yes'."
And I press "Delete"
Then I should not see "Test question to be deleted"
And I should not see "Test question to be deleted version2"
@@ -0,0 +1,55 @@
@core @core_question
Feature: A teacher can duplicate questions in the question bank
In order to efficiently expand my question bank
As a teacher
I need to be able to duplicate existing questions and make small changes
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher | Teacher | One | teacher@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | idnumber |
| Test questions | essay | Test question to be copied | Write about whatever you want | qid |
And I am on the "Course 1" "core_question > course question bank" page logged in as "teacher"
Scenario: Duplicating a previously created question
When I choose "Duplicate" action for "Test question to be copied" in the question bank
And I set the following fields to these values:
| Question name | Duplicated question name |
| Question text | Write a lot about duplicating questions |
And I press "id_submitbutton"
Then I should see "Duplicated question name"
And I should see "Test question to be copied"
And I should see "ID number" in the "Test question to be copied" "table_row"
And I should see "qid" in the "Test question to be copied" "table_row"
Scenario: Duplicated questions automatically get a new name suggested
When I choose "Duplicate" action for "Test question to be copied" in the question bank
Then the field "Question name" matches value "Test question to be copied (copy)"
@javascript
Scenario: The duplicate operation can be cancelled
When I choose "Duplicate" action for "Test question to be copied" in the question bank
And I press "Cancel"
Then I should see "Test question to be copied"
And I should see "Test questions (1)" in the "Filter 1" "fieldset"
Scenario: Duplicating a question with an idnumber increments it
Given the following "questions" exist:
| questioncategory | qtype | name | questiontext | idnumber |
| Test questions | essay | Question with idnumber | Write about whatever you want | id101 |
And I reload the page
When I choose "Duplicate" action for "Question with idnumber" in the question bank
And I press "id_submitbutton"
Then I should see "Question with idnumber (copy)"
Then I should see "id102" in the "Question with idnumber (copy)" "table_row"
@@ -0,0 +1,42 @@
@core @core_question
Feature: A teacher can manage tags on questions in the question bank
In order to organise my questions
As a teacher
I need to be able to tag them
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | essay | Test question to be tagged | Write about whatever you want |
And I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
@javascript
Scenario: Manage tags on a question
When I choose "Manage tags" action for "Test question to be tagged" in the question bank
And I should see "Test question to be tagged" in the "Question tags" "dialogue"
And I set the field "Tags" to "my-tag"
And I click on "Save changes" "button" in the "Question tags" "dialogue"
Then I should see "my-tag" in the "Test question to be tagged" "table_row"
@javascript
Scenario: Manage tags works even on questions of a type is no longer installed
Given the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | missingtype | Broken question | Write something |
And I reload the page
When I choose "Manage tags" action for "Broken question" in the question bank
And I set the field "Tags" to "my-tag"
And I click on "Save changes" "button" in the "Question tags" "dialogue"
Then I should see "my-tag" in the "Broken question" "table_row"
@@ -0,0 +1,48 @@
@core @core_question
Feature: Questions in the question bank have versions
In order to see modified questions
As a teacher
I want to view them as different versions
Background:
Given the following "courses" exist:
| fullname | shortname | category | groupmode |
| Course 1 | C1 | 0 | 1 |
And the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "activities" exist:
| activity | name | course | idnumber |
| quiz | Quiz 1 | C1 | quiz1 |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | answer 1 |
| Test questions | truefalse | First question | Answer the first question | True |
And quiz "Quiz 1" contains the following questions:
| question | page |
| First question | 1 |
@javascript
Scenario: Question version is displayed
Given I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
When I choose "Edit question" action for "First question" in the question bank
Then I should see "Version 1"
@javascript
Scenario: Question version change when question is altered
Given I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
When I choose "Edit question" action for "First question" in the question bank
And I should see "Version 1"
When I set the field "id_name" to "Renamed question v2"
And I set the field "id_questiontext" to "edited question"
And I press "id_submitbutton"
Then I should not see "First question"
And I should see "Renamed question v2"
When I choose "Edit question" action for "Renamed question v2" in the question bank
Then I should see "Version 2"
And I should not see "Version 1"
@@ -0,0 +1,92 @@
@core @core_question @javascript
Feature: A teacher can edit questions in the question bank
In order to improve my questions
As a teacher
I need to be able to edit questions
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | essay | Test question to be edited | Write about whatever you want |
And I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
@javascript
Scenario: Edit a previously created question
When I am on the "Test question to be edited" "core_question > edit" page logged in as "teacher1"
And I set the following fields to these values:
| Question name | Edited question name |
| Question text | Write a lot about what you want |
And I press "id_submitbutton"
Then I should see "Edited question name"
And I should not see "Test question to be edited"
And I should see "Teacher 1"
Scenario: Edit a previously created question without permission 'moodle/question:moveall' and 'moodle/question:movemine'
Given I log in as "admin"
And the following "permission overrides" exist:
| capability | permission | role | contextlevel | reference |
| moodle/question:movemine | Prevent | editingteacher | System | |
| moodle/question:moveall | Prevent | editingteacher | System | |
When I am on the "Test question to be edited" "core_question > edit" page logged in as "teacher1"
And I set the following fields to these values:
| Question name | Edited question name |
| Question text | Write a lot about what you want |
And I press "id_submitbutton"
Then I should see "Edited question name"
And I should not see "Test question to be edited"
And I should see "Teacher 1"
Scenario: Edit a previously created question without permission 'moodle/question:editall' and 'moodle/question:editmine'
Given I log in as "admin"
And the following "permission overrides" exist:
| capability | permission | role | contextlevel | reference |
| moodle/question:editmine | Prevent | editingteacher | System | |
| moodle/question:editall | Prevent | editingteacher | System | |
When I am on the "Test question to be edited" "core_question > edit" page logged in as "teacher1"
And I set the following fields to these values:
| Question name | Edited question name |
| Question text | Write a lot about what you want |
And I press "id_submitbutton"
Then I should see "You don't have permission to edit questions from here."
Scenario: Editing a question can be cancelled
When I am on the "Test question to be edited" "core_question > edit" page logged in as "teacher1"
And I set the field "Question name" to "Edited question name"
And I press "Cancel"
Then I should see "Test question to be edited"
And I should see "Admin User"
Scenario: A question can have its idnumber removed
Given the following "questions" exist:
| questioncategory | qtype | name | idnumber |
| Test questions | essay | Question with idnumber | frog |
When I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
Then I should see "frog" in the "Question with idnumber" "table_row"
When I choose "Edit question" action for "Question with idnumber" in the question bank
And I set the field "ID number" to ""
And I press "id_submitbutton"
Then I should not see "frog" in the "Question with idnumber" "table_row"
Scenario: If the question type is no longer installed, then most edit actions are not present
Given the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Test questions | missingtype | Broken question | Write something |
When I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
Then the "Edit question" item should not exist in the "Edit" action menu of the "Broken question" "table_row"
And the "Duplicate" item should not exist in the "Edit" action menu of the "Broken question" "table_row"
And the "Preview" item should not exist in the "Edit" action menu of the "Broken question" "table_row"
And the "Export as XML" item should not exist in the "Edit" action menu of the "Broken question" "table_row"
And the "Manage tags" item should exist in the "Edit" action menu of the "Broken question" "table_row"
And the "Delete" item should exist in the "Edit" action menu of the "Broken question" "table_row"
@@ -0,0 +1,30 @@
@core @core_question @javascript
Feature: The questions can be tagged
In order to tag questions
As a teacher
I want to see the standard tags in the tags field
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "tags" exist:
| name | isstandard |
| foo | 1 |
| bar | 1 |
Scenario: The tags autocomplete should include standard tags
When I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
And I press "Create a new question ..."
And I set the field "item_qtype_truefalse" to "1"
And I click on "Add" "button" in the "Choose a question type to add" "dialogue"
And I expand all fieldsets
And I open the autocomplete suggestions list
Then "foo" "autocomplete_suggestions" should exist
And "bar" "autocomplete_suggestions" should exist
@@ -0,0 +1,45 @@
@core @core_question
Feature: The questions in the question bank can be filtered by tags
In order to find the questions I need
As a teacher
I want to filter the questions by tags
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | user | questiontext |
| Test questions | essay | question 1 name | admin | Question 1 text |
| Test questions | essay | question 2 name | teacher1 | Question 2 text |
And I am on the "question 1 name" "core_question > edit" page logged in as "teacher1"
And I set the following fields to these values:
| Tags | foo |
And I press "id_submitbutton"
And I am on the "question 2 name" "core_question > edit" page
And I set the following fields to these values:
| Tags | bar |
And I press "id_submitbutton"
@javascript
Scenario: The questions can be filtered by tag
When I apply question bank filter "Tag" with value "foo"
Then I should see "question 1 name" in the "categoryquestions" "table"
And I should not see "question 2 name" in the "categoryquestions" "table"
@javascript
Scenario: Empty condition should not result in exception
When I am on the "Course 1" "core_question > course question bank" page
And I set the field "Type or select..." in the "Filter 1" "fieldset" to "Test questions"
When I click on "Add condition" "button"
And I set the field "type" in the "Filter 2" "fieldset" to "Tag"
And I click on "Apply filters" "button"
@@ -0,0 +1,66 @@
@core @core_question
Feature: The questions in the question bank can be filtered by combine various conditions
In order to find the questions I need
As a teacher
I want to filter the questions by various conditions
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions 1|
| Course | C1 | Test questions 2|
And the following "questions" exist:
| questioncategory | qtype | name | user | questiontext |
| Test questions 1 | essay | question 1 name | teacher1 | Question 1 text |
| Test questions 1 | essay | question 2 name | teacher1 | Question 2 text |
| Test questions 2 | essay | question 3 name | teacher1 | Question 3 text |
| Test questions 2 | essay | question 4 name | teacher1 | Question 4 text |
And the following "core_question > Tags" exist:
| question | tag |
| question 1 name | foo |
| question 3 name | foo |
And I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
@javascript
Scenario: The questions can be filtered by matching all conditions
When I apply question bank filter "Category" with value "Test questions 1"
And I apply question bank filter "Tag" with value "foo"
Then I should see "question 1 name" in the "categoryquestions" "table"
And I should not see "question 2 name" in the "categoryquestions" "table"
And I should not see "question 3 name" in the "categoryquestions" "table"
And I should not see "question 4 name" in the "categoryquestions" "table"
@javascript
Scenario: Filters persist when the page is reloaded
Given the following "questions" exist:
| questioncategory | qtype | name | user | questiontext | status |
| Test questions 1 | essay | hidden question name | teacher1 | Hidden text | hidden |
And the following "core_question > Tags" exist:
| question | tag |
| hidden question name | foo |
And I apply question bank filter "Category" with value "Test questions 1"
And I apply question bank filter "Tag" with value "foo"
And I apply question bank filter "Show hidden questions" with value "Yes"
And I should see "question 1 name" in the "categoryquestions" "table"
And I should see "hidden question name" in the "categoryquestions" "table"
And I should not see "question 2 name" in the "categoryquestions" "table"
And I should not see "question 3 name" in the "categoryquestions" "table"
And I should not see "question 4 name" in the "categoryquestions" "table"
When I reload the page
Then I should see "Test questions 1 (2)" in the "Filter 1" "fieldset"
And the field "Show hidden questions" matches value "Yes"
And I should see "foo" in the "Filter 3" "fieldset"
And I should see "question 1 name" in the "categoryquestions" "table"
And I should see "hidden question name" in the "categoryquestions" "table"
And I should not see "question 2 name" in the "categoryquestions" "table"
And I should not see "question 3 name" in the "categoryquestions" "table"
And I should not see "question 4 name" in the "categoryquestions" "table"
@@ -0,0 +1,39 @@
@core @core_question
Feature: A teacher can move questions between categories in the question bank
In order to organize my questions
As a teacher
I move questions between categories
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | questioncategory | name |
| Course | C1 | Top | top |
| Course | C1 | top | Default for C1 |
| Course | C1 | Default for C1 | Subcategory |
| Course | C1 | top | Used category |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Used category | essay | Test question to be moved | Write about whatever you want |
And I log in as "teacher1"
And I am on "Course 1" course homepage
@javascript
Scenario: Move a question between categories via the question page
When I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
And I apply question bank filter "Category" with value "Used category"
And I click on "Test question to be moved" "checkbox" in the "Test question to be moved" "table_row"
And I click on "With selected" "button"
And I click on question bulk action "move"
And I set the field "Question category" to "Subcategory"
And I press "Move to"
Then I should see "Test question to be moved"
And I should see "Subcategory (1)" in the ".form-autocomplete-selection" "css_element"
@@ -0,0 +1,86 @@
@core @core_question
Feature: A teacher can put questions with idnumbers in categories in the question bank
In order to organize my questions
As a teacher
I move questions between categories (now with idnumbers)
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And I log in as "teacher1"
And I am on "Course 1" course homepage
Scenario: A question can only have a unique idnumber within a category
When the following "question categories" exist:
| contextlevel | reference | questioncategory | name | idnumber |
| Course | C1 | Top | top | |
| Course | C1 | top | Used category | c1used |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | idnumber |
| Used category | essay | Test question 1 | Write about whatever you want | q1 |
| Used category | essay | Test question 2 | Write about whatever you want | q2 |
And I am on the "Test question 2" "core_question > edit" page
And I set the field "ID number" to "q1"
And I press "submitbutton"
# This is the standard form warning reminding the user that the idnumber needs to be unique for a category.
Then I should see "This ID number is already in use"
Scenario: A question can be edited and saved without changing the idnumber
When the following "question categories" exist:
| contextlevel | reference | questioncategory | name | idnumber |
| Course | C1 | Top | top | |
| Course | C1 | top | Used category | c1used |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | idnumber |
| Used category | essay | Test question 1 | Write about whatever you want | q1 |
And I am on the "Test question 1" "core_question > edit" page
And I press "Save changes"
Then I should not see "This ID number is already in use"
Scenario: Question idnumber conflicts found when saving to the same category.
When the following "question categories" exist:
| contextlevel | reference | questioncategory | name |
| Course | C1 | Top | top |
| Course | C1 | top | Category 1 |
| Course | C1 | top | Category 2 |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | idnumber |
| Category 1 | essay | Question to edit | Write about whatever you want | q1 |
| Category 1 | essay | Other question | Write about whatever you want | q2 |
And I am on the "Question to edit" "core_question > edit" page
And I set the field "ID number" to "q2"
And I press "Save changes"
Then I should see "This ID number is already in use"
@javascript
Scenario: Moving a question between categories can force a change to the idnumber
And the following "question categories" exist:
| contextlevel | reference | questioncategory | name | idnumber |
| Course | C1 | Top | top | |
| Course | C1 | top | Subcategory | c1sub |
| Course | C1 | top | Used category | c1used |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | idnumber |
| Used category | essay | Test question 1 | Write about whatever you want | q1 |
| Used category | essay | Test question 2 | Write about whatever you want | q2 |
| Subcategory | essay | Test question 3 | Write about whatever you want | q3 |
When I am on the "Test question 3" "core_question > edit" page
# The q1 idnumber is allowed for this question while it is in the Subcategory.
And I set the field "ID number" to "q1"
And I press "submitbutton"
# Javascript is required for the next step.
And I click on "Test question 3" "checkbox" in the "Test question 3" "table_row"
And I click on "With selected" "button"
And I click on question bulk action "move"
And I set the field "Question category" to "Subcategory"
And I press "Move to"
And I choose "Edit question" action for "Test question 3" in the question bank
# The question just moved into this category needs to have a unique idnumber, so a number is appended.
Then the field "ID number" matches value "q1_1"
@@ -0,0 +1,38 @@
@core @core_question @qbank_filter @javascript
Feature: A teacher can pagimate through question bank questions
In order to paginate questions
As a teacher
I must be able to paginate
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | questioncategory | name |
| Course | C1 | Top | Used category |
Given 100 "questions" exist with the following data:
| questioncategory | Used category |
| qtype | essay |
| name | Tests question [count] |
| questiontext | Write about whatever you want |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext |
| Used category | essay | Not on first page | Write about whatever you want |
Scenario: Questions can be paginated
When I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
When I apply question bank filter "Category" with value "Course 1"
And I follow "Sort by Question name ascending"
And I follow "Sort by Question name descending"
And I should see "Tests question 1"
And I should not see "Not on first page"
And I click on "2" "link" in the ".pagination" "css_element"
And I should not see "Tests question 1"
And I should see "Not on first page"
@@ -0,0 +1,35 @@
@core @core_question
Feature: A teacher can see highlighted questions in the question bank
In order to see my edited questions
As a teacher
I need to be able see the highlight of my edited question.
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And 101 "questions" exist with the following data:
| questioncategory | Test questions |
| qtype | essay |
| name | essay [count] |
| questiontext | Write about whatever you want |
Scenario: Edited question highlight is retained when go to multiple pages.
Given I am on the "essay 1" "core_question > edit" page logged in as "teacher1"
And I set the following fields to these values:
| Question name | essay 1 edited |
And I press "id_submitbutton"
And I should see "essay 1 edited"
And ".highlight" "css_element" should exist in the "#categoryquestions" "css_element"
When I click on "2" "link" in the ".pagination" "css_element"
And I click on "1" "link" in the ".pagination" "css_element"
Then ".highlight" "css_element" should exist in the "#categoryquestions" "css_element"
@@ -0,0 +1,57 @@
@core @core_question
Feature: The questions in the question bank can be selected in various ways
In selected to do something for questions
As a teacher
I want to choose them to move, delete it.
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | user | questiontext |
| Test questions | essay | A question 1 name | admin | Question 1 text |
| Test questions | essay | B question 2 name | teacher1 | Question 2 text |
| Test questions | numerical | C question 3 name | teacher1 | Question 3 text |
And I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
@javascript
Scenario: The question text can be chosen all in the list of questions
Given the field "Select all" matches value ""
When I click on "Select all" "checkbox"
And the field "A question 1 name" matches value "1"
And the field "B question 2 name" matches value "1"
And the field "C question 3 name" matches value "1"
Then I click on "Deselect all" "checkbox"
And the field "A question 1 name" matches value ""
And the field "B question 2 name" matches value ""
And the field "C question 3 name" matches value ""
@javascript
Scenario: The question text can be chosen in the list of questions
Given the field "Select all" matches value ""
When I click on "A question 1 name" "checkbox"
Then the field "Select all" matches value ""
And I click on "B question 2 name" "checkbox"
And I click on "C question 3 name" "checkbox"
And the field "Deselect all" matches value "1"
@javascript
Scenario: The action button can be disabled when the question not be chosen in the list of questions
Given the field "Select all" matches value ""
When I click on "With selected" "button"
And I should not see "Delete"
And I should not see "Move to..."
And I click on "Select all" "checkbox"
And I click on "With selected" "button"
Then I should see question bulk action "move"
And I should see question bulk action "deleteselected"
@@ -0,0 +1,67 @@
@core @core_question
Feature: The questions in the question bank can be sorted in various ways
In order to see what questions I have
As a teacher
I want to view them in different orders
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | format |
| Course 1 | C1 | weeks |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "question categories" exist:
| contextlevel | reference | name |
| Course | C1 | Test questions |
And the following "questions" exist:
| questioncategory | qtype | name | user | questiontext | idnumber |
| Test questions | essay | A question 1 name | admin | Question 1 text | numidnum</a |
| Test questions | essay | B question 2 name | teacher1 | Question 2 text | |
| Test questions | numerical | C question 3 name | teacher1 | Question 3 text | numidnum</c |
And I am on the "Course 1" "core_question > course question bank" page logged in as "teacher1"
Scenario: The questions are sorted by type by default
Then "A question 1 name" "checkbox" should appear before "C question 3 name" "checkbox"
Scenario: The questions can be sorted by idnumber
When I change the window size to "large"
And I follow "Sort by ID number ascending"
Then "C question 3 name" "checkbox" should appear after "A question 1 name" "checkbox"
And I should see "numidnum</c" in the "C question 3 name" "table_row"
And I follow "Sort by ID number descending"
And "C question 3 name" "checkbox" should appear before "A question 1 name" "checkbox"
Scenario: The questions can be sorted in reverse order by type
When I follow "Sort by Question type descending"
Then "C question 3 name" "checkbox" should appear before "A question 1 name" "checkbox"
Scenario: The questions can be sorted by name
When I follow "Sort by Question name ascending"
Then "A question 1 name" "checkbox" should appear before "B question 2 name" "checkbox"
And "B question 2 name" "checkbox" should appear before "C question 3 name" "checkbox"
Scenario: The questions can be sorted in reverse order by name
When I follow "Sort by Question name ascending"
And I follow "Sort by Question name descending"
Then "C question 3 name" "checkbox" should appear before "B question 2 name" "checkbox"
And "B question 2 name" "checkbox" should appear before "A question 1 name" "checkbox"
Scenario: The questions can be sorted by creator name
When I follow "Sort by First name ascending"
Then "A question 1 name" "checkbox" should appear before "B question 2 name" "checkbox"
Scenario: The questions can be sorted in reverse order by creator name
When I follow "Sort by First name ascending"
And I follow "Sort by First name descending"
Then "B question 2 name" "checkbox" should appear before "A question 1 name" "checkbox"
@javascript
Scenario: The question text can be shown in the list of questions
When I set the field "Show question text in the question list?" to "Yes"
Then I should see "Question 1 text"
And I should see "Question 2 text"
And I should see "Question 3 text"
@@ -0,0 +1,162 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use core_question\statistics\questions\calculated_question_summary;
/**
* Class core_question_calculated_question_summary_testcase
*
* @package core_question
* @category test
* @copyright 2018 Shamim Rezaie <shamim@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class calculated_question_summary_test extends \advanced_testcase {
/**
* Provider for test_get_min_max_of.
*
* @return array
*/
public function get_min_max_provider() {
return [
'negative number and null' => [
[
(object)['questionid' => 1, 'index' => 2],
(object)['questionid' => 2, 'index' => -7],
(object)['questionid' => 3, 'index' => null],
(object)['questionid' => 4, 'index' => 12],
],
[-7, 12]
],
'null and negative number' => [
[
(object)['questionid' => 1, 'index' => 2],
(object)['questionid' => 2, 'index' => null],
(object)['questionid' => 3, 'index' => -7],
(object)['questionid' => 4, 'index' => 12],
],
[-7, 12]
],
'negative number and null as maximum' => [
[
(object)['questionid' => 1, 'index' => -2],
(object)['questionid' => 2, 'index' => null],
(object)['questionid' => 3, 'index' => -7],
],
[-7, null]
],
'zero and null' => [
[
(object)['questionid' => 1, 'index' => 2],
(object)['questionid' => 2, 'index' => 0],
(object)['questionid' => 3, 'index' => null],
(object)['questionid' => 4, 'index' => 12],
],
[0, 12]
],
'null as minimum' => [
[
(object)['questionid' => 1, 'index' => 2],
(object)['questionid' => 2, 'index' => null],
(object)['questionid' => 3, 'index' => 12],
],
[null, 12]
],
'null and null' => [
[
(object)['questionid' => 1, 'index' => 2],
(object)['questionid' => 2, 'index' => null],
(object)['questionid' => 3, 'index' => null],
],
[null, 2]
],
];
}
/**
* Unit test for get_min_max_of() method.
*
* @dataProvider get_min_max_provider
*/
public function test_get_min_max_of($subqstats, $expected): void {
$calculatedsummary = new calculated_question_summary(null, null, $subqstats);
$res = $calculatedsummary->get_min_max_of('index');
$this->assertEquals($expected, $res);
}
/**
* Provider for test_get_min_max_of.
*
* @return array
*/
public function get_sd_min_max_provider() {
return [
'null and number' => [
[
(object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0.5],
(object)['questionid' => 2, 'sd' => null, 'maxmark' => 1],
(object)['questionid' => 3, 'sd' => 0.1049, 'maxmark' => 1],
(object)['questionid' => 4, 'sd' => 0.12, 'maxmark' => 1],
],
[null, 0.4]
],
'null and zero' => [
[
(object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0.5],
(object)['questionid' => 2, 'sd' => null, 'maxmark' => 1],
(object)['questionid' => 3, 'sd' => 0, 'maxmark' => 1],
(object)['questionid' => 4, 'sd' => 0.12, 'maxmark' => 1],
],
[0, 0.4]
],
'zero mark' => [
[
(object)['questionid' => 1, 'sd' => 0, 'maxmark' => 0],
(object)['questionid' => 2, 'sd' => 0.1049, 'maxmark' => 1],
],
[null, 0.1049]
],
'nonzero and nonzero' => [
[
(object)['questionid' => 1, 'sd' => 0.2, 'maxmark' => 0.5],
(object)['questionid' => 2, 'sd' => 0.7, 'maxmark' => 2],
],
[0.35, 0.4]
],
'zero max mark as loaded from the DB' => [
[
(object)['questionid' => 1, 'sd' => '0.0000000000', 'maxmark' => '0.0000000'],
(object)['questionid' => 2, 'sd' => '0.0000000000', 'maxmark' => '0.0000000'],
],
[null, null]
],
];
}
/**
* Unit test for get_min_max_of_sd() method.
*
* @dataProvider get_sd_min_max_provider
*/
public function test_get_min_max_of_sd($subqstats, $expected): void {
$calculatedsummary = new calculated_question_summary(null, null, $subqstats);
$res = $calculatedsummary->get_min_max_of('sd');
$this->assertEquals($expected, $res);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
defined('MOODLE_INTERNAL') || die();
/**
* Coverage information for the core_question.
*
* @copyright 2022 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
return new class extends phpunit_coverage_info {
/** @var array The list of files relative to the plugin root to include in coverage generation. */
protected $includelistfiles = [
'category_class.php',
'category_form.php',
'editlib.php',
'export_form.php',
'format.php',
'import_form.php',
'lib.php',
'move_form.php',
'previewlib.php',
'renderer.php',
'upgrade.php',
];
};
+306
View File
@@ -0,0 +1,306 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Events tests.
*
* @package core_question
* @copyright 2014 Mark Nelson <markn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\event;
use qbank_managecategories\question_category_object;
use qtype_description;
use qtype_description_edit_form;
use qtype_description_test_helper;
use test_question_maker;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/question/editlib.php');
class events_test extends \advanced_testcase {
/**
* Tests set up.
*/
public function setUp(): void {
$this->resetAfterTest();
}
/**
* Test the questions imported event.
* There is no easy way to trigger this event using the API, so the unit test will simply
* create and trigger the event and ensure data is returned as expected.
*/
public function test_questions_imported(): void {
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course();
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
$contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid));
$defaultcategoryobj = question_make_default_categories([$contexts->lowest()]);
$defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid;
$qcobject = new question_category_object(
1,
new \moodle_url('/mod/quiz/edit.php', ['cmid' => $quiz->cmid]),
$contexts->having_one_edit_tab_cap('categories'),
$defaultcategoryobj->id,
$defaultcategory,
null,
$contexts->having_cap('moodle/question:add'));
// Create the category.
$categoryid = $qcobject->add_category($defaultcategory, 'newcategory', '', true);
// Log the view of this category.
$params = [
'context' => \context_module::instance($quiz->cmid),
'other' => ['categoryid' => $categoryid, 'format' => 'testformat'],
];
$event = \core\event\questions_imported::create($params);
// Trigger and capture the event.
$sink = $this->redirectEvents();
$event->trigger();
$events = $sink->get_events();
$event = reset($events);
// Check that the event data is valid.
$this->assertInstanceOf('\core\event\questions_imported', $event);
$this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context());
$this->assertEquals($categoryid, $event->other['categoryid']);
$this->assertEquals('testformat', $event->other['format']);
$this->assertDebuggingNotCalled();
}
/**
* Test the questions exported event.
* There is no easy way to trigger this event using the API, so the unit test will simply
* create and trigger the event and ensure data is returned as expected.
*/
public function test_questions_exported(): void {
$this->setAdminUser();
$course = $this->getDataGenerator()->create_course();
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
$contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid));
$defaultcategoryobj = question_make_default_categories([$contexts->lowest()]);
$defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid;
$qcobject = new question_category_object(
1,
new \moodle_url('/mod/quiz/edit.php', ['cmid' => $quiz->cmid]),
$contexts->having_one_edit_tab_cap('categories'),
$defaultcategoryobj->id,
$defaultcategory,
null,
$contexts->having_cap('moodle/question:add'));
// Create the category.
$categoryid = $qcobject->add_category($defaultcategory, 'newcategory', '', true);
// Log the view of this category.
$params = [
'context' => \context_module::instance($quiz->cmid),
'other' => ['categoryid' => $categoryid, 'format' => 'testformat'],
];
$event = \core\event\questions_exported::create($params);
// Trigger and capture the event.
$sink = $this->redirectEvents();
$event->trigger();
$events = $sink->get_events();
$event = reset($events);
// Check that the event data is valid.
$this->assertInstanceOf('\core\event\questions_exported', $event);
$this->assertEquals(\context_module::instance($quiz->cmid), $event->get_context());
$this->assertEquals($categoryid, $event->other['categoryid']);
$this->assertEquals('testformat', $event->other['format']);
$this->assertDebuggingNotCalled();
}
/**
* Test the question created event.
*/
public function test_question_created(): void {
$this->setAdminUser();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]);
// Trigger and capture the event.
$sink = $this->redirectEvents();
$questiondata = $generator->create_question('description', null, ['category' => $cat->id]);
$question = \question_bank::load_question($questiondata->id);
$events = $sink->get_events();
$event = reset($events);
// Check that the event data is valid.
$this->assertInstanceOf('\core\event\question_created', $event);
$this->assertEquals($question->id, $event->objectid);
$this->assertEquals($cat->id, $event->other['categoryid']);
$this->assertDebuggingNotCalled();
}
/**
* Test the question deleted event.
*/
public function test_question_deleted(): void {
$this->setAdminUser();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]);
$questiondata = $generator->create_question('description', null, ['category' => $cat->id]);
$question = \question_bank::load_question($questiondata->id);
// Trigger and capture the event.
$sink = $this->redirectEvents();
question_delete_question($question->id);
$events = $sink->get_events();
$event = reset($events);
// Check that the event data is valid.
$this->assertInstanceOf('\core\event\question_deleted', $event);
$this->assertEquals($question->id, $event->objectid);
$this->assertEquals($cat->id, $event->other['categoryid']);
$this->assertDebuggingNotCalled();
}
/**
* Test the question updated event.
*/
public function test_question_updated(): void {
global $CFG;
require_once($CFG->dirroot . '/question/type/description/questiontype.php');
require_once($CFG->dirroot . '/question/type/edit_question_form.php');
require_once($CFG->dirroot . '/question/type/description/edit_description_form.php');
$this->setAdminUser();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]);
$questiondata = $generator->create_question('description', null, ['category' => $cat->id]);
$question = \question_bank::load_question($questiondata->id);
$qtype = new qtype_description();
$formdata = test_question_maker::get_question_form_data('description');
$formdata->category = "{$cat->id},{$cat->contextid}";
qtype_description_edit_form::mock_submit((array) $formdata);
$form = qtype_description_test_helper::get_question_editing_form($cat, $questiondata);
$fromform = $form->get_data();
// Trigger and capture the event.
$sink = $this->redirectEvents();
$question = $qtype->save_question($questiondata, $fromform);
$events = $sink->get_events();
$event = reset($events);
// Check that the event data is valid.
// Every save is a new question after Moodle 4.0.
$this->assertInstanceOf('\core\event\question_created', $event);
$this->assertEquals($question->id, $event->objectid);
$this->assertEquals($cat->id, $event->other['categoryid']);
$this->assertDebuggingNotCalled();
}
/**
* Test the question moved event.
*/
public function test_question_moved(): void {
$this->setAdminUser();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat1 = $generator->create_question_category([
'name' => 'My category 1', 'sortorder' => 1]);
$cat2 = $generator->create_question_category([
'name' => 'My category 2', 'sortorder' => 2]);
$questiondata = $generator->create_question('description', null, ['category' => $cat1->id]);
$question = \question_bank::load_question($questiondata->id);
// Trigger and capture the event.
$sink = $this->redirectEvents();
question_move_questions_to_category([$question->id], $cat2->id);
$events = $sink->get_events();
$event = reset($events);
// Check that the event data is valid.
$this->assertInstanceOf('\core\event\question_moved', $event);
$this->assertEquals($question->id, $event->objectid);
$this->assertEquals($cat1->id, $event->other['oldcategoryid']);
$this->assertEquals($cat2->id, $event->other['newcategoryid']);
$this->assertDebuggingNotCalled();
}
/**
* Test the question viewed event.
* There is no external API for viewing the question, so the unit test will simply
* create and trigger the event and ensure data is returned as expected.
*/
public function test_question_viewed(): void {
$this->setAdminUser();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]);
$questiondata = $generator->create_question('description', null, ['category' => $cat->id]);
$question = \question_bank::load_question($questiondata->id);
$event = \core\event\question_viewed::create_from_question_instance($question, \context::instance_by_id($cat->contextid));
// Trigger and capture the event.
$sink = $this->redirectEvents();
$event->trigger();
$events = $sink->get_events();
$event = reset($events);
// Check that the event data is valid.
$this->assertInstanceOf('\core\event\question_viewed', $event);
$this->assertEquals($question->id, $event->objectid);
$this->assertEquals($cat->id, $event->other['categoryid']);
$this->assertDebuggingNotCalled();
}
}
+474
View File
@@ -0,0 +1,474 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use core_external\restricted_context_exception;
use core_question_external;
use externallib_advanced_testcase;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/webservice/tests/helpers.php');
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
/**
* Question external functions tests
*
* @package core_question
* @covers \core_question_external
* @category external
* @copyright 2016 Pau Ferrer <pau@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 3.1
*/
class externallib_test extends externallib_advanced_testcase {
/** @var \stdClass course record. */
protected $course;
/** @var \stdClass user record. */
protected $student;
/** @var \stdClass user role record. */
protected $studentrole;
/**
* Set up for every test
*/
public function setUp(): void {
global $DB;
$this->resetAfterTest();
$this->setAdminUser();
// Setup test data.
$this->course = $this->getDataGenerator()->create_course();
// Create users.
$this->student = self::getDataGenerator()->create_user();
// Users enrolments.
$this->studentrole = $DB->get_record('role', ['shortname' => 'student']);
$this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->studentrole->id, 'manual');
}
/**
* Test update question flag
*/
public function test_core_question_update_flag(): void {
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create a question category.
$cat = $questiongenerator->create_question_category();
$quba = \question_engine::make_questions_usage_by_activity('core_question_update_flag', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
$question = \question_bank::load_question($questiondata->id);
$slot = $quba->add_question($question);
$qa = $quba->get_question_attempt($slot);
self::setUser($this->student);
$quba->start_all_questions();
\question_engine::save_questions_usage_by_activity($quba);
$qubaid = $quba->get_id();
$questionid = $question->id;
$qaid = $qa->get_database_id();
$checksum = md5($qubaid . "_" . $this->student->secret . "_" . $questionid . "_" . $qaid . "_" . $slot);
$flag = core_question_external::update_flag($qubaid, $questionid, $qaid, $slot, $checksum, true);
$this->assertTrue($flag['status']);
// Test invalid checksum.
try {
// Using random_string to force failing.
$checksum = md5($qubaid . "_" . random_string(11) . "_" . $questionid . "_" . $qaid . "_" . $slot);
core_question_external::update_flag($qubaid, $questionid, $qaid, $slot, $checksum, true);
$this->fail('Exception expected due to invalid checksum.');
} catch (\moodle_exception $e) {
$this->assertEquals('errorsavingflags', $e->errorcode);
}
}
/**
* Data provider for the get_random_question_summaries test.
*/
public function get_random_question_summaries_test_cases() {
return [
'empty category' => [
'categoryindex' => 'emptycat',
'includesubcategories' => false,
'usetagnames' => [],
'expectedquestionindexes' => []
],
'single category' => [
'categoryindex' => 'cat1',
'includesubcategories' => false,
'usetagnames' => [],
'expectedquestionindexes' => ['cat1q1', 'cat1q2']
],
'include sub category' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => [],
'expectedquestionindexes' => ['cat1q1', 'cat1q2', 'subcatq1', 'subcatq2']
],
'single category with tags' => [
'categoryindex' => 'cat1',
'includesubcategories' => false,
'usetagnames' => ['cat1'],
'expectedquestionindexes' => ['cat1q1']
],
'include sub category with tag on parent' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['cat1'],
'expectedquestionindexes' => ['cat1q1']
],
'include sub category with tag on sub' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['subcat'],
'expectedquestionindexes' => ['subcatq1']
],
'include sub category with same tag on parent and sub' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['foo'],
'expectedquestionindexes' => ['cat1q1', 'subcatq1']
],
'include sub category with tag not matching' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['cat1', 'cat2'],
'expectedquestionindexes' => []
]
];
}
/**
* Test the get_random_question_summaries function with various parameter combinations.
*
* This function creates a data set as follows:
* Category: cat1
* Question: cat1q1
* Tags: 'cat1', 'foo'
* Question: cat1q2
* Category: cat2
* Question: cat2q1
* Tags: 'cat2', 'foo'
* Question: cat2q2
* Category: subcat
* Question: subcatq1
* Tags: 'subcat', 'foo'
* Question: subcatq2
* Parent: cat1
* Category: emptycat
*
* @dataProvider get_random_question_summaries_test_cases()
* @param string $categoryindex The named index for the category to use
* @param bool $includesubcategories If the search should include subcategories
* @param string[] $usetagnames The tag names to include in the search
* @param string[] $expectedquestionindexes The questions expected in the result
*/
public function test_get_random_question_summaries_variations(
$categoryindex,
$includesubcategories,
$usetagnames,
$expectedquestionindexes
): void {
$this->resetAfterTest();
$context = \context_system::instance();
$categories = [];
$questions = [];
$tagnames = [
'cat1',
'cat2',
'subcat',
'foo'
];
$collid = \core_tag_collection::get_default();
$tags = \core_tag_tag::create_if_missing($collid, $tagnames);
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
// First category and questions.
list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat1', 'foo']);
$categories['cat1'] = $category;
$questions['cat1q1'] = $categoryquestions[0];
$questions['cat1q2'] = $categoryquestions[1];
// Second category and questions.
list($category, $categoryquestions) = $this->create_category_and_questions(2, ['cat2', 'foo']);
$categories['cat2'] = $category;
$questions['cat2q1'] = $categoryquestions[0];
$questions['cat2q2'] = $categoryquestions[1];
// Sub category and questions.
list($category, $categoryquestions) = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']);
$categories['subcat'] = $category;
$questions['subcatq1'] = $categoryquestions[0];
$questions['subcatq2'] = $categoryquestions[1];
// Empty category.
list($category, $categoryquestions) = $this->create_category_and_questions(0);
$categories['emptycat'] = $category;
// Generate the arguments for the get_questions function.
$category = $categories[$categoryindex];
$tagids = array_map(function($tagname) use ($tags) {
return $tags[$tagname]->id;
}, $usetagnames);
$result = core_question_external::get_random_question_summaries($category->id, $includesubcategories, $tagids, $context->id);
$resultquestions = $result['questions'];
$resulttotalcount = $result['totalcount'];
// Generate the expected question set.
$expectedquestions = array_map(function($index) use ($questions) {
return $questions[$index];
}, $expectedquestionindexes);
// Ensure the resultquestions matches what was expected.
$this->assertCount(count($expectedquestions), $resultquestions);
$this->assertEquals(count($expectedquestions), $resulttotalcount);
foreach ($expectedquestions as $question) {
$this->assertEquals($resultquestions[$question->id]->id, $question->id);
$this->assertEquals($resultquestions[$question->id]->category, $question->category);
}
}
/**
* get_random_question_summaries should throw an invalid_parameter_exception if not
* given an integer for the category id.
*/
public function test_get_random_question_summaries_invalid_category_id_param(): void {
$this->resetAfterTest();
$context = \context_system::instance();
$this->expectException('\invalid_parameter_exception');
core_question_external::get_random_question_summaries('invalid value', false, [], $context->id);
}
/**
* get_random_question_summaries should throw an invalid_parameter_exception if not
* given a boolean for the $includesubcategories parameter.
*/
public function test_get_random_question_summaries_invalid_includesubcategories_param(): void {
$this->resetAfterTest();
$context = \context_system::instance();
$this->expectException('\invalid_parameter_exception');
core_question_external::get_random_question_summaries(1, 'invalid value', [], $context->id);
}
/**
* get_random_question_summaries should throw an invalid_parameter_exception if not
* given an array of integers for the tag ids parameter.
*/
public function test_get_random_question_summaries_invalid_tagids_param(): void {
$this->resetAfterTest();
$context = \context_system::instance();
$this->expectException('\invalid_parameter_exception');
core_question_external::get_random_question_summaries(1, false, ['invalid', 'values'], $context->id);
}
/**
* get_random_question_summaries should throw an invalid_parameter_exception if not
* given a context.
*/
public function test_get_random_question_summaries_invalid_context(): void {
$this->resetAfterTest();
$this->expectException('\invalid_parameter_exception');
core_question_external::get_random_question_summaries(1, false, [1, 2], 'context');
}
/**
* get_random_question_summaries should throw an restricted_context_exception
* if the given context is outside of the set of restricted contexts the user
* is allowed to call external functions in.
*/
public function test_get_random_question_summaries_restricted_context(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$coursecontext = \context_course::instance($course->id);
$systemcontext = \context_system::instance();
// Restrict access to external functions for the logged in user to only
// the course we just created. External functions should not be allowed
// to execute in any contexts above the course context.
core_question_external::set_context_restriction($coursecontext);
// An exception should be thrown when we try to execute at the system context
// since we're restricted to the course context.
try {
// Do this in a try/catch statement to allow the context restriction
// to be reset afterwards.
core_question_external::get_random_question_summaries(1, false, [], $systemcontext->id);
} catch (\Exception $e) {
$this->assertInstanceOf(restricted_context_exception::class, $e);
}
// Reset the restriction so that other tests don't fail aftwards.
core_question_external::set_context_restriction($systemcontext);
}
/**
* get_random_question_summaries should return a question that is formatted correctly.
*/
public function test_get_random_question_summaries_formats_returned_questions(): void {
$this->resetAfterTest();
list($category, $questions) = $this->create_category_and_questions(1);
$context = \context_system::instance();
$question = $questions[0];
$expected = (object) [
'id' => $question->id,
'category' => $question->category,
'parent' => $question->parent,
'name' => $question->name,
'qtype' => $question->qtype
];
$result = core_question_external::get_random_question_summaries($category->id, false, [], $context->id);
$actual = $result['questions'][$question->id];
$this->assertEquals($expected->id, $actual->id);
$this->assertEquals($expected->category, $actual->category);
$this->assertEquals($expected->parent, $actual->parent);
$this->assertEquals($expected->name, $actual->name);
$this->assertEquals($expected->qtype, $actual->qtype);
// These values are added by the formatting. It doesn't matter what the
// exact values are just that they are returned.
$this->assertObjectHasProperty('icon', $actual);
$this->assertObjectHasProperty('key', $actual->icon);
$this->assertObjectHasProperty('component', $actual->icon);
$this->assertObjectHasProperty('alttext', $actual->icon);
}
/**
* get_random_question_summaries should allow limiting and offsetting of the result set.
*/
public function test_get_random_question_summaries_with_limit_and_offset(): void {
$this->resetAfterTest();
$numberofquestions = 5;
$includesubcategories = false;
$tagids = [];
$limit = 1;
$offset = 0;
$context = \context_system::instance();
list($category, $questions) = $this->create_category_and_questions($numberofquestions);
// Sort the questions by id to match the ordering of the result.
usort($questions, function($a, $b) {
$aid = $a->id;
$bid = $b->id;
if ($aid == $bid) {
return 0;
}
return $aid < $bid ? -1 : 1;
});
for ($i = 0; $i < $numberofquestions; $i++) {
$result = core_question_external::get_random_question_summaries(
$category->id,
$includesubcategories,
$tagids,
$context->id,
$limit,
$offset
);
$resultquestions = $result['questions'];
$totalcount = $result['totalcount'];
$this->assertCount($limit, $resultquestions);
$this->assertEquals($numberofquestions, $totalcount);
$actual = array_shift($resultquestions);
$expected = $questions[$i];
$this->assertEquals($expected->id, $actual->id);
$offset++;
}
}
/**
* get_random_question_summaries should throw an exception if the user doesn't
* have the capability to use the questions in the requested category.
*/
public function test_get_random_question_summaries_without_capability(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$user = $generator->create_user();
$roleid = $generator->create_role();
$systemcontext = \context_system::instance();
$numberofquestions = 5;
$includesubcategories = false;
$tagids = [];
$context = \context_system::instance();
list($category, $questions) = $this->create_category_and_questions($numberofquestions);
$categorycontext = \context::instance_by_id($category->contextid);
$generator->role_assign($roleid, $user->id, $systemcontext->id);
// Prohibit all of the tag capabilities.
assign_capability('moodle/question:viewall', CAP_PROHIBIT, $roleid, $categorycontext->id);
$this->setUser($user);
$this->expectException('moodle_exception');
core_question_external::get_random_question_summaries(
$category->id,
$includesubcategories,
$tagids,
$context->id
);
}
/**
* Create a question category and create questions in that category. Tag
* the first question in each category with the given tags.
*
* @param int $questioncount How many questions to create.
* @param string[] $tagnames The list of tags to use.
* @param stdClass|null $parentcategory The category to set as the parent of the created category.
* @return array The category and questions.
*/
protected function create_category_and_questions($questioncount, $tagnames = [], $parentcategory = null) {
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
if ($parentcategory) {
$catparams = ['parent' => $parentcategory->id];
} else {
$catparams = [];
}
$category = $generator->create_question_category($catparams);
$questions = [];
for ($i = 0; $i < $questioncount; $i++) {
$questions[] = $generator->create_question('shortanswer', null, ['category' => $category->id]);
}
if (!empty($tagnames) && !empty($questions)) {
$context = \context::instance_by_id($category->contextid);
\core_tag_tag::set_item_tags('core_question', 'question', $questions[0]->id, $context, $tagnames);
}
return [$category, $questions];
}
}
@@ -0,0 +1,66 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Helper class to to test column_base class.
*
* @package core_question
* @copyright 2018 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Helper class to to test column_base class.
*
* @package core_question
* @copyright 2018 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class testable_core_question_column extends \core_question\local\bank\column_base {
/** @var array sortable columns. */
private $sortable = [];
/**
* Output the column header cell.
*/
public function is_sortable() {
return $this->sortable;
}
/**
* Set the sortable columns for testing.
*
* @param array $sortable
*/
public function set_sortable(array $sortable) {
$this->sortable = $sortable;
}
protected function display_content($question, $rowclasses) {
echo 'Test Column';
}
public function get_name() {
return 'test_column';
}
public function get_title() {
return 'Test Column';
}
}
@@ -0,0 +1,85 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Behat data generator for core_question.
*
* @package core_question
* @category test
* @copyright 2020 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Behat data generator for core_question.
*/
class behat_core_question_generator extends behat_generator_base {
protected function get_creatable_entities(): array {
// Note, for historical reasons, questions and question categories
// are generated by behat_core_generator.
return [
'Tags' => [
'singular' => 'Tag',
'datagenerator' => 'question_tag',
'required' => ['question', 'tag'],
'switchids' => ['question' => 'questionid'],
],
'updated questions' => [
'singular' => 'question',
'datagenerator' => 'updated_question',
'required' => ['question', 'questioncategory'],
'switchids' => ['question' => 'id', 'questioncategory' => 'category'],
],
];
}
/**
* Look up the id of a question from its name.
*
* @param string $questionname the question name, for example 'Question 1'.
* @return int corresponding id.
*/
protected function get_question_id(string $questionname): int {
global $DB;
if (!$id = $DB->get_field('question', 'id', ['name' => $questionname])) {
throw new Exception('There is no question with name "' . $questionname . '".');
}
return $id;
}
/**
* Update a question
*
* This will update a question matching the supplied name with the provided data, creating a new version in the process.
*
* @param array $data the row of data from the behat script.
* @return void
*/
protected function process_updated_question(array $data): void {
global $DB;
$question = $DB->get_record('question', ['id' => $data['id']], '*', MUST_EXIST);
foreach ($data as $key => $value) {
$question->{$key} = $value;
}
$this->datagenerator->get_plugin_generator('core_question')->update_question($question);
}
}
+266
View File
@@ -0,0 +1,266 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Quiz module test data generator.
*
* @package core_question
* @copyright 2013 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_question\local\bank\question_version_status;
/**
* Class core_question_generator for generating question data.
*
* @package core_question
* @copyright 2013 The Open University
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class core_question_generator extends component_generator_base {
/**
* @var number of created instances
*/
protected $categorycount = 0;
/**
* Make the category count to zero.
*/
public function reset() {
$this->categorycount = 0;
}
/**
* Create a new question category.
*
* @param array|stdClass $record
* @return stdClass question_categories record.
*/
public function create_question_category($record = null) {
global $DB;
$this->categorycount ++;
$defaults = [
'name' => 'Test question category ' . $this->categorycount,
'info' => '',
'infoformat' => FORMAT_HTML,
'stamp' => make_unique_id_code(),
'sortorder' => 999,
'idnumber' => null
];
$record = $this->datagenerator->combine_defaults_and_record($defaults, $record);
if (!isset($record['contextid'])) {
if (isset($record['parent'])) {
$record['contextid'] = $DB->get_field('question_categories', 'contextid', ['id' => $record['parent']]);
} else {
$record['contextid'] = context_system::instance()->id;
}
}
if (!isset($record['parent'])) {
$record['parent'] = question_get_top_category($record['contextid'], true)->id;
}
$record['id'] = $DB->insert_record('question_categories', $record);
return (object) $record;
}
/**
* Create a new question. The question is initialised using one of the
* examples from the appropriate {@link question_test_helper} subclass.
* Then, any files you want to change from the value in the base example you
* can override using $overrides.
*
* @param string $qtype the question type to create an example of.
* @param string $which as for the corresponding argument of
* {@link question_test_helper::get_question_form_data}. null for the default one.
* @param array|stdClass $overrides any fields that should be different from the base example.
* @return stdClass the question data.
*/
public function create_question($qtype, $which = null, $overrides = null) {
$question = new stdClass();
$question->qtype = $qtype;
$question->createdby = 0;
$question->idnumber = null;
$question->status = question_version_status::QUESTION_STATUS_READY;
return $this->update_question($question, $which, $overrides);
}
/**
* Create a tag on a question.
*
* @param array $data with two elements ['questionid' => 123, 'tag' => 'mytag'].
*/
public function create_question_tag(array $data): void {
$question = question_bank::load_question($data['questionid']);
core_tag_tag::add_item_tag('core_question', 'question', $question->id,
context::instance_by_id($question->contextid), $data['tag'], 0);
}
/**
* Update an existing question.
*
* @param stdClass $question the question data to update.
* @param string $which as for the corresponding argument of
* {@link question_test_helper::get_question_form_data}. null for the default one.
* @param array|stdClass $overrides any fields that should be different from the base example.
* @return stdClass the question data, including version info and questionbankentryid
*/
public function update_question($question, $which = null, $overrides = null) {
global $CFG, $DB;
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
$question = clone($question);
$qtype = $question->qtype;
$fromform = test_question_maker::get_question_form_data($qtype, $which);
$fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $question, $fromform);
$fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $fromform, $overrides);
$fromform->status = $fromform->status ?? $question->status;
$question = question_bank::get_qtype($qtype)->save_question($question, $fromform);
if ($overrides && (array_key_exists('createdby', $overrides) || array_key_exists('modifiedby', $overrides))) {
// Manually update the createdby and modifiedby because questiontypebase forces
// current user and some tests require a specific user.
if (array_key_exists('createdby', $overrides)) {
$question->createdby = $overrides['createdby'];
}
if (array_key_exists('modifiedby', $overrides)) {
$question->modifiedby = $overrides['modifiedby'];
}
$DB->update_record('question', $question);
}
$questionversion = $DB->get_record('question_versions', ['questionid' => $question->id], '*', MUST_EXIST);
$question->versionid = $questionversion->id;
$question->questionbankentryid = $questionversion->questionbankentryid;
$question->version = $questionversion->version;
$question->status = $questionversion->status;
return $question;
}
/**
* Setup a course category, course, a question category, and 2 questions
* for testing.
*
* @param string $type The type of question category to create.
* @return array The created data objects
*/
public function setup_course_and_questions($type = 'course') {
$datagenerator = $this->datagenerator;
$category = $datagenerator->create_category();
$course = $datagenerator->create_course([
'numsections' => 5,
'category' => $category->id
]);
switch ($type) {
case 'category':
$context = context_coursecat::instance($category->id);
break;
case 'system':
$context = context_system::instance();
break;
default:
$context = context_course::instance($course->id);
break;
}
$qcat = $this->create_question_category(['contextid' => $context->id]);
$questions = [
$this->create_question('shortanswer', null, ['category' => $qcat->id]),
$this->create_question('shortanswer', null, ['category' => $qcat->id]),
];
return [$category, $course, $qcat, $questions];
}
/**
* This method can construct what the post data would be to simulate a user submitting
* responses to a number of questions within a question usage.
*
* In the responses array, the array keys are the slot numbers for which a response will
* be submitted. You can submit a response to any number of questions within the usage.
* There is no need to do them all. The values are a string representation of the response.
* The exact meaning of that depends on the particular question type. These strings
* are passed to the un_summarise_response method of the question to decode.
*
* @param question_usage_by_activity $quba the question usage.
* @param array $responses the responses to submit, in the format described above.
* @param bool $checkbutton if simulate a click on the check button for each question, else simulate save.
* This should only be used with behaviours that have a check button.
* @return array that can be passed to methods like $quba->process_all_actions as simulated POST data.
*/
public function get_simulated_post_data_for_questions_in_usage(
question_usage_by_activity $quba, array $responses, $checkbutton) {
$postdata = [];
foreach ($responses as $slot => $responsesummary) {
$postdata += $this->get_simulated_post_data_for_question_attempt(
$quba->get_question_attempt($slot), $responsesummary, $checkbutton);
}
return $postdata;
}
/**
* This method can construct what the post data would be to simulate a user submitting
* responses to one particular question attempt.
*
* The $responsesummary is a string representation of the response to be submitted.
* The exact meaning of that depends on the particular question type. These strings
* are passed to the un_summarise_response method of the question to decode.
*
* @param question_attempt $qa the question attempt for which we are generating POST data.
* @param string $responsesummary a textual summary of the response, as described above.
* @param bool $checkbutton if simulate a click on the check button, else simulate save.
* This should only be used with behaviours that have a check button.
* @return array the simulated post data that can be passed to $quba->process_all_actions.
*/
public function get_simulated_post_data_for_question_attempt(
question_attempt $qa, $responsesummary, $checkbutton) {
$question = $qa->get_question();
if (!$question instanceof question_with_responses) {
return [];
}
$postdata = [];
$postdata[$qa->get_control_field_name('sequencecheck')] = (string)$qa->get_sequence_check_count();
$postdata[$qa->get_flag_field_name()] = (string)(int)$qa->is_flagged();
$response = $question->un_summarise_response($responsesummary);
foreach ($response as $name => $value) {
$postdata[$qa->get_qt_field_name($name)] = (string)$value;
}
// TODO handle behaviour variables better than this.
if ($checkbutton) {
$postdata[$qa->get_behaviour_field_name('submit')] = 1;
}
return $postdata;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Data generators tests
*
* @package core_question
* @subpackage questionengine
* @copyright 2013 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question;
/**
* Test data generator
*
* @copyright 2013 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class generator_test extends \advanced_testcase {
public function test_create(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$count = $DB->count_records('question_categories');
$cat = $generator->create_question_category();
$count += $count ? 1 : 2; // Calling $generator->create_question_category() for the first time
// creates a Top category as well.
$this->assertEquals($count, $DB->count_records('question_categories'));
$cat = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1]);
$this->assertSame('My category', $cat->name);
$this->assertSame(1, $cat->sortorder);
}
public function test_idnumbers_in_categories_and_questions(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
list($category, $course, $qcat, $questions) = $generator->setup_course_and_questions();
// Check default idnumbers in question_category and questions.
$this->assertNull($qcat->idnumber);
$this->assertNull($questions[0]->idnumber);
$this->assertNull($questions[1]->idnumber);
// Check created idnumbers.
$qcat1 = $generator->create_question_category([
'name' => 'My category', 'sortorder' => 1, 'idnumber' => 'myqcat']);
$this->assertSame('myqcat', $qcat1->idnumber);
$quest1 = $generator->update_question($questions[0], null, ['idnumber' => 'myquest']);
$this->assertSame('myquest', $quest1->idnumber);
$quest3 = $generator->create_question('shortanswer', null,
['name' => 'sa1', 'category' => $qcat1->id, 'idnumber' => 'myquest_3']);
$this->assertSame('myquest_3', $quest3->idnumber);
// Check idnumbers of questions moved. Note have to use load_question_data or we only get to see old cached data.
question_move_questions_to_category([$quest1->id], $qcat1->id);
$this->assertSame('myquest', \question_bank::load_question_data($quest1->id)->idnumber);
// Can only change idnumber of quest2 once quest1 has been moved to another category.
$quest2 = $generator->update_question($questions[1], null, ['idnumber' => 'myquest_4']);
question_move_questions_to_category([$quest2->id], $qcat1->id);
$this->assertSame('myquest_4', \question_bank::load_question_data($quest2->id)->idnumber);
// Check can add an idnumber of 0.
$quest4 = $generator->create_question('shortanswer', null, ['name' => 'sa1', 'category' => $qcat1->id, 'idnumber' => '0']);
$this->assertSame('0', $quest4->idnumber);
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Unit tests for the question import and export system.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question;
use qformat_default;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->libdir . '/questionlib.php');
require_once($CFG->dirroot . '/question/format.php');
/**
* Subclass to make it easier to test qformat_default.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class testable_qformat extends qformat_default {
public function assemble_category_path($names) {
return parent::assemble_category_path($names);
}
public function split_category_path($names) {
return parent::split_category_path($names);
}
}
/**
* Unit tests for the matching question definition class.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class importexport_test extends \advanced_testcase {
public function test_assemble_category_path(): void {
$format = new testable_qformat();
$pathsections = [
'$course$',
"Tim's questions",
"Tricky things like / // and so on",
'Category name ending in /',
'/ and one that starts with one',
'<span lang="en" class="multilang">Matematically</span> <span lang="sv" class="multilang">Matematiskt (svenska)</span>'
];
$this->assertEquals('$course$/Tim\'s questions/Tricky things like // //// and so on/Category name ending in // / // and one that starts with one/<span lang="en" class="multilang">Matematically<//span> <span lang="sv" class="multilang">Matematiskt (svenska)<//span>',
$format->assemble_category_path($pathsections));
}
public function test_split_category_path(): void {
$format = new testable_qformat();
$path = '$course$/Tim\'s questions/Tricky things like // //// and so on/Category name ending in // / // and one that starts with one/<span lang="en" class="multilang">Matematically<//span> <span lang="sv" class="multilang">Matematiskt (svenska)<//span>';
$this->assertEquals([
'$course$',
"Tim's questions",
"Tricky things like / // and so on",
'Category name ending in /',
'/ and one that starts with one',
'<span lang="en" class="multilang">Matematically</span> <span lang="sv" class="multilang">Matematiskt (svenska)</span>'
], $format->split_category_path($path));
}
public function test_split_category_path_cleans(): void {
$format = new testable_qformat();
$path = '<evil>Nasty <virus //> thing<//evil>';
$this->assertEquals(['Nasty thing'], $format->split_category_path($path));
}
public function test_clean_question_name(): void {
$format = new testable_qformat();
$name = 'Nice simple name';
$this->assertEquals($name, $format->clean_question_name($name));
$name = 'Question in <span lang="en" class="multilang">English</span><span lang="fr" class="multilang">French</span>';
$this->assertEquals($name, $format->clean_question_name($name));
$name = 'Evil <script type="text/javascrip">alert("You have been hacked!");</script>';
$this->assertEquals('Evil alert("You have been hacked!");', $format->clean_question_name($name));
$name = 'This is a very long question name. It goes on and on and on. ' .
'I wonder if it will ever stop. The quetsion name field in the database is only ' .
'two hundred and fifty five characters wide, so if the import file contains a ' .
'name longer than that, the code had better truncate it!';
$this->assertEquals(shorten_text($name, 251), $format->clean_question_name($name));
// The worst case scenario is a whole lot of single charaters in separate multilang tags.
$name = '<span lang="en" class="multilang">A</span>' .
'<span lang="fr" class="multilang">B</span>' .
'<span lang="fr_ca" class="multilang">C</span>' .
'<span lang="en_us" class="multilang">D</span>' .
'<span lang="de" class="multilang">E</span>' .
'<span lang="cz" class="multilang">F</span>' .
'<span lang="it" class="multilang">G</span>' .
'<span lang="es" class="multilang">H</span>' .
'<span lang="pt" class="multilang">I</span>' .
'<span lang="ch" class="multilang">J</span>';
$this->assertEquals(shorten_text($name, 1), $format->clean_question_name($name));
}
public function test_create_default_question_name(): void {
$format = new testable_qformat();
$text = 'Nice simple name';
$this->assertEquals($text, $format->create_default_question_name($text, 'Default'));
$this->assertEquals('Default', $format->create_default_question_name('', 'Default'));
$text = 'Question in <span lang="en" class="multilang">English</span><span lang="fr" class="multilang">French</span>';
$this->assertEquals($text, $format->create_default_question_name($text, 'Default'));
$text = 'Evil <script type="text/javascrip">alert("You have been hacked!");</script>';
$this->assertEquals('Evil alert("You have been hacked!");',
$format->create_default_question_name($text, 'Default'));
$text = 'This is a very long question text. It goes on and on and on. ' .
'I wonder if it will ever stop. The question name field in the database is only ' .
'two hundred and fifty five characters wide, so if the import file contains a ' .
'name longer than that, the code had better truncate it!';
$this->assertEquals(shorten_text($text, 80), $format->create_default_question_name($text, 'Default'));
// The worst case scenario is a whole lot of single charaters in separate multilang tags.
$text = '<span lang="en" class="multilang">A</span>' .
'<span lang="fr" class="multilang">B</span>' .
'<span lang="fr_ca" class="multilang">C</span>' .
'<span lang="en_us" class="multilang">D</span>' .
'<span lang="de" class="multilang">E</span>' .
'<span lang="cz" class="multilang">F</span>' .
'<span lang="it" class="multilang">G</span>' .
'<span lang="es" class="multilang">H</span>' .
'<span lang="pt" class="multilang">I</span>' .
'<span lang="ch" class="multilang">J</span>';
$this->assertEquals(shorten_text($text, 1), $format->create_default_question_name($text, 'Default'));
}
}
@@ -0,0 +1,123 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use core_question\engine\variants\least_used_strategy;
use qubaid_list;
use question_bank;
use question_engine;
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.
*
* @package core_question
* @copyright 2015 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class least_used_variant_strategy_test extends \advanced_testcase {
public function test_question_with_one_variant_always_picks_that(): void {
$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 least_used_strategy(
$quba, new qubaid_list([])));
$this->assertEquals(1, $quba->get_variant($slot));
}
public function test_synchronised_question_should_use_the_same_dataset(): void {
// 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 least_used_strategy(
$quba, new qubaid_list([])));
$this->assertEquals($quba->get_variant($slot1), $quba->get_variant($slot2));
}
public function test_second_attempt_uses_other_dataset(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$questiondata = $generator->create_question('calculated', null, ['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 = ?", [$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 = ?", [$questiondata->id, 'b']);
$DB->set_field('question_dataset_definitions', 'itemcount', 2, ['id' => $adefinitionid]);
$DB->set_field('question_dataset_definitions', 'itemcount', 2, ['id' => $bdefinitionid]);
$DB->insert_record('question_dataset_items', ['definition' => $adefinitionid,
'itemnumber' => 1, 'value' => 3]);
$DB->insert_record('question_dataset_items', ['definition' => $bdefinitionid,
'itemnumber' => 1, 'value' => 7]);
$DB->insert_record('question_dataset_items', ['definition' => $adefinitionid,
'itemnumber' => 2, 'value' => 6]);
$DB->insert_record('question_dataset_items', ['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 least_used_strategy(
$quba1, new qubaid_list([])));
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 least_used_strategy(
$quba1, new qubaid_list([$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 least_used_strategy(
$quba1, new qubaid_list([$quba1->get_id(), $quba2->get_id()])));
$variant3 = $quba3->get_variant($slot3);
$this->assertTrue($variant3 == $variant1 || $variant3 == $variant2);
}
}
@@ -0,0 +1,79 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question\local\bank;
use context_course;
use context_coursecat;
use context_module;
use context_system;
use context_user;
/**
* Unit tests for the context_to_string_translator class.
*
* @package core_question
* @category test
* @copyright 2023 the Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_question\local\bank\context_to_string_translator
*/
class context_to_string_translator_test extends \advanced_testcase {
public function test_context_to_string_translator_test_good_case(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
// Generate a quiz in a course in a category.
$systemcontext = context_system::instance();
$category = $generator->create_category();
$categorycontext = context_coursecat::instance($category->id);
$course = $generator->create_course(['category' => $category->id]);
$coursecontext = context_course::instance($course->id);
$quiz = $generator->create_module('quiz', ['course' => $course->id]);
$quizcontext = context_module::instance($quiz->cmid);
// Create the context_to_string_translator.
$translator = new context_to_string_translator((new question_edit_contexts($quizcontext))->all());
// Verify its behaviour.
$this->assertEquals('module', $translator->context_to_string($quizcontext->id));
$this->assertEquals($quizcontext->id, $translator->string_to_context('module'));
$this->assertEquals('course', $translator->context_to_string($coursecontext->id));
$this->assertEquals($coursecontext->id, $translator->string_to_context('course'));
$this->assertEquals('cat1', $translator->context_to_string($categorycontext->id));
$this->assertEquals($categorycontext->id, $translator->string_to_context('cat1'));
$this->assertEquals('system', $translator->context_to_string($systemcontext->id));
$this->assertEquals($systemcontext->id, $translator->string_to_context('system'));
}
public function test_context_to_string_translator_throws_exception_with_bad_context(): void {
global $USER;
$this->resetAfterTest();
$this->setAdminUser();
$context = context_user::instance($USER->id);
$this->expectExceptionMessage('Unexpected context level User for context ' .
$context->id . ' in generate_context_to_string_array. ' .
'Questions can never exist in this type of context.');
new context_to_string_translator((new question_edit_contexts($context))->all());
}
}
@@ -0,0 +1,601 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question\local\statistics;
defined('MOODLE_INTERNAL') || die();
use advanced_testcase;
use context;
use context_module;
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
use quiz_statistics\tests\statistics_helper;
use core_question_generator;
use Generator;
use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
use question_engine;
use ReflectionMethod;
global $CFG;
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* Tests for question statistics.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_question\local\statistics\statistics_bulk_loader
*/
class statistics_bulk_loader_test extends advanced_testcase {
use \quiz_question_helper_test_trait;
/** @var float Delta used when comparing statistics values out-of 1. */
protected const DELTA = 0.00005;
/** @var float Delta used when comparing statistics values out-of 100. */
protected const PERCENT_DELTA = 0.005;
/**
* Test quizzes that contain a specified question.
*
* @covers ::get_all_places_where_questions_were_attempted
*/
public function test_get_all_places_where_questions_were_attempted(): void {
global $DB;
$this->resetAfterTest();
$this->setAdminUser();
$rcm = new ReflectionMethod(statistics_bulk_loader::class, 'get_all_places_where_questions_were_attempted');
// Create a course.
$course = $this->getDataGenerator()->create_course();
// Create three quizzes.
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$quiz1 = $quizgenerator->create_instance([
'course' => $course->id,
'grade' => 100.0, 'sumgrades' => 2,
'layout' => '1,2,0'
]);
$quiz1context = context_module::instance($quiz1->cmid);
$quiz2 = $quizgenerator->create_instance([
'course' => $course->id,
'grade' => 100.0, 'sumgrades' => 2,
'layout' => '1,2,0'
]);
$quiz2context = context_module::instance($quiz2->cmid);
$quiz3 = $quizgenerator->create_instance([
'course' => $course->id,
'grade' => 100.0, 'sumgrades' => 2,
'layout' => '1,2,0'
]);
$quiz3context = context_module::instance($quiz3->cmid);
// Create questions.
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$question1 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$question2 = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
// Add question 1 to quiz 1 and make an attempt.
quiz_add_quiz_question($question1->id, $quiz1);
// Quiz 1 attempt.
$this->submit_quiz($quiz1, [1 => ['answer' => 'frog']]);
// Add questions 1 and 2 to quiz 2.
quiz_add_quiz_question($question1->id, $quiz2);
quiz_add_quiz_question($question2->id, $quiz2);
$this->submit_quiz($quiz2, [1 => ['answer' => 'frog'], 2 => ['answer' => 10]]);
// Checking quizzes that use question 1.
$q1places = $rcm->invoke(null, [$question1->id]);
$this->assertCount(2, $q1places);
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz1context->id], $q1places[0]);
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q1places[1]);
// Checking quizzes that contain question 2.
$q2places = $rcm->invoke(null, [$question2->id]);
$this->assertCount(1, $q2places);
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q2places[0]);
// Add a random question to quiz3.
$this->add_random_questions($quiz3->id, 0, $cat->id, 1, false);
$this->submit_quiz($quiz3, [1 => ['answer' => 'willbewrong']]);
// Quiz 3 will now be in one of these arrays.
$q1places = $rcm->invoke(null, [$question1->id]);
$q2places = $rcm->invoke(null, [$question2->id]);
if (count($q1places) == 3) {
$newplace = end($q1places);
} else {
$newplace = end($q2places);
}
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz3context->id], $newplace);
// Simulate the situation where the context for quiz3 is gone from the database, without
// the corresponding attempt data being properly cleaned up. Ensure this does not cause errors.
$DB->delete_records('context', ['id' => context_module::instance($quiz3->cmid)->id]);
accesslib_clear_all_caches_for_unit_testing();
// Same asserts as above, before we added quiz3.
$q1places = $rcm->invoke(null, [$question1->id]);
$this->assertCount(2, $q1places);
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz1context->id], $q1places[0]);
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q1places[1]);
$q2places = $rcm->invoke(null, [$question2->id]);
$this->assertCount(1, $q2places);
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q2places[0]);
}
/**
* Create 2 quizzes.
*
* @return array return 2 quizzes
*/
private function prepare_quizzes(): array {
// Create a course.
$course = $this->getDataGenerator()->create_course();
// Make 2 quizzes.
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$layout = '1,2,0,3,4,0';
$quiz1 = $quizgenerator->create_instance([
'course' => $course->id,
'grade' => 100.0, 'sumgrades' => 2,
'layout' => $layout
]);
$quiz2 = $quizgenerator->create_instance([
'course' => $course->id,
'grade' => 100.0, 'sumgrades' => 2,
'layout' => $layout
]);
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$page = 1;
$questions = [];
foreach (explode(',', $layout) as $slot) {
if ($slot == 0) {
$page += 1;
continue;
}
$question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$questions[$slot] = $question;
quiz_add_quiz_question($question->id, $quiz1, $page);
quiz_add_quiz_question($question->id, $quiz2, $page);
}
return [$quiz1, $quiz2, $questions];
}
/**
* Submit quiz answers
*
* @param object $quiz
* @param array $answers
*/
private function submit_quiz(object $quiz, array $answers): void {
// Create user.
$user = $this->getDataGenerator()->create_user();
// Create attempt.
$quizobj = quiz_settings::create($quiz->id, $user->id);
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
$timenow = time();
$attempt = quiz_create_attempt($quizobj, 1, null, $timenow, false, $user->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
quiz_attempt_save_started($quizobj, $quba, $attempt);
// Submit attempt.
$attemptobj = quiz_attempt::create($attempt->id);
$attemptobj->process_submitted_actions($timenow, false, $answers);
$attemptobj->process_finish($timenow, false);
}
/**
* Generate attempt answers.
*
* @param array $correctanswerflags array of 1 or 0
* 1 : generate correct answer
* 0 : generate wrong answer
*
* @return array
*/
private function generate_attempt_answers(array $correctanswerflags): array {
$attempt = [];
for ($i = 1; $i <= 4; $i++) {
if (isset($correctanswerflags) && $correctanswerflags[$i - 1] == 1) {
// Correct answer.
$attempt[$i] = ['answer' => 'frog'];
} else {
$attempt[$i] = ['answer' => 'false'];
}
}
return $attempt;
}
/**
* Generate quizzes and submit answers.
*
* @param array $quiz1attempts quiz 1 attempts
* @param array $quiz2attempts quiz 2 attempts
*
* @return array
*/
private function prepare_and_submit_quizzes(array $quiz1attempts, array $quiz2attempts): array {
list($quiz1, $quiz2, $questions) = $this->prepare_quizzes();
// Submit attempts of quiz1.
foreach ($quiz1attempts as $attempt) {
$this->submit_quiz($quiz1, $attempt);
}
// Submit attempts of quiz2.
foreach ($quiz2attempts as $attempt) {
$this->submit_quiz($quiz2, $attempt);
}
// Calculate the statistics.
$this->expectOutputRegex('~.*Calculations completed.*~');
statistics_helper::run_pending_recalculation_tasks();
return [$quiz1, $quiz2, $questions];
}
/**
* To use private helper::extract_item_value function.
*
* @param all_calculated_for_qubaid_condition $statistics the batch of statistics.
* @param int $questionid a question id.
* @param string $item one of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'.
* @return float|null the required value.
*/
private function extract_item_value(all_calculated_for_qubaid_condition $statistics,
int $questionid, string $item): ?float {
$rcm = new ReflectionMethod(statistics_bulk_loader::class, 'extract_item_value');
return $rcm->invoke(null, $statistics, $questionid, $item);
}
/**
* To use private helper::load_statistics_for_place function (with mod_quiz component).
*
* @param context $context the context to load the statistics for.
* @return all_calculated_for_qubaid_condition|null question statistics.
*/
private function load_quiz_statistics_for_place(context $context): ?all_calculated_for_qubaid_condition {
$rcm = new ReflectionMethod(statistics_bulk_loader::class, 'load_statistics_for_place');
return $rcm->invoke(null, 'mod_quiz', $context);
}
/**
* Data provider for {@see test_load_question_facility()}.
*
* @return Generator
*/
public function load_question_facility_provider(): Generator {
yield 'Facility case 1' => [
'Quiz 1 attempts' => [
$this->generate_attempt_answers([1, 0, 0, 0]),
],
'Expected quiz 1 facilities' => [1.0, 0.0, 0.0, 0.0],
'Quiz 2 attempts' => [
$this->generate_attempt_answers([1, 0, 0, 0]),
$this->generate_attempt_answers([1, 1, 0, 0]),
],
'Expected quiz 2 facilities' => [1.0, 0.5, 0.0, 0.0],
'Expected average facilities' => [1.0, 0.25, 0.0, 0.0],
];
yield 'Facility case 2' => [
'Quiz 1 attempts' => [
$this->generate_attempt_answers([1, 0, 0, 0]),
$this->generate_attempt_answers([1, 1, 0, 0]),
$this->generate_attempt_answers([1, 1, 1, 0]),
],
'Expected quiz 1 facilities' => [1.0, 0.6667, 0.3333, 0.0],
'Quiz 2 attempts' => [
$this->generate_attempt_answers([1, 0, 0, 0]),
$this->generate_attempt_answers([1, 1, 0, 0]),
$this->generate_attempt_answers([1, 1, 1, 0]),
$this->generate_attempt_answers([1, 1, 1, 1]),
],
'Expected quiz 2 facilities' => [1.0, 0.75, 0.5, 0.25],
'Expected average facilities' => [1.0, 0.7083, 0.4167, 0.1250],
];
}
/**
* Test question facility
*
* @dataProvider load_question_facility_provider
*
* @param array $quiz1attempts quiz 1 attempts
* @param array $expectedquiz1facilities expected quiz 1 facilities
* @param array $quiz2attempts quiz 2 attempts
* @param array $expectedquiz2facilities expected quiz 2 facilities
* @param array $expectedaveragefacilities expected average facilities
*/
public function test_load_question_facility(
array $quiz1attempts,
array $expectedquiz1facilities,
array $quiz2attempts,
array $expectedquiz2facilities,
array $expectedaveragefacilities
): void {
$this->resetAfterTest();
list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
// Quiz 1 facilities.
$stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz1->cmid));
$quiz1facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility');
$quiz1facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility');
$quiz1facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility');
$quiz1facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility');
$this->assertEqualsWithDelta($expectedquiz1facilities[0], $quiz1facility1, self::DELTA);
$this->assertEqualsWithDelta($expectedquiz1facilities[1], $quiz1facility2, self::DELTA);
$this->assertEqualsWithDelta($expectedquiz1facilities[2], $quiz1facility3, self::DELTA);
$this->assertEqualsWithDelta($expectedquiz1facilities[3], $quiz1facility4, self::DELTA);
// Quiz 2 facilities.
$stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz2->cmid));
$quiz2facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility');
$quiz2facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility');
$quiz2facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility');
$quiz2facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility');
$this->assertEqualsWithDelta($expectedquiz2facilities[0], $quiz2facility1, self::DELTA);
$this->assertEqualsWithDelta($expectedquiz2facilities[1], $quiz2facility2, self::DELTA);
$this->assertEqualsWithDelta($expectedquiz2facilities[2], $quiz2facility3, self::DELTA);
$this->assertEqualsWithDelta($expectedquiz2facilities[3], $quiz2facility4, self::DELTA);
// Average question facilities.
$stats = statistics_bulk_loader::load_aggregate_statistics(
[$questions[1]->id, $questions[2]->id, $questions[3]->id, $questions[4]->id],
['facility']
);
$this->assertEqualsWithDelta($expectedaveragefacilities[0],
$stats[$questions[1]->id]['facility'], self::DELTA);
$this->assertEqualsWithDelta($expectedaveragefacilities[1],
$stats[$questions[2]->id]['facility'], self::DELTA);
$this->assertEqualsWithDelta($expectedaveragefacilities[2],
$stats[$questions[3]->id]['facility'], self::DELTA);
$this->assertEqualsWithDelta($expectedaveragefacilities[3],
$stats[$questions[4]->id]['facility'], self::DELTA);
}
/**
* Data provider for {@see test_load_question_discriminative_efficiency()}.
* @return Generator
*/
public function load_question_discriminative_efficiency_provider(): Generator {
yield 'Discriminative efficiency' => [
'Quiz 1 attempts' => [
$this->generate_attempt_answers([1, 0, 0, 0]),
$this->generate_attempt_answers([1, 1, 0, 0]),
$this->generate_attempt_answers([1, 0, 1, 0]),
$this->generate_attempt_answers([1, 1, 1, 1]),
],
'Expected quiz 1 discriminative efficiency' => [null, 33.33, 33.33, 100.00],
'Quiz 2 attempts' => [
$this->generate_attempt_answers([1, 1, 1, 1]),
$this->generate_attempt_answers([0, 0, 0, 0]),
$this->generate_attempt_answers([1, 0, 0, 1]),
$this->generate_attempt_answers([0, 1, 1, 0]),
],
'Expected quiz 2 discriminative efficiency' => [50.00, 50.00, 50.00, 50.00],
'Expected average discriminative efficiency' => [50.00, 41.67, 41.67, 75.00],
];
}
/**
* Test discriminative efficiency
*
* @dataProvider load_question_discriminative_efficiency_provider
*
* @param array $quiz1attempts quiz 1 attempts
* @param array $expectedquiz1discriminativeefficiency expected quiz 1 discriminative efficiency
* @param array $quiz2attempts quiz 2 attempts
* @param array $expectedquiz2discriminativeefficiency expected quiz 2 discriminative efficiency
* @param array $expectedaveragediscriminativeefficiency expected average discriminative efficiency
*/
public function test_load_question_discriminative_efficiency(
array $quiz1attempts,
array $expectedquiz1discriminativeefficiency,
array $quiz2attempts,
array $expectedquiz2discriminativeefficiency,
array $expectedaveragediscriminativeefficiency
): void {
$this->resetAfterTest();
list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
// Quiz 1 discriminative efficiency.
$stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz1->cmid));
$discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency');
$discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency');
$discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency');
$discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency');
$this->assertEqualsWithDelta($expectedquiz1discriminativeefficiency[0],
$discriminativeefficiency1, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz1discriminativeefficiency[1],
$discriminativeefficiency2, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz1discriminativeefficiency[2],
$discriminativeefficiency3, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz1discriminativeefficiency[3],
$discriminativeefficiency4, self::PERCENT_DELTA);
// Quiz 2 discriminative efficiency.
$stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz2->cmid));
$discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency');
$discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency');
$discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency');
$discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency');
$this->assertEqualsWithDelta($expectedquiz2discriminativeefficiency[0],
$discriminativeefficiency1, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz2discriminativeefficiency[1],
$discriminativeefficiency2, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz2discriminativeefficiency[2],
$discriminativeefficiency3, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz2discriminativeefficiency[3],
$discriminativeefficiency4, self::PERCENT_DELTA);
// Average question discriminative efficiency.
$stats = statistics_bulk_loader::load_aggregate_statistics(
[$questions[1]->id, $questions[2]->id, $questions[3]->id, $questions[4]->id],
['discriminativeefficiency']
);
$this->assertEqualsWithDelta($expectedaveragediscriminativeefficiency[0],
$stats[$questions[1]->id]['discriminativeefficiency'], self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedaveragediscriminativeefficiency[1],
$stats[$questions[2]->id]['discriminativeefficiency'], self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedaveragediscriminativeefficiency[2],
$stats[$questions[3]->id]['discriminativeefficiency'], self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedaveragediscriminativeefficiency[3],
$stats[$questions[4]->id]['discriminativeefficiency'], self::PERCENT_DELTA);
}
/**
* Data provider for {@see test_load_question_discrimination_index()}.
* @return Generator
*/
public function load_question_discrimination_index_provider(): Generator {
yield 'Discrimination Index' => [
'Quiz 1 attempts' => [
$this->generate_attempt_answers([1, 0, 0, 0]),
$this->generate_attempt_answers([1, 1, 0, 0]),
$this->generate_attempt_answers([1, 0, 1, 0]),
$this->generate_attempt_answers([1, 1, 1, 1]),
],
'Expected quiz 1 Discrimination Index' => [null, 30.15, 30.15, 81.65],
'Quiz 2 attempts' => [
$this->generate_attempt_answers([1, 1, 1, 1]),
$this->generate_attempt_answers([0, 0, 0, 0]),
$this->generate_attempt_answers([1, 0, 0, 1]),
$this->generate_attempt_answers([0, 1, 1, 0]),
],
'Expected quiz 2 discrimination Index' => [44.72, 44.72, 44.72, 44.72],
'Expected average discrimination Index' => [44.72, 37.44, 37.44, 63.19],
];
}
/**
* Test discrimination index
*
* @dataProvider load_question_discrimination_index_provider
*
* @param array $quiz1attempts quiz 1 attempts
* @param array $expectedquiz1discriminationindex expected quiz 1 discrimination index
* @param array $quiz2attempts quiz 2 attempts
* @param array $expectedquiz2discriminationindex expected quiz 2 discrimination index
* @param array $expectedaveragediscriminationindex expected average discrimination index
*/
public function test_load_question_discrimination_index(
array $quiz1attempts,
array $expectedquiz1discriminationindex,
array $quiz2attempts,
array $expectedquiz2discriminationindex,
array $expectedaveragediscriminationindex
): void {
$this->resetAfterTest();
list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
// Quiz 1 discrimination index.
$stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz1->cmid));
$discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex');
$discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex');
$discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex');
$discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex');
$this->assertEqualsWithDelta($expectedquiz1discriminationindex[0],
$discriminationindex1, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz1discriminationindex[1],
$discriminationindex2, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz1discriminationindex[2],
$discriminationindex3, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz1discriminationindex[3],
$discriminationindex4, self::PERCENT_DELTA);
// Quiz 2 discrimination index.
$stats = $this->load_quiz_statistics_for_place(context_module::instance($quiz2->cmid));
$discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex');
$discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex');
$discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex');
$discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex');
$this->assertEqualsWithDelta($expectedquiz2discriminationindex[0],
$discriminationindex1, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz2discriminationindex[1],
$discriminationindex2, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz2discriminationindex[2],
$discriminationindex3, self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedquiz2discriminationindex[3],
$discriminationindex4, self::PERCENT_DELTA);
// Average question discrimination index.
$stats = statistics_bulk_loader::load_aggregate_statistics(
[$questions[1]->id, $questions[2]->id, $questions[3]->id, $questions[4]->id],
['discriminationindex']
);
$this->assertEqualsWithDelta($expectedaveragediscriminationindex[0],
$stats[$questions[1]->id]['discriminationindex'], self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedaveragediscriminationindex[1],
$stats[$questions[2]->id]['discriminationindex'], self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedaveragediscriminationindex[2],
$stats[$questions[3]->id]['discriminationindex'], self::PERCENT_DELTA);
$this->assertEqualsWithDelta($expectedaveragediscriminationindex[3],
$stats[$questions[4]->id]['discriminationindex'], self::PERCENT_DELTA);
}
/**
* Test with question statistics disabled
*/
public function test_statistics_disabled(): void {
$this->resetAfterTest();
// Prepare some quizzes and attempts. Exactly what is not important to this test.
$quiz1attempts = [$this->generate_attempt_answers([1, 0, 0, 0])];
$quiz2attempts = [$this->generate_attempt_answers([1, 1, 1, 1])];
[, , $questions] = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
// Prepare some useful arrays.
$expectedstats = [
$questions[1]->id => [],
$questions[2]->id => [],
$questions[3]->id => [],
$questions[4]->id => [],
];
$questionids = array_keys($expectedstats);
// Ask to load no statistics at all.
$stats = statistics_bulk_loader::load_aggregate_statistics($questionids, []);
// Verify we got the right thing.
$this->assertEquals($expectedstats, $stats);
}
}
+544
View File
@@ -0,0 +1,544 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Privacy provider tests.
*
* @package core_question
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\privacy;
use core_privacy\local\metadata\collection;
use core_privacy\local\request\deletion_criteria;
use core_privacy\local\request\writer;
use core_question\privacy\provider;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->libdir . '/xmlize.php');
require_once(__DIR__ . '/../privacy_helper.php');
require_once(__DIR__ . '/../../engine/tests/helpers.php');
/**
* Privacy provider tests class.
*
* @package core_question
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider_test extends \core_privacy\tests\provider_testcase {
// Include the privacy helper which has assertions on it.
use \core_question_privacy_helper;
/**
* Prepare a question attempt.
*
* @return question_usage_by_activity
*/
protected function prepare_question_attempt() {
// Create a question with a usage from the current user.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$quba = \question_engine::make_questions_usage_by_activity('core_question_preview', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
$question = \question_bank::load_question($questiondata->id);
$quba->add_question($question);
$quba->start_all_questions();
\question_engine::save_questions_usage_by_activity($quba);
return $quba;
}
/**
* Test that calling export_question_usage on a usage belonging to a
* different user does not export any data.
*/
public function test_export_question_usage_no_usage(): void {
$this->resetAfterTest();
$quba = $this->prepare_question_attempt();
// Create a question with a usage from the current user.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$quba = \question_engine::make_questions_usage_by_activity('core_question_preview', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$questiondata = $questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
$question = \question_bank::load_question($questiondata->id);
$quba->add_question($question);
$quba->start_all_questions();
\question_engine::save_questions_usage_by_activity($quba);
// Set the user.
$testuser = $this->getDataGenerator()->create_user();
$this->setUser($testuser);
$context = $quba->get_owning_context();
$options = new \question_display_options();
provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, false);
/** @var \core_privacy\tests\request\content_writer $writer */
$writer = writer::with_context($context);
$this->assertFalse($writer->has_any_data_in_any_context());
}
/**
* Test that calling export_question_usage on a usage belonging to a
* different user but ignoring the user match
*/
public function test_export_question_usage_with_usage(): void {
$this->resetAfterTest();
$quba = $this->prepare_question_attempt();
// Create a question with a usage from the current user.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$quba = \question_engine::make_questions_usage_by_activity('core_question_preview', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$questiondata = $questiongenerator->create_question('truefalse', 'true', ['category' => $cat->id]);
$quba->add_question(\question_bank::load_question($questiondata->id));
$questiondata = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$quba->add_question(\question_bank::load_question($questiondata->id));
// Set the user and answer the questions.
$testuser = $this->getDataGenerator()->create_user();
$this->setUser($testuser);
$quba->start_all_questions();
$quba->process_action(1, ['answer' => 1]);
$quba->process_action(2, ['answer' => 'cat']);
$quba->finish_all_questions();
\question_engine::save_questions_usage_by_activity($quba);
$context = $quba->get_owning_context();
// Export all questions for this attempt.
$options = new \question_display_options();
provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, true);
/** @var \core_privacy\tests\request\content_writer $writer */
$writer = writer::with_context($context);
$this->assertTrue($writer->has_any_data_in_any_context());
$this->assertTrue($writer->has_any_data());
$slots = $quba->get_slots();
$this->assertCount(2, $slots);
foreach ($slots as $slotno) {
$data = $writer->get_data([get_string('questions', 'core_question'), $slotno]);
$this->assertNotNull($data);
$this->assert_question_slot_equals($quba, $slotno, $options, $data);
}
$this->assertEmpty($writer->get_data([get_string('questions', 'core_question'), $quba->next_slot_number()]));
// Disable some options and re-export.
writer::reset();
$options = new \question_display_options();
$options->hide_all_feedback();
$options->flags = \question_display_options::HIDDEN;
$options->marks = \question_display_options::HIDDEN;
provider::export_question_usage($testuser->id, $context, [], $quba->get_id(), $options, true);
/** @var \core_privacy\tests\request\content_writer $writer */
$writer = writer::with_context($context);
$this->assertTrue($writer->has_any_data_in_any_context());
$this->assertTrue($writer->has_any_data());
$slots = $quba->get_slots();
$this->assertCount(2, $slots);
foreach ($slots as $slotno) {
$data = $writer->get_data([get_string('questions', 'core_question'), $slotno]);
$this->assertNotNull($data);
$this->assert_question_slot_equals($quba, $slotno, $options, $data);
}
$this->assertEmpty($writer->get_data([get_string('questions', 'core_question'), $quba->next_slot_number()]));
}
/**
* Test that questions owned by a user are exported and never deleted.
*/
public function test_question_owned_is_handled(): void {
global $DB;
$this->resetAfterTest();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create the two test users.
$user = $this->getDataGenerator()->create_user();
$otheruser = $this->getDataGenerator()->create_user();
// Create one question as each user in diferent contexts.
$this->setUser($user);
$userdata = $questiongenerator->setup_course_and_questions();
$expectedcontext = \context_course::instance($userdata[1]->id);
$this->setUser($otheruser);
$otheruserdata = $questiongenerator->setup_course_and_questions();
$unexpectedcontext = \context_course::instance($otheruserdata[1]->id);
// And create another one where we'll update a question as the test user.
$moreotheruserdata = $questiongenerator->setup_course_and_questions();
$otherexpectedcontext = \context_course::instance($moreotheruserdata[1]->id);
$morequestions = $moreotheruserdata[3];
// Update the third set of questions.
$this->setUser($user);
foreach ($morequestions as $question) {
$questiongenerator->update_question($question);
}
// Run the get_contexts_for_userid as default user.
$this->setUser();
// There should be two contexts returned - the first course, and the third.
$contextlist = provider::get_contexts_for_userid($user->id);
$this->assertCount(2, $contextlist);
$expectedcontexts = [
$expectedcontext->id,
$otherexpectedcontext->id,
];
$this->assertEqualsCanonicalizing($expectedcontexts, $contextlist->get_contextids(), 'Contexts not equal');
// Run the export_user_Data as the test user.
$this->setUser($user);
$approvedcontextlist = new \core_privacy\tests\request\approved_contextlist(
\core_user::get_user($user->id),
'core_question',
$expectedcontexts
);
provider::export_user_data($approvedcontextlist);
// There should be data for the user's question context.
$writer = writer::with_context($expectedcontext);
$this->assertTrue($writer->has_any_data());
// And for the course we updated.
$otherwriter = writer::with_context($otherexpectedcontext);
$this->assertTrue($otherwriter->has_any_data());
// But not for the other user's course.
$otherwriter = writer::with_context($unexpectedcontext);
$this->assertFalse($otherwriter->has_any_data());
// The question data is exported as an XML export in custom files.
$writer = writer::with_context($expectedcontext);
$subcontext = [get_string('questionbank', 'core_question')];
$exportfile = $writer->get_custom_file($subcontext, 'questions.xml');
$this->assertNotEmpty($exportfile);
$xmlized = xmlize($exportfile);
$xmlquestions = $xmlized['quiz']['#']['question'];
$this->assertCount(2, $xmlquestions);
// Run the delete functions as default user.
$this->setUser();
// Find out how many questions are in the question bank to start with.
$questioncount = $DB->count_records('question');
// The delete functions should do nothing here.
// Delete for all users in context.
provider::delete_data_for_all_users_in_context($expectedcontext);
$this->assertEquals($questioncount, $DB->count_records('question'));
provider::delete_data_for_user($approvedcontextlist);
$this->assertEquals($questioncount, $DB->count_records('question'));
}
/**
* Deleting questions should only unset their created and modified user.
*/
public function test_question_delete_data_for_user_anonymised(): void {
global $DB;
$this->resetAfterTest(true);
$user = \core_user::get_user_by_username('admin');
$otheruser = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course();
$context = \context_course::instance($course->id);
$othercourse = $this->getDataGenerator()->create_course();
$othercontext = \context_course::instance($othercourse->id);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category([
'contextid' => $context->id,
]);
$othercat = $questiongenerator->create_question_category([
'contextid' => $othercontext->id,
]);
// Create questions:
// Q1 - Created by the UUT, Modified by UUT.
// Q2 - Created by the UUT, Modified by the other user.
// Q3 - Created by the other user, Modified by UUT
// Q4 - Created by the other user, Modified by the other user.
// Q5 - Created by the UUT, Modified by the UUT, but in a different context.
$this->setUser($user);
$q1 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$q2 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$this->setUser($otheruser);
// When we update a question, a new question/version is created.
$q2updated = $questiongenerator->update_question($q2);
$q3 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$q4 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$this->setUser($user);
// When we update a question, a new question/version is created.
$q3updated = $questiongenerator->update_question($q3);
$q5 = $questiongenerator->create_question('shortanswer', null, ['category' => $othercat->id]);
$approvedcontextlist = new \core_privacy\tests\request\approved_contextlist(
$user,
'core_question',
[$context->id]
);
// Find out how many questions are in the question bank to start with.
$questioncount = $DB->count_records('question');
// Delete the data and check it is removed.
$this->setUser();
provider::delete_data_for_user($approvedcontextlist);
$this->assertEquals($questioncount, $DB->count_records('question'));
$qrecord = $DB->get_record('question', ['id' => $q1->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q2updated->id]);
$this->assertEquals($otheruser->id, $qrecord->createdby);
$this->assertEquals($otheruser->id, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q3updated->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q4->id]);
$this->assertEquals($otheruser->id, $qrecord->createdby);
$this->assertEquals($otheruser->id, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q5->id]);
$this->assertEquals($user->id, $qrecord->createdby);
$this->assertEquals($user->id, $qrecord->modifiedby);
}
/**
* Deleting questions should only unset their created and modified user for all questions in a context.
*/
public function test_question_delete_data_for_all_users_in_context_anonymised(): void {
global $DB;
$this->resetAfterTest(true);
$user = \core_user::get_user_by_username('admin');
$otheruser = $this->getDataGenerator()->create_user();
$course = $this->getDataGenerator()->create_course();
$context = \context_course::instance($course->id);
$othercourse = $this->getDataGenerator()->create_course();
$othercontext = \context_course::instance($othercourse->id);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category([
'contextid' => $context->id,
]);
$othercat = $questiongenerator->create_question_category([
'contextid' => $othercontext->id,
]);
// Create questions:
// Q1 - Created by the UUT, Modified by UUT.
// Q2 - Created by the UUT, Modified by the other user.
// Q3 - Created by the other user, Modified by UUT
// Q4 - Created by the other user, Modified by the other user.
// Q5 - Created by the UUT, Modified by the UUT, but in a different context.
$this->setUser($user);
$q1 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$q2 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$this->setUser($otheruser);
$questiongenerator->update_question($q2);
$q3 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$q4 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
$this->setUser($user);
$questiongenerator->update_question($q3);
$q5 = $questiongenerator->create_question('shortanswer', null, array('category' => $othercat->id));
// Find out how many questions are in the question bank to start with.
$questioncount = $DB->count_records('question');
// Delete the data and check it is removed.
$this->setUser();
provider::delete_data_for_all_users_in_context($context);
$this->assertEquals($questioncount, $DB->count_records('question'));
$qrecord = $DB->get_record('question', ['id' => $q1->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q2->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q3->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q4->id]);
$this->assertEquals(0, $qrecord->createdby);
$this->assertEquals(0, $qrecord->modifiedby);
$qrecord = $DB->get_record('question', ['id' => $q5->id]);
$this->assertEquals($user->id, $qrecord->createdby);
$this->assertEquals($user->id, $qrecord->modifiedby);
}
/**
* Test for provider::get_users_in_context().
*/
public function test_get_users_in_context(): void {
$this->resetAfterTest();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create three test users.
$user1 = $this->getDataGenerator()->create_user();
$user2 = $this->getDataGenerator()->create_user();
$user3 = $this->getDataGenerator()->create_user();
// Create one question as each user in different contexts.
$this->setUser($user1);
$user1data = $questiongenerator->setup_course_and_questions();
$this->setUser($user2);
$user2data = $questiongenerator->setup_course_and_questions();
$course1context = \context_course::instance($user1data[1]->id);
$course1questions = $user1data[3];
// Log in as user3 and update the questions in course1.
$this->setUser($user3);
foreach ($course1questions as $question) {
$questiongenerator->update_question($question);
}
$userlist = new \core_privacy\local\request\userlist($course1context, 'core_question');
provider::get_users_in_context($userlist);
// User1 has created questions and user3 has edited them.
$this->assertCount(2, $userlist);
$this->assertEqualsCanonicalizing([$user1->id, $user3->id], $userlist->get_userids());
}
/**
* Test for provider::delete_data_for_users().
*/
public function test_delete_data_for_users(): void {
global $DB;
$this->resetAfterTest();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create three test users.
$user1 = $this->getDataGenerator()->create_user();
$user2 = $this->getDataGenerator()->create_user();
$user3 = $this->getDataGenerator()->create_user();
// Create one question as each user in different contexts.
$this->setUser($user1);
$course1data = $questiongenerator->setup_course_and_questions();
$course1 = $course1data[1];
$course1qcat = $course1data[2];
$course1questions = $course1data[3];
$course1context = \context_course::instance($course1->id);
// Log in as user2 and update the questions in course1.
$this->setUser($user2);
foreach ($course1questions as $question) {
$questiongenerator->update_question($question);
}
// Add 2 more questions to course1 by user3.
$this->setUser($user3);
$questiongenerator->create_question('shortanswer', null, ['category' => $course1qcat->id]);
$questiongenerator->create_question('shortanswer', null, ['category' => $course1qcat->id]);
// Now, log in as user1 again, and then create a new course and add questions to that.
$this->setUser($user1);
$questiongenerator->setup_course_and_questions();
$approveduserlist = new \core_privacy\local\request\approved_userlist($course1context, 'core_question',
[$user1->id, $user2->id]);
provider::delete_data_for_users($approveduserlist);
// Now, there should be no question related to user1 or user2 in course1.
$this->assertEquals(0,
$DB->count_records_sql("SELECT COUNT(q.id)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = ?
AND (q.createdby = ? OR q.modifiedby = ? OR q.createdby = ? OR q.modifiedby = ?)",
[$course1context->id, $user1->id, $user1->id, $user2->id, $user2->id])
);
// User3 data in course1 should not change.
$this->assertEquals(2,
$DB->count_records_sql("SELECT COUNT(q.id)
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = ? AND (q.createdby = ? OR q.modifiedby = ?)",
[$course1context->id, $user3->id, $user3->id])
);
// User1 has authored 2 questions in another course.
$this->assertEquals(
2,
$DB->count_records_select('question', "createdby = ? OR modifiedby = ?", [$user1->id, $user1->id])
);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Helper for privacy tests.
*
* @package core_question
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
use \core_privacy\local\request\writer;
/**
* Helper for privacy tests.
*
* @package core_question
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
trait core_question_privacy_helper {
/**
* Assert that the question usage in the supplied slot matches the expected format
* and usage for a question.
*
* @param \question_usage_by_activity $quba The Question Usage to test against.
* @param int $slotno The slot number to compare
* @param \question_display_options $options The display options used for formatting.
* @param \stdClass $data The data to check.
*/
public function assert_question_slot_equals(
\question_usage_by_activity $quba,
$slotno,
\question_display_options $options,
$data
) {
$attempt = $quba->get_question_attempt($slotno);
$question = $attempt->get_question(false);
// Check the question data exported.
$this->assertEquals($data->name, $question->name);
$this->assertEquals($data->question, $question->questiontext);
// Check the answer exported.
$this->assertEquals($attempt->get_response_summary(), $data->answer);
if ($options->marks != \question_display_options::HIDDEN) {
$this->assertEquals($attempt->get_mark(), $data->mark);
} else {
$this->assertFalse(isset($data->mark));
}
if ($options->flags != \question_display_options::HIDDEN) {
$this->assertEquals($attempt->is_flagged(), (int) $data->flagged);
} else {
$this->assertFalse(isset($data->flagged));
}
if ($options->generalfeedback != \question_display_options::HIDDEN) {
$this->assertEquals($question->format_generalfeedback($attempt), $data->generalfeedback);
} else {
$this->assertFalse(isset($data->generalfeedback));
}
}
/**
* Assert that a question attempt was exported.
*
* @param \context $context The context which the attempt should be in
* @param array $subcontext The base of the export
* @param question_usage_by_activity $quba The question usage expected
* @param \question_display_options $options The display options used for formatting.
* @param \stdClass $user The user exported
*/
public function assert_question_attempt_exported(\context $context, array $subcontext, $quba, $options, $user) {
$usagecontext = array_merge(
$subcontext,
[get_string('questions', 'core_question')]
);
/** @var \core_privacy\tests\request\content_writer $writer */
$writer = writer::with_context($context);
foreach ($quba->get_slots() as $slotno) {
$data = $writer->get_data(array_merge($usagecontext, [$slotno]));
$this->assert_question_slot_equals($quba, $slotno, $options, $data);
}
}
}
@@ -0,0 +1,107 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use core_question\local\bank\question_edit_contexts;
use core_question\local\bank\view;
use testable_core_question_column;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/question/editlib.php');
require_once($CFG->dirroot . '/question/tests/fixtures/testable_core_question_column.php');
/**
* Unit tests for the question bank column class.
*
* @package core_question
* @copyright 2018 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_bank_column_test extends \advanced_testcase {
/**
* Test function display_header multiple sorts with no custom tooltips.
*
*/
public function test_column_header_multi_sort_no_tooltips(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$questionbank = new view(
new question_edit_contexts(\context_course::instance($course->id)),
new \moodle_url('/'),
$course
);
$columnbase = new testable_core_question_column($questionbank);
$sortable = [
'apple' => [
'field' => 'apple',
'title' => 'Apple'
],
'banana' => [
'field' => 'banana',
'title' => 'Banana'
]
];
$columnbase->set_sortable($sortable);
ob_start();
$columnbase->display_header();
$output = ob_get_clean();
$this->assertStringContainsString(' title="Sort by Apple ascending">', $output);
$this->assertStringContainsString(' title="Sort by Banana ascending">', $output);
}
/**
* Test function display_header multiple sorts with custom tooltips.
*
*/
public function test_column_header_multi_sort_with_tooltips(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$questionbank = new view(
new question_edit_contexts(\context_course::instance($course->id)),
new \moodle_url('/'),
$course
);
$columnbase = new testable_core_question_column($questionbank);
$sortable = [
'apple' => [
'field' => 'apple',
'title' => 'Apple',
'tip' => 'Apple Tooltips'
],
'banana' => [
'field' => 'banana',
'title' => 'Banana',
'tip' => 'Banana Tooltips'
]
];
$columnbase->set_sortable($sortable);
ob_start();
$columnbase->display_header();
$output = ob_get_clean();
$this->assertStringContainsString(' title="Sort by Apple Tooltips ascending">', $output);
$this->assertStringContainsString(' title="Sort by Banana Tooltips ascending">', $output);
}
}
@@ -0,0 +1,683 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use core_question\local\bank\condition;
use core_question\local\bank\question_version_status;
use core_question\local\bank\random_question_loader;
use core_question_generator;
use mod_quiz\quiz_settings;
use qubaid_list;
use question_bank;
use question_engine;
use question_filter_test_helper;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
/**
* Tests for the {@see \core_question\local\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
* @covers \random_question_loader
*/
final class random_question_loader_test extends \advanced_testcase {
use \quiz_question_helper_test_trait;
public function test_empty_category_gives_null(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertNull($loader->get_next_filtered_question_id($filters));
$filters = question_filter_test_helper::create_filters([$cat->id], 1);
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_unknown_category_behaves_like_empty(): void {
// It is up the caller to make sure the category id is valid.
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([-1], 1);
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_descriptions_not_returned(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$info = $generator->create_question('description', null, ['category' => $cat->id]);
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_hidden_questions_not_returned(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$DB->set_field('question_versions', 'status',
\core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN, ['questionid' => $question1->id]);
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_cloze_subquestions_not_returned(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question1 = $generator->create_question('multianswer', null, ['category' => $cat->id]);
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters));
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_random_questions_not_returned(): void {
$this->resetAfterTest();
$this->setAdminUser();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$course = $this->getDataGenerator()->create_course();
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course]);
$this->add_random_questions($quiz->id, 1, $cat->id, 1);
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_draft_questions_not_returned(): void {
$this->resetAfterTest();
$this->setAdminUser();
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create a question in draft state.
$category = $questiongenerator->create_question_category();
$questiongenerator->create_question('shortanswer', null,
['category' => $category->id, 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
// Try to a random question from that category - should not be one.
$filtercondition = [
'category' => [
'jointype' => condition::JOINTYPE_DEFAULT,
'values' => [$category->id],
'filteroptions' => ['includesubcategories' => false],
],
];
$loader = new random_question_loader(new qubaid_list([]));
$this->assertNull($loader->get_next_filtered_question_id($filtercondition));
}
public function test_questions_with_later_draft_version_is_returned(): void {
$this->resetAfterTest();
$this->setAdminUser();
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create a question in draft state.
$category = $questiongenerator->create_question_category();
$question = $questiongenerator->create_question('shortanswer', null,
['questiontext' => 'V1', 'category' => $category->id]);
$questiongenerator->update_question($question, null,
['questiontext' => 'V2', 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
// Try to a random question from that category - should get V1.
$filtercondition = [
'category' => [
'jointype' => condition::JOINTYPE_DEFAULT,
'values' => [$category->id],
'filteroptions' => ['includesubcategories' => false],
],
];
$loader = new random_question_loader(new qubaid_list([]));
$this->assertEquals($question->id, $loader->get_next_filtered_question_id($filtercondition));
}
public function test_one_question_category_returns_that_q_then_null(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat->id], 1);
$this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_two_question_category_returns_both_then_null(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$question2 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$loader = new random_question_loader(new qubaid_list([]));
$questionids = [];
$filters = question_filter_test_helper::create_filters([$cat->id]);
$questionids[] = $loader->get_next_filtered_question_id($filters);
$questionids[] = $loader->get_next_filtered_question_id($filters);
sort($questionids);
$this->assertEquals([$question1->id, $question2->id], $questionids);
$filters = question_filter_test_helper::create_filters([$cat->id], 1);
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_nested_categories(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat1 = $generator->create_question_category();
$cat2 = $generator->create_question_category(['parent' => $cat1->id]);
$question1 = $generator->create_question('shortanswer', null, ['category' => $cat1->id]);
$question2 = $generator->create_question('shortanswer', null, ['category' => $cat2->id]);
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat2->id], 1);
$this->assertEquals($question2->id, $loader->get_next_filtered_question_id($filters));
$filters = question_filter_test_helper::create_filters([$cat1->id], 1);
$this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters));
$filters = question_filter_test_helper::create_filters([$cat1->id]);
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_used_question_not_returned_until_later(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$question2 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$loader = new random_question_loader(new qubaid_list([]),
[$question2->id => 2]);
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters));
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_previously_used_question_not_returned_until_later(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$question2 = $generator->create_question('shortanswer', null, ['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 random_question_loader(new qubaid_list([$quba->get_id()]));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertEquals($question1->id, $loader->get_next_filtered_question_id($filters));
$this->assertEquals($question2->id, $loader->get_next_filtered_question_id($filters));
$this->assertNull($loader->get_next_filtered_question_id($filters));
}
public function test_empty_category_does_not_have_question_available(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertFalse($loader->is_filtered_question_available($filters, 1));
$filters = question_filter_test_helper::create_filters([$cat->id], 1);
$this->assertFalse($loader->is_filtered_question_available($filters, 1));
}
public function test_descriptions_not_available(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$info = $generator->create_question('description', null, ['category' => $cat->id]);
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertFalse($loader->is_filtered_question_available($filters, $info->id));
$filters = question_filter_test_helper::create_filters([$cat->id], 1);
$this->assertFalse($loader->is_filtered_question_available($filters, $info->id));
}
public function test_existing_question_is_available_but_then_marked_used(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$cat->id]);
$this->assertTrue($loader->is_filtered_question_available($filters, $question1->id));
$this->assertFalse($loader->is_filtered_question_available($filters, $question1->id));
$this->assertFalse($loader->is_filtered_question_available($filters, -1));
}
/**
* Data provider for the get_questions test.
*
* @return array testcases.
*/
public static function get_questions_test_cases(): array {
return [
'empty category' => [
'categoryindex' => 'emptycat',
'includesubcategories' => false,
'usetagnames' => [],
'expectedquestionindexes' => []
],
'single category' => [
'categoryindex' => 'cat1',
'includesubcategories' => false,
'usetagnames' => [],
'expectedquestionindexes' => ['cat1q1', 'cat1q2']
],
'include sub category' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => [],
'expectedquestionindexes' => ['cat1q1', 'cat1q2', 'subcatq1', 'subcatq2']
],
'single category with tags' => [
'categoryindex' => 'cat1',
'includesubcategories' => false,
'usetagnames' => ['cat1'],
'expectedquestionindexes' => ['cat1q1']
],
'include sub category with tag on parent' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['cat1'],
'expectedquestionindexes' => ['cat1q1']
],
'include sub category with tag on sub' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['subcat'],
'expectedquestionindexes' => ['subcatq1']
],
'include sub category with same tag on parent and sub' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['foo'],
'expectedquestionindexes' => ['cat1q1', 'subcatq1']
],
'include sub category with tag not matching' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['cat1', 'cat2'],
'expectedquestionindexes' => []
]
];
}
/**
* Test the get_questions function with various parameter combinations.
*
* This function creates a data set as follows:
* Category: cat1
* Question: cat1q1
* Tags: 'cat1', 'foo'
* Question: cat1q2
* Category: cat2
* Question: cat2q1
* Tags: 'cat2', 'foo'
* Question: cat2q2
* Category: subcat
* Question: subcatq1
* Tags: 'subcat', 'foo'
* Question: subcatq2
* Parent: cat1
* Category: emptycat
*
* @dataProvider get_questions_test_cases()
* @param string $categoryindex The named index for the category to use
* @param bool $includesubcategories If the search should include subcategories
* @param string[] $usetagnames The tag names to include in the search
* @param string[] $expectedquestionindexes The questions expected in the result
*/
public function test_get_questions_variations(
$categoryindex,
$includesubcategories,
$usetagnames,
$expectedquestionindexes
): void {
$this->resetAfterTest();
$categories = [];
$questions = [];
$tagnames = [
'cat1',
'cat2',
'subcat',
'foo'
];
$collid = \core_tag_collection::get_default();
$tags = \core_tag_tag::create_if_missing($collid, $tagnames);
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
// First category and questions.
[$category, $categoryquestions] = $this->create_category_and_questions(2, ['cat1', 'foo']);
$categories['cat1'] = $category;
$questions['cat1q1'] = $categoryquestions[0];
$questions['cat1q2'] = $categoryquestions[1];
// Second category and questions.
[$category, $categoryquestions] = $this->create_category_and_questions(2, ['cat2', 'foo']);
$categories['cat2'] = $category;
$questions['cat2q1'] = $categoryquestions[0];
$questions['cat2q2'] = $categoryquestions[1];
// Sub category and questions.
[$category, $categoryquestions] = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']);
$categories['subcat'] = $category;
$questions['subcatq1'] = $categoryquestions[0];
$questions['subcatq2'] = $categoryquestions[1];
// Empty category.
[$category, $categoryquestions] = $this->create_category_and_questions(0);
$categories['emptycat'] = $category;
// Generate the arguments for the get_questions function.
$category = $categories[$categoryindex];
$tagids = array_map(function($tagname) use ($tags) {
return $tags[$tagname]->id;
}, $usetagnames);
$loader = new random_question_loader(new qubaid_list([]));
$filters = question_filter_test_helper::create_filters([$category->id], $includesubcategories, $tagids);
$result = $loader->get_filtered_questions($filters);
// Generate the expected question set.
$expectedquestions = array_map(function($index) use ($questions) {
return $questions[$index];
}, $expectedquestionindexes);
// Ensure the result matches what was expected.
$this->assertCount(count($expectedquestions), $result);
foreach ($expectedquestions as $question) {
$this->assertEquals($result[$question->id]->id, $question->id);
$this->assertEquals($result[$question->id]->category, $question->category);
}
}
/**
* get_questions should allow limiting and offsetting of the result set.
*/
public function test_get_questions_with_limit_and_offset(): void {
$this->resetAfterTest();
$numberofquestions = 5;
$includesubcategories = false;
$tagids = [];
$limit = 1;
$offset = 0;
$loader = new random_question_loader(new qubaid_list([]));
[$category, $questions] = $this->create_category_and_questions($numberofquestions);
// Add questionid as key to find them easily later.
$questionsbyid = [];
array_walk($questions, function (&$value) use (&$questionsbyid) {
$questionsbyid[$value->id] = $value;
});
$filters = question_filter_test_helper::create_filters([$category->id], $includesubcategories, $tagids);
for ($i = 0; $i < $numberofquestions; $i++) {
$result = $loader->get_filtered_questions(
$filters,
$limit,
$offset
);
$this->assertCount($limit, $result);
$actual = array_shift($result);
$expected = $questionsbyid[$actual->id];
$this->assertEquals($expected->id, $actual->id);
$offset++;
}
}
/**
* get_questions should allow retrieving questions with only a subset of
* fields populated.
*/
public function test_get_questions_with_restricted_fields(): void {
$this->resetAfterTest();
$includesubcategories = false;
$tagids = [];
$limit = 10;
$offset = 0;
$fields = ['id', 'name'];
$loader = new random_question_loader(new qubaid_list([]));
[$category, $questions] = $this->create_category_and_questions(1);
$filters = question_filter_test_helper::create_filters([$category->id], $includesubcategories, $tagids);
$result = $loader->get_filtered_questions(
$filters,
$limit,
$offset,
$fields
);
$expectedquestion = array_shift($questions);
$actualquestion = array_shift($result);
$actualfields = get_object_vars($actualquestion);
$actualfields = array_keys($actualfields);
sort($actualfields);
sort($fields);
$this->assertEquals($fields, $actualfields);
}
/**
* Data provider for the count_questions test.
*
* @return array testcases.
*/
public static function count_questions_test_cases(): array {
return [
'empty category' => [
'categoryindex' => 'emptycat',
'includesubcategories' => false,
'usetagnames' => [],
'expectedcount' => 0
],
'single category' => [
'categoryindex' => 'cat1',
'includesubcategories' => false,
'usetagnames' => [],
'expectedcount' => 2
],
'include sub category' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => [],
'expectedcount' => 4
],
'single category with tags' => [
'categoryindex' => 'cat1',
'includesubcategories' => false,
'usetagnames' => ['cat1'],
'expectedcount' => 1
],
'include sub category with tag on parent' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['cat1'],
'expectedcount' => 1
],
'include sub category with tag on sub' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['subcat'],
'expectedcount' => 1
],
'include sub category with same tag on parent and sub' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['foo'],
'expectedcount' => 2
],
'include sub category with tag not matching' => [
'categoryindex' => 'cat1',
'includesubcategories' => true,
'usetagnames' => ['cat1', 'cat2'],
'expectedcount' => 0
]
];
}
/**
* Test the count_questions function with various parameter combinations.
*
* This function creates a data set as follows:
* Category: cat1
* Question: cat1q1
* Tags: 'cat1', 'foo'
* Question: cat1q2
* Category: cat2
* Question: cat2q1
* Tags: 'cat2', 'foo'
* Question: cat2q2
* Category: subcat
* Question: subcatq1
* Tags: 'subcat', 'foo'
* Question: subcatq2
* Parent: cat1
* Category: emptycat
*
* @dataProvider count_questions_test_cases()
* @param string $categoryindex The named index for the category to use
* @param bool $includesubcategories If the search should include subcategories
* @param string[] $usetagnames The tag names to include in the search
* @param int $expectedcount The number of questions expected in the result
*/
public function test_count_questions_variations(
$categoryindex,
$includesubcategories,
$usetagnames,
$expectedcount
): void {
$this->resetAfterTest();
$categories = [];
$questions = [];
$tagnames = [
'cat1',
'cat2',
'subcat',
'foo'
];
$collid = \core_tag_collection::get_default();
$tags = \core_tag_tag::create_if_missing($collid, $tagnames);
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
// First category and questions.
[$category, $categoryquestions] = $this->create_category_and_questions(2, ['cat1', 'foo']);
$categories['cat1'] = $category;
$questions['cat1q1'] = $categoryquestions[0];
$questions['cat1q2'] = $categoryquestions[1];
// Second category and questions.
[$category, $categoryquestions] = $this->create_category_and_questions(2, ['cat2', 'foo']);
$categories['cat2'] = $category;
$questions['cat2q1'] = $categoryquestions[0];
$questions['cat2q2'] = $categoryquestions[1];
// Sub category and questions.
[$category, $categoryquestions] = $this->create_category_and_questions(2, ['subcat', 'foo'], $categories['cat1']);
$categories['subcat'] = $category;
$questions['subcatq1'] = $categoryquestions[0];
$questions['subcatq2'] = $categoryquestions[1];
// Empty category.
[$category, $categoryquestions] = $this->create_category_and_questions(0);
$categories['emptycat'] = $category;
// Generate the arguments for the get_questions function.
$category = $categories[$categoryindex];
$tagids = array_map(function($tagname) use ($tags) {
return $tags[$tagname]->id;
}, $usetagnames);
$filters = question_filter_test_helper::create_filters([$category->id], $includesubcategories, $tagids);
$loader = new random_question_loader(new qubaid_list([]));
$result = $loader->count_filtered_questions($filters);
// Ensure the result matches what was expected.
$this->assertEquals($expectedcount, $result);
}
/**
* Create a question category and create questions in that category. Tag
* the first question in each category with the given tags.
*
* @param int $questioncount How many questions to create.
* @param string[] $tagnames The list of tags to use.
* @param stdClass|null $parentcategory The category to set as the parent of the created category.
* @return array The category and questions.
*/
protected function create_category_and_questions($questioncount, $tagnames = [], $parentcategory = null): array {
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
if ($parentcategory) {
$catparams = ['parent' => $parentcategory->id];
} else {
$catparams = [];
}
$category = $generator->create_question_category($catparams);
$questions = [];
for ($i = 0; $i < $questioncount; $i++) {
$questions[] = $generator->create_question('shortanswer', null, ['category' => $category->id]);
}
if (!empty($tagnames) && !empty($questions)) {
$context = \context::instance_by_id($category->contextid);
\core_tag_tag::set_item_tags('core_question', 'question', $questions[0]->id, $context, $tagnames);
}
return [$category, $questions];
}
}
+372
View File
@@ -0,0 +1,372 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use core_question\local\bank\question_version_status;
use core_question\output\question_version_info;
use question_bank;
/**
* Question version unit tests.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Guillermo Gomez Arias <guillermogomez@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \question_bank
*/
class version_test extends \advanced_testcase {
/**
* @var \context_module module context.
*/
protected $context;
/**
* @var \stdClass course object.
*/
protected $course;
/**
* @var \component_generator_base question generator.
*/
protected $qgenerator;
/**
* @var \stdClass quiz object.
*/
protected $quiz;
/**
* Called before every test.
*/
protected function setUp(): void {
parent::setUp();
self::setAdminUser();
$this->resetAfterTest();
$datagenerator = $this->getDataGenerator();
$this->course = $datagenerator->create_course();
$this->quiz = $datagenerator->create_module('quiz', ['course' => $this->course->id]);
$this->qgenerator = $datagenerator->get_plugin_generator('core_question');
$this->context = \context_module::instance($this->quiz->cmid);
}
protected function tearDown(): void {
question_version_info::$pendingdefinitions = [];
parent::tearDown();
}
/**
* Test if creating a question a new version and bank entry records are created.
*
* @covers ::load_question
*/
public function test_make_question_create_version_and_bank_entry(): void {
global $DB;
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]);
// Get the question object after creating a question.
$questiondefinition = question_bank::load_question($question->id);
// The version and bank entry in the object should be the same.
$sql = "SELECT qv.id AS versionid, qv.questionbankentryid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE q.id = ?";
$questionversion = $DB->get_record_sql($sql, [$questiondefinition->id]);
$this->assertEquals($questionversion->versionid, $questiondefinition->versionid);
$this->assertEquals($questionversion->questionbankentryid, $questiondefinition->questionbankentryid);
// If a question is updated, a new version should be created.
$question = $this->qgenerator->update_question($question, null, ['name' => 'This is a new version']);
$newquestiondefinition = question_bank::load_question($question->id);
// The version should be 2.
$this->assertEquals('2', $newquestiondefinition->version);
// Both versions should be in same bank entry.
$this->assertEquals($questiondefinition->questionbankentryid, $newquestiondefinition->questionbankentryid);
}
/**
* Test if deleting a question the related version and bank entry records are deleted.
*
* @covers ::load_question
* @covers ::question_delete_question
*/
public function test_delete_question_delete_versions(): void {
global $DB;
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]);
$questionfirstversionid = $question->id;
// Create a new version and try to remove it.
$question = $this->qgenerator->update_question($question, null, ['name' => 'This is a new version']);
// The new version and bank entry record should exist.
$sql = "SELECT q.id, qv.id AS versionid, qv.questionbankentryid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE q.id = ?";
$questionobject = $DB->get_records_sql($sql, [$question->id]);
$this->assertCount(1, $questionobject);
// Try to delete new version.
question_delete_question($question->id);
// The version record should not exist.
$sql = "SELECT qv.*
FROM {question_versions} qv
WHERE qv.id = ?";
$questionversion = $DB->get_record_sql($sql, [$questionobject[$question->id]->versionid]);
$this->assertFalse($questionversion);
// The bank entry record should exist because there is an older version.
$sql = "SELECT qbe.*
FROM {question_bank_entries} qbe
WHERE qbe.id = ?";
$questionbankentry = $DB->get_records_sql($sql, [$questionobject[$question->id]->questionbankentryid]);
$this->assertCount(1, $questionbankentry);
// Now remove the first version.
question_delete_question($questionfirstversionid);
$sql = "SELECT qbe.*
FROM {question_bank_entries} qbe
WHERE qbe.id = ?";
$questionbankentry = $DB->get_record_sql($sql, [$questionobject[$question->id]->questionbankentryid]);
// The bank entry record should not exist.
$this->assertFalse($questionbankentry);
}
/**
* Test if deleting a question will not break a quiz.
*
* @covers ::load_question
* @covers ::quiz_add_quiz_question
* @covers ::question_delete_question
*/
public function test_delete_question_in_use(): void {
global $DB;
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]);
$questionfirstversionid = $question->id;
// Create a new version and try to remove it after adding it to a quiz.
$question = $this->qgenerator->update_question($question, null, ['name' => 'This is a new version']);
// Add it to the quiz.
quiz_add_quiz_question($question->id, $this->quiz);
// Try to delete new version.
question_delete_question($question->id);
// Try to delete old version.
question_delete_question($questionfirstversionid);
// The used question version should exist even after trying to remove it, but now hidden.
$questionversion2 = question_bank::load_question($question->id);
$this->assertEquals($question->id, $questionversion2->id);
$this->assertEquals(question_version_status::QUESTION_STATUS_HIDDEN, $questionversion2->status);
// The unused version should be completely gone.
$this->assertFalse($DB->record_exists('question', ['id' => $questionfirstversionid]));
}
/**
* Test if moving a category will not break a quiz.
*
* @covers ::load_question
* @covers ::quiz_add_quiz_question
*/
public function test_move_category_with_questions(): void {
global $DB;
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$qcategorychild = $this->qgenerator->create_question_category(['contextid' => $this->context->id,
'parent' => $qcategory->id]);
$systemcontext = \context_system::instance();
$qcategorysys = $this->qgenerator->create_question_category(['contextid' => $systemcontext->id]);
$question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategorychild->id]);
$questiondefinition = question_bank::load_question($question->id);
// Add it to the quiz.
quiz_add_quiz_question($question->id, $this->quiz);
// Move the category to system context.
$contexts = new \core_question\local\bank\question_edit_contexts($systemcontext);
$qcobject = new \qbank_managecategories\question_category_object(null,
new \moodle_url('/question/bank/managecategories/category.php', ['courseid' => SITEID]),
$contexts->having_one_edit_tab_cap('categories'), 0, null, 0,
$contexts->having_cap('moodle/question:add'));
$qcobject->move_questions_and_delete_category($qcategorychild->id, $qcategorysys->id);
// The bank entry record should point to the new category in order to not break quizzes.
$sql = "SELECT qbe.questioncategoryid
FROM {question_bank_entries} qbe
WHERE qbe.id = ?";
$questionbankentry = $DB->get_record_sql($sql, [$questiondefinition->questionbankentryid]);
$this->assertEquals($qcategorysys->id, $questionbankentry->questioncategoryid);
}
/**
* Test that all versions will have the same bank entry idnumber value.
*
* @covers ::load_question
*/
public function test_id_number_in_bank_entry(): void {
global $DB;
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null,
[
'category' => $qcategory->id,
'idnumber' => 'id1'
]);
$questionid1 = $question->id;
// Create a new version and try to remove it after adding it to a quiz.
$question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id2']);
$questionid2 = $question->id;
// Change the id number and get the question object.
$question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id3']);
$questionid3 = $question->id;
// The new version and bank entry record should exist.
$questiondefinition = question_bank::load_question($question->id);
$sql = "SELECT q.id AS questionid, qv.id AS versionid, qbe.id AS questionbankentryid, qbe.idnumber
FROM {question_bank_entries} qbe
JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
JOIN {question} q ON q.id = qv.questionid
WHERE qbe.id = ?";
$questionbankentry = $DB->get_records_sql($sql, [$questiondefinition->questionbankentryid]);
// We should have 3 versions and 1 question bank entry with the same idnumber.
$this->assertCount(3, $questionbankentry);
$this->assertEquals($questionbankentry[$questionid1]->idnumber, 'id3');
$this->assertEquals($questionbankentry[$questionid2]->idnumber, 'id3');
$this->assertEquals($questionbankentry[$questionid3]->idnumber, 'id3');
}
/**
* Test that all the versions are available from the method.
*
* @covers ::get_all_versions_of_question
*/
public function test_get_all_versions_of_question(): void {
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null,
[
'category' => $qcategory->id,
'idnumber' => 'id1'
]);
$questionid1 = $question->id;
// Create a new version.
$question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id2']);
$questionid2 = $question->id;
// Change the id number and get the question object.
$question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id3']);
$questionid3 = $question->id;
$questiondefinition = question_bank::get_all_versions_of_question($question->id);
// Test the versions are available.
$this->assertEquals(array_slice($questiondefinition, 0, 1)[0]->questionid, $questionid3);
$this->assertEquals(array_slice($questiondefinition, 1, 1)[0]->questionid, $questionid2);
$this->assertEquals(array_slice($questiondefinition, 2, 1)[0]->questionid, $questionid1);
}
/**
* Test that all the versions of questions are available from the method.
*
* @covers ::get_all_versions_of_questions
*/
public function test_get_all_versions_of_questions(): void {
global $DB;
$questionversions = [];
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question = $this->qgenerator->create_question('shortanswer', null,
[
'category' => $qcategory->id,
'idnumber' => 'id1'
]);
$questionversions[1] = $question->id;
// Create a new version.
$question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id2']);
$questionversions[2] = $question->id;
// Change the id number and get the question object.
$question = $this->qgenerator->update_question($question, null, ['idnumber' => 'id3']);
$questionversions[3] = $question->id;
$questionbankentryid = $DB->get_record('question_versions', ['questionid' => $question->id], 'questionbankentryid');
$questionversionsofquestions = question_bank::get_all_versions_of_questions([$question->id]);
$questionbankentryids = array_keys($questionversionsofquestions)[0];
$this->assertEquals($questionbankentryid->questionbankentryid, $questionbankentryids);
$this->assertEquals($questionversions, $questionversionsofquestions[$questionbankentryids]);
}
/**
* Test population of latestversion field in question_definition objects
*
* When an instance of question_definition is created, it is added to an array of pending definitions which
* do not yet have the latestversion field populated. When one definition has its latestversion property accessed,
* all pending definitions have their latestversion field populated at once.
*
* @covers \core_question\output\question_version_info::populate_latest_versions()
* @return void
*/
public function test_populate_definition_latestversions(): void {
$qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]);
$question1 = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]);
$question2 = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]);
$question3 = $this->qgenerator->update_question($question2, null, ['idnumber' => 'id2']);
$latestversioninspector = new \ReflectionProperty('question_definition', 'latestversion');
$this->assertEmpty(question_version_info::$pendingdefinitions);
$questiondef1 = question_bank::load_question($question1->id);
$questiondef2 = question_bank::load_question($question2->id);
$questiondef3 = question_bank::load_question($question3->id);
$this->assertContains($questiondef1, question_version_info::$pendingdefinitions);
$this->assertContains($questiondef2, question_version_info::$pendingdefinitions);
$this->assertContains($questiondef3, question_version_info::$pendingdefinitions);
$this->assertNull($latestversioninspector->getValue($questiondef1));
$this->assertNull($latestversioninspector->getValue($questiondef2));
$this->assertNull($latestversioninspector->getValue($questiondef3));
// Read latestversion from one definition. This should populate the field in all pending definitions.
$latestversion1 = $questiondef1->latestversion;
$this->assertEmpty(question_version_info::$pendingdefinitions);
$this->assertNotNull($latestversioninspector->getValue($questiondef1));
$this->assertNotNull($latestversioninspector->getValue($questiondef2));
$this->assertNotNull($latestversioninspector->getValue($questiondef3));
$this->assertEquals($latestversion1, $latestversioninspector->getValue($questiondef1));
$this->assertEquals($questiondef1->version, $questiondef1->latestversion);
$this->assertNotEquals($questiondef2->version, $questiondef2->latestversion);
$this->assertEquals($questiondef3->version, $questiondef3->latestversion);
}
}