Autoría | Ultima modificación | Ver Log |
<?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_bank_helper;
/**
* question bank helper class tests.
*
* @package core_question
* @copyright 2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Simon Adams <simon.adams@catalyst-eu.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_question\local\bank\question_bank_helper
*/
final class question_bank_helper_test extends \advanced_testcase {
/**
* Assert that at least 1 module type that shares questions exists and that mod_qbank is in the returned list.
*
* @return void
* @covers ::get_activity_types_with_shareable_questions
*/
public function test_get_shareable_modules(): void {
$openmods = question_bank_helper::get_activity_types_with_shareable_questions();
$this->assertGreaterThanOrEqual(1, count($openmods));
$this->assertContains('qbank', $openmods);
$this->assertNotContains('quiz', $openmods);
}
/**
* Assert that at least 1 module type that does not share questions exists and that mod_quiz is in the returned list.
*
* @return void
* @covers ::get_activity_types_with_private_questions
*/
public function test_get_private_modules(): void {
$closedmods = question_bank_helper::get_activity_types_with_private_questions();
$this->assertGreaterThanOrEqual(1, count($closedmods));
$this->assertContains('quiz', $closedmods);
$this->assertNotContains('qbank', $closedmods);
}
/**
* Setup some courses with quiz and qbank module instances and set different permissions for a user.
* Then assert that the correct results are returned from calls to the class methods.
*
* @covers ::get_activity_instances_with_shareable_questions
* @covers ::get_activity_instances_with_private_questions
* @return void
*/
public function test_get_instances(): void {
global $DB;
$this->resetAfterTest();
$user = self::getDataGenerator()->create_user();
$roles = $DB->get_records('role', [], '', 'shortname, id');
self::setUser($user);
$qgen = self::getDataGenerator()->get_plugin_generator('core_question');
$sharedmodgen = self::getDataGenerator()->get_plugin_generator('mod_qbank');
$privatemodgen = self::getDataGenerator()->get_plugin_generator('mod_quiz');
$category1 = self::getDataGenerator()->create_category();
$category2 = self::getDataGenerator()->create_category();
$course1 = self::getDataGenerator()->create_course(['category' => $category1->id]);
$course2 = self::getDataGenerator()->create_course(['category' => $category1->id]);
$course3 = self::getDataGenerator()->create_course(['category' => $category2->id]);
$course4 = self::getDataGenerator()->create_course(['category' => $category2->id]);
$sharedmod1 = $sharedmodgen->create_instance(['course' => $course1]);
$sharedmod1context = \context_module::instance($sharedmod1->cmid);
$sharedmod1qcat1 = question_get_default_category($sharedmod1context->id);
$sharedmod1qcat2 = $qgen->create_question_category(['contextid' => $sharedmod1context->id]);
$sharedmod1qcat2child = $qgen->create_question_category([
'contextid' => $sharedmod1context->id,
'parent' => $sharedmod1qcat2->id,
'name' => 'A, B, C',
]);
$privatemod1 = $privatemodgen->create_instance(['course' => $course1]);
$privatemod1context = \context_module::instance($privatemod1->cmid);
$privatemod1qcat1 = question_get_default_category($privatemod1context->id);
role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($sharedmod1->cmid));
role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($privatemod1->cmid));
$sharedmod2 = $sharedmodgen->create_instance(['course' => $course2]);
$sharedmod2context = \context_module::instance($sharedmod2->cmid);
$sharedmod2qcat1 = question_get_default_category($sharedmod2context->id);
$sharedmod2qcat2 = $qgen->create_question_category(['contextid' => $sharedmod2context->id]);
$sharedmod2qcat2child = $qgen->create_question_category([
'contextid' => $sharedmod2context->id,
'parent' => $sharedmod2qcat2->id,
]);
$privatemod2 = $privatemodgen->create_instance(['course' => $course2]);
$privatemod2context = \context_module::instance($privatemod2->cmid);
$privatemod1qcat1 = question_get_default_category($privatemod2context->id);
role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($sharedmod2->cmid));
role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($privatemod2->cmid));
// User doesn't have the capability on this one.
$sharedmod3 = $sharedmodgen->create_instance(['course' => $course3]);
$privatemod3 = $privatemodgen->create_instance(['course' => $course3]);
// Exclude this course in the results despite having the capability.
$sharedmod4 = $sharedmodgen->create_instance(['course' => $course4]);
role_assign($roles['editingteacher']->id, $user->id, \context_module::instance($sharedmod4->cmid));
$sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(
[],
[$course4->id],
['moodle/question:add'],
true
);
$count = 0;
foreach ($sharedbanks as $courseinstance) {
// Must all be mod_qbanks.
$this->assertEquals('qbank', $courseinstance->cminfo->modname);
// Must have 2 categories each bank.
$this->assertCount(3, $courseinstance->questioncategories);
// Must not include the bank the user does not have access to.
$this->assertNotEquals($sharedmod3->name, $courseinstance->name);
$this->assertNotEquals($privatemod3->name, $courseinstance->name);
$count++;
}
// Expect count of 2 bank instances.
$this->assertEquals(2, $count);
$privatebanks = question_bank_helper::get_activity_instances_with_private_questions(
[$course1->id],
[],
['moodle/question:add'],
true
);
$count = 0;
foreach ($privatebanks as $courseinstance) {
// Must all be mod_quiz.
$this->assertEquals('quiz', $courseinstance->cminfo->modname);
// Must have 1 category in each bank.
$this->assertCount(1, $courseinstance->questioncategories);
// Must only include the bank from course 1.
$this->assertNotContains($courseinstance->cminfo->course, [$course2->id, $course3->id, $course4->id]);
$count++;
}
// Expect count of 1 bank instances.
$this->assertEquals(1, $count);
}
/**
* We should be able to filter sharable question bank instances by name.
*
* @covers ::get_activity_instances_with_shareable_questions
* @return void
*/
public function test_get_instances_by_name(): void {
global $DB;
$this->resetAfterTest();
$user = self::getDataGenerator()->create_user();
$roles = $DB->get_records('role', [], '', 'shortname, id');
self::setUser($user);
$sharedmodgen = self::getDataGenerator()->get_plugin_generator('mod_qbank');
$category1 = self::getDataGenerator()->create_category();
$course1 = self::getDataGenerator()->create_course(['category' => $category1->id]);
role_assign($roles['editingteacher']->id, $user->id, \core\context\course::instance($course1->id));
$sharedmods = [];
for ($i = 1; $i <= 21; $i++) {
$sharedmods[$i] = $sharedmodgen->create_instance(['course' => $course1, 'name' => "Shared bank {$i}"]);
}
$sharedmods[22] = $sharedmodgen->create_instance(['course' => $course1, 'name' => "Another bank"]);
// We get all banks with no parameters.
$allsharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions();
$this->assertCount(22, $allsharedbanks);
// Searching for "2", we get the 4 banks with "2" in the name.
$twobanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: '2');
$this->assertCount(4, $twobanks);
$this->assertEquals(
[$sharedmods[2]->cmid, $sharedmods[12]->cmid, $sharedmods[20]->cmid, $sharedmods[21]->cmid],
array_map(fn($bank) => $bank->modid, $twobanks),
);
// Searching for "Shared bank" with no limit, we should get all 21, but not "Another bank".
$sharedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank');
$this->assertCount(21, $sharedbanks);
$this->assertEmpty(array_filter($sharedbanks, fn($bank) => in_array($bank->name, ['Another bank'])));
// Searching for "Shared bank" with a limit of 20, we should get all except number 21 and "Another bank".
$limitedbanks = question_bank_helper::get_activity_instances_with_shareable_questions(search: 'Shared bank', limit: 20);
$this->assertCount(20, $limitedbanks);
$this->assertEmpty(array_filter($limitedbanks, fn($bank) => in_array($bank->name, ['Shared bank 21', 'Another bank'])));
}
/**
* Assert creating a default mod_qbank instance on a course provides the expected boilerplate settings.
*
* @return void
* @covers ::create_default_open_instance
*/
public function test_create_default_open_instance(): void {
global $DB;
$this->resetAfterTest();
self::setAdminUser();
$course = self::getDataGenerator()->create_course();
// Create the instance and assert default values.
question_bank_helper::create_default_open_instance($course, $course->fullname);
$modinfo = get_fast_modinfo($course);
$cminfos = $modinfo->get_instances_of('qbank');
$this->assertCount(1, $cminfos);
$cminfo = reset($cminfos);
$this->assertEquals($course->fullname, $cminfo->get_name());
$this->assertEquals(0, $cminfo->sectionnum);
$modrecord = $DB->get_record('qbank', ['id' => $cminfo->instance]);
$this->assertEquals(question_bank_helper::TYPE_STANDARD, $modrecord->type);
$this->assertEmpty($cminfo->idnumber);
$this->assertEmpty($cminfo->content);
// Create a system type bank.
question_bank_helper::create_default_open_instance($course, 'System bank 1', question_bank_helper::TYPE_SYSTEM);
// Try and create another system type bank.
question_bank_helper::create_default_open_instance($course, 'System bank 2', question_bank_helper::TYPE_SYSTEM);
$modinfo = get_fast_modinfo($course);
$cminfos = $modinfo->get_instances_of('qbank');
$cminfos = array_filter($cminfos, static function($cminfo) {
global $DB;
return $DB->record_exists('qbank', ['id' => $cminfo->instance, 'type' => question_bank_helper::TYPE_SYSTEM]);
});
// Can only be 1 system 'type' bank per course.
$this->assertCount(1, $cminfos);
$cminfo = reset($cminfos);
$this->assertEquals('System bank 1', $cminfo->get_name());
$moddata = $DB->get_record('qbank', ['id' => $cminfo->instance]);
$this->assertEquals(get_string('systembankdescription', 'question'), $moddata->intro);
$this->assertEquals(1, $cminfo->showdescription);
}
/**
* Create a default instance, passing a name that is too long for the database.
*
* @return void
* @throws \coding_exception
* @throws \dml_exception
* @throws \moodle_exception
*/
public function test_create_default_open_instance_with_long_name(): void {
$this->resetAfterTest();
self::setAdminUser();
$coursename = random_string(question_bank_helper::BANK_NAME_MAX_LENGTH);
$course = self::getDataGenerator()->create_course(['shortname' => $coursename]);
$this->expectExceptionMessage('The provided bankname is too long for the database field.');
question_bank_helper::create_default_open_instance(
$course,
get_string('defaultbank', 'core_question', ['coursename' => $coursename]),
);
}
/**
* Create a default instance, passing a multibyte-character name.
*
* The name has more bytes than the max length, but is within the character limit as they are multibyte characters.
*/
public function test_create_default_open_instance_with_multibyte_name(): void {
$this->resetAfterTest();
self::setAdminUser();
$coursename = '';
while (strlen($coursename) < question_bank_helper::BANK_NAME_MAX_LENGTH) {
$coursename .= '🙂';
}
$course = self::getDataGenerator()->create_course(['shortname' => '🙂']);
$bankname = get_string('defaultbank', 'core_question', ['coursename' => $coursename]);
$this->assertTrue(strlen($bankname) > question_bank_helper::BANK_NAME_MAX_LENGTH);
$this->assertTrue(\core_text::strlen($bankname) < question_bank_helper::BANK_NAME_MAX_LENGTH);
question_bank_helper::create_default_open_instance($course, $bankname);
$modinfo = get_fast_modinfo($course);
$cminfos = $modinfo->get_instances_of('qbank');
$this->assertCount(1, $cminfos);
$cminfo = reset($cminfos);
$this->assertEquals($bankname, $cminfo->get_name());
}
/**
* Assert that viewing a question bank logs the view for that user up to a maximum of 5 unique bank views.
*
* @return void
* @covers ::get_recently_used_open_banks
* @covers ::add_bank_context_to_recently_viewed
*/
public function test_recently_viewed_question_banks(): void {
$this->resetAfterTest();
$user = self::getDataGenerator()->create_user();
$course1 = self::getDataGenerator()->create_course();
$course2 = self::getDataGenerator()->create_course();
self::getDataGenerator()->enrol_user($user->id, $course1->id, 'editingteacher');
$banks = [];
$banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course1->id]);
$banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course1->id]);
$banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course1->id]);
$banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course2->id]);
$banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course2->id]);
$banks[] = self::getDataGenerator()->create_module('qbank', ['course' => $course2->id]);
self::setUser($user);
// Trigger bank view on each of them.
foreach ($banks as $bank) {
$cat = question_get_default_category(\context_module::instance($bank->cmid)->id, true);
$context = \context::instance_by_id($cat->contextid);
question_bank_helper::add_bank_context_to_recently_viewed($context);
}
$viewedorder = array_reverse($banks);
// Check that the courseid filter works.
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id, $course1->id);
$this->assertCount(3, $recentlyviewed);
// We should have the viewed banks in course 2.
$courseviewed = array_slice($banks, 3, 3);
$this->assertEqualsCanonicalizing(array_column($recentlyviewed, 'modid'), array_column($courseviewed, 'cmid'));
// Check that the capability filter works.
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id, havingcap: ['moodle/question:useall']);
$this->assertCount(2, $recentlyviewed);
// We should have the 2 most recently viewed banks in course 1.
$capabilityviewed = array_slice($banks, 1, 2);
$this->assertEqualsCanonicalizing(array_column($recentlyviewed, 'modid'), array_column($capabilityviewed, 'cmid'));
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id);
// We only keep a record of 5 maximum.
$this->assertCount(5, $recentlyviewed);
foreach ($recentlyviewed as $order => $record) {
$this->assertEquals($viewedorder[$order]->cmid, $record->modid);
}
// Now if we view one of those again it should get bumped to the front of the list.
$bank3cat = question_get_default_category(\context_module::instance($banks[2]->cmid)->id, true);
$bank3context = \context::instance_by_id($bank3cat->contextid);
question_bank_helper::add_bank_context_to_recently_viewed($bank3context);
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id);
// We should still have 5 maximum.
$this->assertCount(5, $recentlyviewed);
// The recently viewed on got bumped to the front.
$this->assertEquals($banks[2]->cmid, $recentlyviewed[0]->modid);
// The others got sorted accordingly behind it.
$this->assertEquals($banks[5]->cmid, $recentlyviewed[1]->modid);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[2]->modid);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[3]->modid);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[4]->modid);
// Now create a quiz and trigger the bank view of it.
$quiz = self::getDataGenerator()->get_plugin_generator('mod_quiz')->create_instance(['course' => $course1]);
$quizcat = question_get_default_category(\context_module::instance($quiz->cmid)->id, true);
$quizcontext = \context::instance_by_id($quizcat->contextid);
question_bank_helper::add_bank_context_to_recently_viewed($quizcontext);
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id);
// We should still have 5 maximum.
$this->assertCount(5, $recentlyviewed);
// Make sure that we only store bank views for plugins that support FEATURE_PUBLISHES_QUESTIONS.
foreach ($recentlyviewed as $record) {
$this->assertNotEquals($quiz->cmid, $record->modid);
}
// Now delete one of the viewed bank modules and get the records again.
course_delete_module($banks[2]->cmid);
$recentlyviewed = question_bank_helper::get_recently_used_open_banks($user->id);
$this->assertCount(4, $recentlyviewed);
// Check the order was retained.
$this->assertEquals($banks[5]->cmid, $recentlyviewed[0]->modid);
$this->assertEquals($banks[4]->cmid, $recentlyviewed[1]->modid);
$this->assertEquals($banks[3]->cmid, $recentlyviewed[2]->modid);
$this->assertEquals($banks[1]->cmid, $recentlyviewed[3]->modid);
}
/**
* Assert that getting a default qbank instance on a course works with and without the "$createifnotexists" argument.
*
* @return void
* @covers ::get_default_open_instance_system_type
*/
public function test_get_default_open_instance_system_type(): void {
global $DB;
$this->resetAfterTest();
self::setAdminUser();
$course = self::getDataGenerator()->create_course();
$modinfo = get_fast_modinfo($course);
$qbanks = $modinfo->get_instances_of('qbank');
$this->assertCount(0, $qbanks);
$qbank = question_bank_helper::get_default_open_instance_system_type($course);
$this->assertNull($qbank);
$qbank = question_bank_helper::get_default_open_instance_system_type($course, true);
$this->assertEquals(get_string('systembank', 'question'), $qbank->get_name());
$modrecord = $DB->get_record('qbank', ['id' => $qbank->instance]);
$this->assertEquals(question_bank_helper::TYPE_SYSTEM, $modrecord->type);
// Create module other than a qbank with an ID that isn't used by a qbank yet.
do {
$wiki = self::getDataGenerator()->create_module('wiki', [
'course' => $course->id,
]);
} while ($DB->record_exists('qbank', ['id' => $wiki->id]));
// Swap the qbank instance record for one with the same ID as the wiki instance.
$newqbank = clone($modrecord);
$newqbank->id = $wiki->id;
$DB->insert_record_raw('qbank', $newqbank, customsequence: true);
$DB->delete_records('qbank', ['id' => $qbank->id]);
$DB->set_field('course_modules', 'instance', $newqbank->id, ['instance' => $qbank->instance]);
// Retry the above again.
\course_modinfo::purge_course_caches([$course->id]);
$qbank = question_bank_helper::get_default_open_instance_system_type($course);
$this->assertEquals(get_string('systembank', 'question'), $qbank->get_name());
$modrecord = $DB->get_record('qbank', ['id' => $qbank->instance]);
$this->assertEquals(question_bank_helper::TYPE_SYSTEM, $modrecord->type);
}
/**
* Assert that get_bank_name_string returns suitably truncated strings.
*
* @dataProvider bank_name_strings
* @param string $identifier
* @param string $component
* @param mixed $params
* @param string $expected
*/
public function test_get_bank_name_string(string $identifier, string $component, mixed $params, string $expected): void {
$this->assertEquals($expected, question_bank_helper::get_bank_name_string($identifier, $component, $params));
}
/**
* Get string examples with different parameter types and lengths.
*
* @return array[]
*/
public static function bank_name_strings(): array {
$longname = 'One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen ' .
'eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven ' .
'twenty-eight twenty-nine thirty thirty-one';
return [
'String with no parameters' => [
'systembank',
'question',
null,
'System shared question bank',
],
'String with short string parameter' => [
'topfor',
'question',
'Test course',
'Top for Test course',
],
'String with long string parameter' => [
'topfor',
'question',
$longname,
'Top for One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen ' .
'seventeen eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six ' .
'twenty-seven twenty-eight ...',
],
'String with short array parameter' => [
'defaultbank',
'question',
['coursename' => 'Test course'],
'Test course course question bank',
],
'String with long array parameter' => [
'defaultbank',
'question',
['coursename' => $longname],
'One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen ' .
'eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six ' .
'twenty-seven twenty-eight ... course question bank',
],
'String with multiple long array parameters' => [
'markoutofmax',
'question',
['mark' => $longname, 'max' => $longname],
'Mark One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen ' .
'eighteen ... out of One two three four five six seven eight nine ten eleven twelve thirteen fourteen ' .
'fifteen sixteen seventeen eighteen ...',
],
'Long lang string' => [
'howquestionsbehave_help',
'question',
null,
'Students can interact with the questions in the quiz in various different ways. For example, you may wish the ' .
'students to enter an answer to each question and then submit the entire quiz, before anything is graded or ' .
'they get any feedback. That would ...',
],
];
}
}