Commit ceb3fd15 authored by Mehmedovski's avatar Mehmedovski
Browse files

Initial commit: KNIGHT adaptivequiz for Moodle 5.0-5.2 (based on upstream...

Initial commit: KNIGHT adaptivequiz for Moodle 5.0-5.2 (based on upstream MOODLE_500, tested with 5.2, KNIGHT customizations)
parents
<?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 mod_adaptivequiz\local\report\individual_user_attempts;
use advanced_testcase;
use mod_adaptivequiz\local\attempt\attempt_state;
use mod_adaptivequiz\local\report\questions_difficulty_range;
use mod_adaptivequiz_renderer;
use moodle_page;
use moodle_url;
use ReflectionClass;
use stdClass;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* Tests for the individual user attempts report table (Feature 4 competency feedback link).
*
* @package mod_adaptivequiz
* @copyright 2026 KNIGHT, Hochschule fuer Technik Stuttgart
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(table::class)]
final class table_test extends advanced_testcase {
/**
* The score links to the competency feedback only when the feature is enabled for the activity.
*/
public function test_score_links_to_feedback_only_when_enabled(): void {
global $CFG;
require_once($CFG->dirroot . '/mod/adaptivequiz/locallib.php');
require_once($CFG->dirroot . '/mod/adaptivequiz/renderer.php');
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$student = $generator->create_and_enrol($course, 'student');
$renderer = new mod_adaptivequiz_renderer(new moodle_page(), 'mod_adaptivequiz');
$baseurl = new moodle_url('/mod/adaptivequiz/viewattemptreport.php', ['cmid' => 1]);
$completed = (object) ['id' => 55, 'measure' => 0.5, 'stderror' => 0.3,
'attemptstate' => attempt_state::COMPLETED];
// Feature enabled: a completed attempt's score links to the feedback page.
$aqon = $aqgenerator->create_instance(['course' => $course->id, 'competencyfeedbackenable' => 1]);
$cmon = get_coursemodule_from_instance('adaptivequiz', $aqon->id);
$tableon = new table(
$renderer,
filter::from_vars($aqon->id, $student->id),
$baseurl,
questions_difficulty_range::from_activity_instance($aqon),
$cmon->id
);
$linked = $this->call_col_score($tableon, $completed);
$this->assertStringContainsString('feedback.php', $linked);
$this->assertStringContainsString('attempt=55', $linked);
// An 'n/a' score (standard error 0, but a valid ability) is still linked - the feedback exists.
$naattempt = (object) ['id' => 56, 'measure' => 0.5, 'stderror' => 0.0,
'attemptstate' => attempt_state::COMPLETED];
$nalinked = $this->call_col_score($tableon, $naattempt);
$this->assertStringContainsString('feedback.php', $nalinked);
$this->assertStringContainsString('n/a', $nalinked);
// Feature disabled: the score is shown without a feedback link.
$aqoff = $aqgenerator->create_instance(['course' => $course->id, 'competencyfeedbackenable' => 0]);
$cmoff = get_coursemodule_from_instance('adaptivequiz', $aqoff->id);
$tableoff = new table(
$renderer,
filter::from_vars($aqoff->id, $student->id),
$baseurl,
questions_difficulty_range::from_activity_instance($aqoff),
$cmoff->id
);
$this->assertStringNotContainsString('feedback.php', $this->call_col_score($tableoff, $completed));
}
/**
* Invokes the protected col_score() formatter.
*
* @param table $table
* @param stdClass $row
* @return string
*/
private function call_col_score(table $table, stdClass $row): string {
$method = (new ReflectionClass($table))->getMethod('col_score');
$method->setAccessible(true);
return $method->invoke($table, $row);
}
}
<?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 mod_adaptivequiz\local\report;
use basic_testcase;
use PHPUnit\Framework\Attributes\CoversClass;
use stdClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\local\report\questions_difficulty_range::class)]
class questions_difficulty_range_test extends basic_testcase {
public function test_it_can_be_created_from_activity_record(): void {
$record = new stdClass();
$record->lowestlevel = 5;
$record->highestlevel = 25;
$range = questions_difficulty_range::from_activity_instance($record);
$this->assertEquals(5, $range->lowest_level());
$this->assertEquals(25, $range->highest_level());
}
}
<?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 mod_adaptivequiz\local\report\users_attempts\user_preferences;
use basic_testcase;
use mod_adaptivequiz\local\report\users_attempts\filter\filter_options;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\local\report\users_attempts\user_preferences\filter_user_preferences::class)]
class filter_user_preferences_test extends basic_testcase {
public function test_it_acquires_correct_default_values_when_unexpected_parameters_provided(): void {
$filter = filter_user_preferences::from_array([]);
$this->assertEquals(filter_options::users_option_default(), $filter->users());
$this->assertEquals(filter_options::INCLUDE_INACTIVE_ENROLMENTS_DEFAULT,
$filter->include_inactive_enrolments());
$filter = filter_user_preferences::from_array(['users' => 42, 'includeinactiveenrolments' => 5]);
$this->assertEquals(filter_options::users_option_default(), $filter->users());
$this->assertEquals(filter_options::INCLUDE_INACTIVE_ENROLMENTS_DEFAULT,
$filter->include_inactive_enrolments());
}
public function test_it_can_be_converted_to_array(): void {
$filterasarray = ['users' => filter_options::users_option_default(), 'includeinactiveenrolments' => 1];
$filter = filter_user_preferences::from_array($filterasarray);
$this->assertEquals($filterasarray, $filter->as_array());
}
}
<?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 mod_adaptivequiz\local\report\users_attempts\user_preferences;
use advanced_testcase;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\local\report\users_attempts\user_preferences\user_preferences_repository::class)]
class user_preferences_repository_test extends advanced_testcase {
public function test_it_stores_and_fetches_preferences(): void {
$this->resetAfterTest();
$preferences = user_preferences::from_array(['perpage' => 20, 'showinitialsbar' => 0]);
user_preferences_repository::save($preferences);
$this->assertEquals($preferences, user_preferences_repository::get());
}
public function test_it_avoids_querying_database_when_not_needed(): void {
global $DB;
$this->resetAfterTest();
$preferences = user_preferences::from_array(['perpage' => 20, 'showinitialsbar' => 0]);
// Check it will not query database to fetch preference after saving them.
$queriescountbefore = $DB->perf_get_reads();
user_preferences_repository::save($preferences);
user_preferences_repository::get();
$this->assertEquals($queriescountbefore, $DB->perf_get_reads());
// Check it will not query the database for subsequent fetches of preferences.
user_preferences_repository::get();
$this->assertEquals($queriescountbefore, $DB->perf_get_reads());
}
}
<?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 mod_adaptivequiz\local\report\users_attempts\user_preferences;
use basic_testcase;
use mod_adaptivequiz\local\report\users_attempts\filter\filter_options;
use PHPUnit\Framework\Attributes\CoversClass;
use stdClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\local\report\users_attempts\user_preferences\user_preferences::class)]
class user_preferences_test extends basic_testcase {
public function test_it_acquires_correct_default_values_when_provided_values_are_not_in_valid_range(): void {
$preferences = user_preferences::from_array(
['perpage' => 100, 'showinitialsbar' => 22, 'persistentfilter' => -1]
);
$this->assertEquals(15, $preferences->rows_per_page());
$this->assertTrue($preferences->show_initials_bar());
$this->assertFalse($preferences->persistent_filter());
$this->assertNull($preferences->filter());
$preferences = user_preferences::from_array([]);
$this->assertEquals(15, $preferences->rows_per_page());
$this->assertTrue($preferences->show_initials_bar());
$this->assertFalse($preferences->persistent_filter());
$this->assertNull($preferences->filter());
$preferencesobject = new stdClass();
$preferencesobject->perpage = -25;
$preferencesobject->showinitialsbar = 100;
$preferencesobject->persistentfilter = "12";
$preferences = user_preferences::from_plain_object($preferencesobject);
$this->assertEquals(15, $preferences->rows_per_page());
$this->assertTrue($preferences->show_initials_bar());
$this->assertFalse($preferences->persistent_filter());
$this->assertNull($preferences->filter());
}
public function test_it_can_acquire_and_loose_filter_preferences_while_preserving_previously_set_preferences(): void {
$preferences = user_preferences::from_array(['perpage' => 10, 'showinitialsbar' => 0, 'persistentfilter' => 1]);
$pfilterpreferencesarray = ['users' => filter_options::users_option_default(),
'includeinactiveenrolments' => filter_options::INCLUDE_INACTIVE_ENROLMENTS_DEFAULT];
$preferences = $preferences->with_filter_preference(filter_user_preferences::from_array($pfilterpreferencesarray));
$this->assertEquals(10, $preferences->rows_per_page());
$this->assertEquals(0, $preferences->show_initials_bar());
$this->assertEquals(1, $preferences->persistent_filter());
$this->assertEquals(filter_user_preferences::from_array($pfilterpreferencesarray), $preferences->filter());
$preferences = $preferences->without_filter_preference();
$this->assertEquals(10, $preferences->rows_per_page());
$this->assertEquals(0, $preferences->show_initials_bar());
$this->assertEquals(1, $preferences->persistent_filter());
$this->assertEquals(null, $preferences->filter());
}
public function test_it_can_be_converted_to_array(): void {
$preferences = user_preferences::defaults();
$this->assertEquals(['perpage' => 15, 'showinitialsbar' => 1, 'persistentfilter' => 0, 'filter' => null],
$preferences->as_array());
}
}
<?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 mod_adaptivequiz\local\repository;
use advanced_testcase;
use coding_exception;
use context;
use context_course;
use context_module;
use core_question\local\bank\question_version_status;
use core_tag_tag;
use PHPUnit\Framework\Attributes\CoversClass;
use question_bank;
use stdClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\local\repository\questions_repository::class)]
final class questions_repository_test extends advanced_testcase {
public function test_it_can_count_adaptive_questions_in_pool(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \core_question_generator $questionsgenerator */
$questionsgenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course = $coregenerator->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, $course->id, false, MUST_EXIST);
$qbankcontext = context_module::instance($qbankcm->id);
$questionscat1 = $questionsgenerator->create_question_category(['contextid' => $qbankcontext->id]);
$question1 = $questionsgenerator->create_question('truefalse', null, ['category' => $questionscat1->id]);
$questionsgenerator->create_question_tag(['questionid' => $question1->id, 'tag' => 'adpq_1']);
$question2 = $questionsgenerator->create_question('truefalse', null, ['category' => $questionscat1->id]);
$questionsgenerator->create_question_tag(['questionid' => $question2->id, 'tag' => 'adpq_2']);
$question3 = $questionsgenerator->create_question('truefalse', null, ['category' => $questionscat1->id]);
$questionsgenerator->create_question_tag(['questionid' => $question3->id, 'tag' => 'adpq_001']);
$questionscat2 = $questionsgenerator->create_question_category(['contextid' => $qbankcontext->id]);
$result = questions_repository::count_adaptive_questions_in_pool_with_level(
[$questionscat1->id, $questionscat2->id],
1
);
self::assertEquals(1, $result);
$questionsgenerator->create_question('truefalse', null, ['category' => $questionscat2->id]);
$questionsgenerator->create_question_tag(
['questionid' => $question2->id, 'tag' => 'truefalse_1']
);
$result = questions_repository::count_adaptive_questions_in_pool_with_level(
[$questionscat1->id, $questionscat2->id],
1
);
self::assertEquals(1, $result);
$questionscat3 = $questionsgenerator->create_question_category(['contextid' => $qbankcontext->id]);
$result = questions_repository::count_adaptive_questions_in_pool_with_level([$questionscat3->id], 1);
$this->assertEquals(0, $result);
}
public function test_it_can_count_questions_number_per_difficulty(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
/** @var \core_question_generator $questionsgenerator */
$questionsgenerator = $generator->get_plugin_generator('core_question');
$course = $generator->create_course();
$category1 = $questionsgenerator->create_question_category(
['contextid' => context_course::instance($course->id)->id]
);
$category2 = $questionsgenerator->create_question_category(
['contextid' => context_course::instance($course->id)->id]
);
$question1 = $questionsgenerator->create_question('truefalse', null, ['category' => $category1->id]);
$question1tag = $this->create_question_tag($question1->id, 'adpq_1');
$question2 = $questionsgenerator->create_question('truefalse', null, ['category' => $category2->id]);
$question2tag = $this->create_question_tag($question2->id, 'adpq_4');
$question3 = $questionsgenerator->create_question('truefalse', null, ['category' => $category1->id]);
// Update one of the questions to populate several versions in the database.
$questionsgenerator->update_question($question3, null, ['name' => 'New question version']);
$question3tag = $this->create_question_tag($question3->id, 'adpq_4');
$draftquestion = $questionsgenerator->create_question('truefalse', null,
['category' => $category1->id, 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
$draftquestiontag = $this->create_question_tag($draftquestion->id, 'adpq_5');
$hiddenquestion = $questionsgenerator->create_question('truefalse', null,
['category' => $category1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN]);
$hiddenquestiontag = $this->create_question_tag($hiddenquestion->id, 'adpq_6');
self::assertEquals([
new questions_number_per_difficulty(1, 1),
new questions_number_per_difficulty(4, 2),
],
questions_repository::count_questions_number_per_difficulty(
[$question1tag->id, $question2tag->id, $question3tag->id, $draftquestiontag->id, $hiddenquestiontag->id],
[$category1->id, $category2->id]
)
);
}
public function test_it_finds_questions_with_tags(): void {
$this->resetAfterTest();
// It returns empty array when no tags or no question categories specified.
self::assertEquals([], questions_repository::find_questions_with_tags([], [1, 2], []));
self::assertEquals([], questions_repository::find_questions_with_tags([1, 2], [], []));
$generator = $this->getDataGenerator();
/** @var \core_question_generator $questionsgenerator */
$questionsgenerator = $generator->get_plugin_generator('core_question');
$course = $generator->create_course();
$category1 = $questionsgenerator->create_question_category(
['contextid' => context_course::instance($course->id)->id]
);
$category2 = $questionsgenerator->create_question_category(
['contextid' => context_course::instance($course->id)->id]
);
$question1 = $questionsgenerator->create_question('truefalse', null,
['name' => 'Question 1', 'category' => $category1->id]);
$question1tag = $this->create_question_tag($question1->id, 'adpq_1');
$question2 = $questionsgenerator->create_question('truefalse', null,
['name' => 'Question 2', 'category' => $category2->id]);
// Update one of the questions to populate several versions in the database.
$question2 = $questionsgenerator->update_question($question2, null, ['name' => 'New question version']);
$question2tag = $this->create_question_tag($question2->id, 'adpq_4');
$draftquestion = $questionsgenerator->create_question('truefalse', null,
['category' => $category1->id, 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
$draftquestiontag = $this->create_question_tag($draftquestion->id, 'adpq_5');
$hiddenquestion = $questionsgenerator->create_question('truefalse', null,
['category' => $category1->id, 'status' => question_version_status::QUESTION_STATUS_HIDDEN]);
$hiddenquestiontag = $this->create_question_tag($hiddenquestion->id, 'adpq_6');
$tagidlist = [$question1tag->id, $question2tag->id, $draftquestiontag->id, $hiddenquestiontag->id];
$categoryidlist = [$category1->id, $category2->id];
$result = questions_repository::find_questions_with_tags($tagidlist, $categoryidlist, []);
$expectedresult = [];
$expectedrecord = new stdClass;
$expectedrecord->id = $question1->id;
$expectedrecord->name = $question1->name;
$expectedresult[$question1->id] = $expectedrecord;
$expectedrecord = new stdClass;
$expectedrecord->id = $question2->id;
$expectedrecord->name = $question2->name;
$expectedresult[$question2->id] = $expectedrecord;
self::assertEquals($expectedresult, $result);
// It can handle excluded questions.
$result = questions_repository::find_questions_with_tags($tagidlist, $categoryidlist, [$question1->id]);
$expectedresult = [];
$expectedrecord = new stdClass;
$expectedrecord->id = $question2->id;
$expectedrecord->name = $question2->name;
$expectedresult[$question2->id] = $expectedrecord;
self::assertEquals($expectedresult, $result);
}
/**
* Wraps creation of a question tag.
*
* @param int $questionid
* @param string $tagname
*/
private function create_question_tag(int $questionid, string $tagname): core_tag_tag {
$question = question_bank::load_question($questionid);
core_tag_tag::add_item_tag('core_question', 'question', $question->id,
context::instance_by_id($question->contextid), $tagname);
$tagbyname = core_tag_tag::get_by_name(0, $tagname);
if (!$tagbyname) {
throw new coding_exception('Could not find the tag by name, please, check data initialization steps.');
}
return $tagbyname;
}
}
<?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 mod_adaptivequiz\local\repository;
use advanced_testcase;
use coding_exception;
use context_course;
use core_question_generator;
use core_tag_tag;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\local\repository\tags_repository::class)]
final class tags_repository_test extends advanced_testcase {
public function test_it_gets_question_level_to_tag_id_mapping_by_tag_names(): void {
$this->resetAfterTest();
$this->assertEquals(
[],
tags_repository::get_question_level_to_tag_id_mapping_by_tag_names(
['adpq_5', 'adpq_6']
)
);
$generator = $this->getDataGenerator();
/** @var core_question_generator $questionsgenerator */
$questionsgenerator = $generator->get_plugin_generator('core_question');
$course = $generator->create_course();
$questionscat = $questionsgenerator->create_question_category(
['contextid' => context_course::instance($course->id)->id]
);
$question1 = $questionsgenerator->create_question('shortanswer', null, ['category' => $questionscat->id]);
$questionsgenerator->create_question_tag(
['questionid' => $question1->id, 'tag' => 'adpq_5']
);
$question2 = $questionsgenerator->create_question('shortanswer', null, ['category' => $questionscat->id]);
$questionsgenerator->create_question_tag(
['questionid' => $question2->id, 'tag' => 'adpq_6']
);
$question3 = $questionsgenerator->create_question('shortanswer', null, ['category' => $questionscat->id]);
$questionsgenerator->create_question_tag(
['questionid' => $question3->id, 'tag' => 'adpq_7']
);
core_tag_tag::add_item_tag('core', 'course', $course->id, context_course::instance($course->id), 'adpq_5');
$map = tags_repository::get_question_level_to_tag_id_mapping_by_tag_names(
['adpq_5', 'adpq_6']
);
$questionstags = core_tag_tag::get_items_tags('core_question', 'question', [$question1->id, $question2->id]);
$tagidlist = array_map(function(array $itemtags): int {
return $itemtags[array_key_first($itemtags)]->id;
}, $questionstags);
$this->assertEquals(2, count($map));
$this->assertEquals(
[5 => array_shift($tagidlist), 6 => array_shift($tagidlist)],
$map
);
}
public function test_it_fails_to_get_question_level_to_tag_id_mapping_for_an_empty_array_of_tag_names(): void {
$this->expectException(coding_exception::class);
tags_repository::get_question_level_to_tag_id_mapping_by_tag_names([]);
}
public function test_it_gets_tag_id_list_by_tag_names(): void {
$this->resetAfterTest();
$this->assertEquals(
[],
tags_repository::get_tag_id_list_by_tag_names(
['adpq_10', 'adpq_11']
)
);
$generator = $this->getDataGenerator();
/** @var core_question_generator $questionsgenerator */
$questionsgenerator = $generator->get_plugin_generator('core_question');
$course = $generator->create_course();
$questionscat = $questionsgenerator->create_question_category(
['contextid' => context_course::instance($course->id)->id]
);
$question1 = $questionsgenerator->create_question('truefalse', null, ['category' => $questionscat->id]);
$questionsgenerator->create_question_tag(
['questionid' => $question1->id, 'tag' => 'adpq_10']
);
$question2 = $questionsgenerator->create_question('shortanswer', null, ['category' => $questionscat->id]);
$questionsgenerator->create_question_tag(
['questionid' => $question2->id, 'tag' => 'adpq_11']
);
$question3 = $questionsgenerator->create_question('truefalse', null, ['category' => $questionscat->id]);
$questionsgenerator->create_question_tag(
['questionid' => $question3->id, 'tag' => 'adpq_12']
);
core_tag_tag::add_item_tag('core', 'course', $course->id, context_course::instance($course->id), 'adpq_10');
$tagidlist = tags_repository::get_tag_id_list_by_tag_names(
['adpq_10', 'adpq_11']
);
$questionstags = core_tag_tag::get_items_tags('core_question', 'question', [$question1->id, $question2->id]);
$itemtagidlist = array_map(function(array $itemtags): int {
return $itemtags[array_key_first($itemtags)]->id;
}, $questionstags);
$this->assertEquals(2, count($tagidlist));
$this->assertEquals(
[0 => array_shift($itemtagidlist), 1 => array_shift($itemtagidlist)],
$tagidlist
);
}
public function test_it_fails_to_get_a_list_of_tag_id_for_an_empty_array_of_tag_names(): void {
$this->expectException(coding_exception::class);
tags_repository::get_tag_id_list_by_tag_names([]);
}
}
<?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 mod_adaptivequiz;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot .'/mod/adaptivequiz/locallib.php');
use advanced_testcase;
use context_module;
use mod_adaptivequiz\event\attempt_completed;
use mod_adaptivequiz\local\attempt;
use mod_adaptivequiz\local\attempt\attempt_state;
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\DataProvider;
use question_engine;
/**
* Adaptive locallib.php PHPUnit tests.
*
* @package mod_adaptivequiz
* @copyright 2013 Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversFunction('adaptivequiz_count_user_previous_attempts')]
#[CoversFunction('adaptivequiz_allowed_attempt')]
#[CoversFunction('adaptivequiz_uniqueid_part_of_attempt')]
#[CoversFunction('adaptivequiz_update_attempt_data')]
#[CoversFunction('adaptivequiz_complete_attempt')]
#[CoversFunction('adaptivequiz_min_attempts_reached')]
#[CoversFunction('adaptivequiz_user_can_review_attempt')]
#[CoversFunction('adaptivequiz_count_completed_attempts')]
#[CoversFunction('adaptivequiz_question_check_reminder_due')]
#[CoversFunction('adaptivequiz_ability_from_measure')]
#[CoversFunction('adaptivequiz_competency_feedback_levels')]
#[CoversFunction('adaptivequiz_save_competency_descriptions')]
#[CoversFunction('adaptivequiz_competency_feedback_selectable')]
class locallib_test extends advanced_testcase {
/**
* Provide input data to the parameters of the test_allowed_attempt_fail() method.
*/
public static function attempts_allowed_data_fail(): array {
return [
[99, 100],
[99, 99],
];
}
/**
* Provide input data to the parameters of the test_allowed_attempt() method.
*/
public static function attempts_allowed_data(): array {
return [
[99, 98],
[0, 99],
];
}
/**
* Provide input data to the parameters of the test_adaptivequiz_construct_view_report_orderby() method.
*/
public function view_reports_data(): array {
return [
['firstname', 'ASC'],
['firstname', 'DESC'],
['lastname', 'ASC'],
['lastname', 'DESC'],
['attempts', 'ASC'],
['attempts', 'DESC'],
['stderror', 'ASC'],
['stderror', 'DESC'],
];
}
/**
* This function tests failing conditions for counting user's previous attempts that have been marked as completed.
*/
public function test_count_user_previous_attempts_fail(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$modgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \core_question_generator $questionsgenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course = $coregenerator->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, 0, false, MUST_EXIST);
$qbankcontext = context_module::instance($qbankcm->id);
$qcat = question_get_default_category($qbankcontext->id);
$question = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$startinglevel = 1;
$questiongenerator->create_question_tag(['questionid' => $question->id, 'tag' => "adpq_$startinglevel"]);
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 1,
'maximumquestions' => 2,
]);
$modgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank->id,
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user1 = $this->getDataGenerator()->create_user();
$user2 = $this->getDataGenerator()->create_user();
// End of setup.
$this->assertEquals(0, adaptivequiz_count_user_previous_attempts($adaptivequiz->id, $user1->id));
$this->assertEquals(0, adaptivequiz_count_user_previous_attempts($adaptivequiz->id, $user2->id));
$attempt = new attempt($adaptivequizforattempt, $user1->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
adaptivequiz_complete_attempt(uniqueid: $uniqueid, adaptivequiz: $adaptivequiz, context: $context, userid: $user1->id,
standarderror: '', statusmessage: '');
$this->assertEquals(1, adaptivequiz_count_user_previous_attempts($adaptivequiz->id, $user1->id));
$this->assertEquals(0, adaptivequiz_count_user_previous_attempts($adaptivequiz->id, $user2->id));
$attempt = new attempt($adaptivequizforattempt, $user1->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
adaptivequiz_complete_attempt(uniqueid: $uniqueid, adaptivequiz: $adaptivequiz, context: $context, userid: $user1->id,
standarderror: '', statusmessage: '');
$attempt = new attempt($adaptivequizforattempt, $user2->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
adaptivequiz_complete_attempt(uniqueid: $uniqueid, adaptivequiz: $adaptivequiz, context: $context, userid: $user2->id,
standarderror: '', statusmessage: '');
$this->assertEquals(2, adaptivequiz_count_user_previous_attempts($adaptivequiz->id, $user1->id));
$this->assertEquals(1, adaptivequiz_count_user_previous_attempts($adaptivequiz->id, $user2->id));
}
/**
* This function tests a non-failing conditions for counting user's previous attempts that have been marked
* as completed.
*/
public function test_count_user_previous_attempts_inprogress(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$modgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \core_question_generator $questionsgenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course = $coregenerator->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, 0, false, MUST_EXIST);
$qbankcontext = context_module::instance($qbankcm->id);
$qcat = question_get_default_category($qbankcontext->id);
$question = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$startinglevel = 1;
$questiongenerator->create_question_tag(['questionid' => $question->id, 'tag' => "adpq_$startinglevel"]);
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 1,
'maximumquestions' => 2,
]);
$modgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank->id,
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user = $coregenerator->create_user();
// End of setup.
$attempt = new attempt($adaptivequizforattempt, $user->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
adaptivequiz_complete_attempt(uniqueid: $uniqueid, adaptivequiz: $adaptivequiz, context: $context, userid: $user->id,
standarderror: '', statusmessage: '');
$this->assertEquals(1, adaptivequiz_count_user_previous_attempts($adaptivequiz->id, $user->id));
$attempt = new attempt($adaptivequizforattempt, $user->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
adaptivequiz_complete_attempt(uniqueid: $uniqueid, adaptivequiz: $adaptivequiz, context: $context, userid: $user->id,
standarderror: '', statusmessage: '');
$this->assertEquals(2, adaptivequiz_count_user_previous_attempts($adaptivequiz->id, $user->id));
}
/**
* Tests failing conditions for determining whether a user is allowed further attempts at the activity.
*
* @param int $maxattempts The maximum number of attempts allowed.
* @param int $attempts The number of attempts taken thus far.
*/
#[DataProvider('attempts_allowed_data_fail')]
public function test_allowed_attempt_no_more_attempts_allowed($maxattempts, $attempts) {
$data = adaptivequiz_allowed_attempt($maxattempts, $attempts);
$this->assertFalse($data);
}
/**
* Tests failing conditions for determining whether a user is allowed further attempts at the activity.
*
* @param int $maxattempts The maximum number of attempts allowed.
* @param int $attempts The number of attempts taken thus far.
*/
#[DataProvider('attempts_allowed_data')]
public function test_allowed_attempt($maxattempts, $attempts) {
$data = adaptivequiz_allowed_attempt($maxattempts, $attempts);
$this->assertTrue($data);
}
/**
* This function tests adaptivequiz_uniqueid_part_of_attempt().
*/
public function test_adaptivequiz_uniqueid_part_of_attempt(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$modgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \core_question_generator $questionsgenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course = $coregenerator->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, 0, false, MUST_EXIST);
$qbankcontext = context_module::instance($qbankcm->id);
$qcat = question_get_default_category($qbankcontext->id);
$question = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$startinglevel = 1;
$questiongenerator->create_question_tag(['questionid' => $question->id, 'tag' => "adpq_$startinglevel"]);
$adaptivequiz1 = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 1,
'maximumquestions' => 2,
]);
$modgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz1->id,
'qbankid' => $qbank->id,
]);
$adaptivequiz2 = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 1,
'maximumquestions' => 2,
]);
$context = context_module::instance($adaptivequiz1->cmid);
$adaptivequizforattempt = clone($adaptivequiz1);
$adaptivequizforattempt->context = $context;
$user1 = $coregenerator->create_user();
$user2 = $coregenerator->create_user();
// End of setup.
$attempt = new attempt($adaptivequizforattempt, $user1->id);
$attempt->set_level($adaptivequiz1->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
$this->assertTrue(adaptivequiz_uniqueid_part_of_attempt($uniqueid, $adaptivequiz1->id, $user1->id));
$this->assertFalse(adaptivequiz_uniqueid_part_of_attempt($uniqueid, $adaptivequiz1->id, $user2->id));
$this->assertFalse(adaptivequiz_uniqueid_part_of_attempt($uniqueid, $adaptivequiz2->id, $user1->id));
$this->assertFalse(adaptivequiz_uniqueid_part_of_attempt($uniqueid, $adaptivequiz2->id, $user2->id));
}
/**
* This function tests the updating of the attempt data.
*/
public function test_adaptivequiz_update_attempt_data(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$modgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \core_question_generator $questionsgenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course = $coregenerator->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, 0, false, MUST_EXIST);
$qbankcontext = context_module::instance($qbankcm->id);
$qcat = question_get_default_category($qbankcontext->id);
$question = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$startinglevel = 1;
$questiongenerator->create_question_tag(['questionid' => $question->id, 'tag' => "adpq_$startinglevel"]);
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 1,
'maximumquestions' => 2,
]);
$modgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank->id,
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user = $coregenerator->create_user();
// End of setup.
// 'Answer' the question as the user.
$attempt = new attempt($adaptivequizforattempt, $user->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$attempt->get_attempt();
$quba = $attempt->initialize_quba();
$slot = $attempt->find_last_quest_used_by_attempt($quba);
$time = time();
$quba->process_all_actions($time, $quba->prepare_simulated_post_data([
$slot => ['answer' => true],
]));
$quba->finish_all_questions($time);
question_engine::save_questions_usage_by_activity($quba);
$attemptrecord = $attempt->get_attempt();
$this->assertEquals(0, $attemptrecord->difficultysum);
$this->assertEquals(0, $attemptrecord->questionsattempted);
$this->assertEquals(999, $attemptrecord->standarderror);
$this->assertEquals(0, $attemptrecord->measure);
$uniqueid = $quba->get_id();
adaptivequiz_update_attempt_data(uniqueid: $uniqueid, instance: $adaptivequiz->id, userid: $user->id, level: 50,
standarderror: 0.002, measure: 0.99);
$attemptrecord = $attempt->get_attempt();
$this->assertEquals(50, $attemptrecord->difficultysum);
$this->assertEquals(1, $attemptrecord->questionsattempted);
$this->assertEquals(0.002, $attemptrecord->standarderror);
$this->assertEquals(0.99, $attemptrecord->measure);
}
/**
* This function tests the updating of the attempt data.
*/
public function test_adaptivequiz_update_attempt_data_using_infinite_value(): void {
$result = adaptivequiz_update_attempt_data(3, 13, 3, -INF, 0.02, 0.1);
$this->assertFalse($result);
}
/**
* This function tests completing an attempt.
*/
public function test_adaptivequiz_complete_attempt(): void {
global $DB;
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$modgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \core_question_generator $questionsgenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course = $coregenerator->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, 0, false, MUST_EXIST);
$qbankcontext = context_module::instance($qbankcm->id);
$qcat = question_get_default_category($qbankcontext->id);
$question = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$startinglevel = 1;
$questiongenerator->create_question_tag(['questionid' => $question->id, 'tag' => "adpq_$startinglevel"]);
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 1,
'maximumquestions' => 2,
]);
$modgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank->id,
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user = $coregenerator->create_user();
// End of setup.
$attempt = new attempt($adaptivequizforattempt, $user->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
adaptivequiz_complete_attempt(uniqueid: $uniqueid, adaptivequiz: $adaptivequiz, context: $context, userid: $user->id,
standarderror: '1', statusmessage: 'php unit test');
$attemptrecord = $DB->get_record('adaptivequiz_attempt', ['uniqueid' => $uniqueid],
'id, attemptstopcriteria, attemptstate, standarderror', MUST_EXIST);
$this->assertEquals('php unit test', $attemptrecord->attemptstopcriteria);
$this->assertEquals(attempt_state::COMPLETED, $attemptrecord->attemptstate);
$this->assertEquals('1.00000', $attemptrecord->standarderror);
}
public function test_event_is_triggered_on_attempt_completion(): void {
global $DB;
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$modgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \core_question_generator $questionsgenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course = $coregenerator->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, 0, false, MUST_EXIST);
$qbankcontext = context_module::instance($qbankcm->id);
$qcat = question_get_default_category($qbankcontext->id);
$question = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$startinglevel = 1;
$questiongenerator->create_question_tag(['questionid' => $question->id, 'tag' => "adpq_$startinglevel"]);
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 1,
'maximumquestions' => 2,
]);
$modgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank->id,
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user = $coregenerator->create_user();
// End of setup.
$attempt = new attempt($adaptivequizforattempt, $user->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
$attemptrecord = $DB->get_record('adaptivequiz_attempt', ['uniqueid' => $uniqueid], '*', MUST_EXIST);
$eventsink = $this->redirectEvents();
adaptivequiz_complete_attempt($uniqueid, $adaptivequiz, $context, $user->id, '1', 'php unit test');
$events = $eventsink->get_events();
$eventsink->close();
$attemptcompletedevent = null;
foreach ($events as $event) {
if ($event instanceof attempt_completed) {
$attemptcompletedevent = $event;
break;
}
}
$this->assertNotNull($attemptcompletedevent,
sprintf('Failed asserting that event %s was triggered.', attempt_completed::class));
$this->assertEquals($attemptrecord->id, $event->objectid);
$this->assertEquals($context, $attemptcompletedevent->get_context());
$this->assertEquals($user->id, $event->userid);
$this->assertEquals($attemptrecord, $event->get_record_snapshot('adaptivequiz_attempt', $attemptrecord->id));
$this->assertEquals($adaptivequiz, $event->get_record_snapshot('adaptivequiz', $adaptivequiz->id));
}
/**
* This function tests checking if the minimum number of questions have been attempted.
*/
public function test_adaptivequiz_min_attempts_reached(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$modgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \core_question_generator $questionsgenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course = $coregenerator->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, 0, false, MUST_EXIST);
$qbankcontext = context_module::instance($qbankcm->id);
$qcat = question_get_default_category($qbankcontext->id);
$question = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$startinglevel = 1;
$questiongenerator->create_question_tag(['questionid' => $question->id, 'tag' => "adpq_$startinglevel"]);
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 3,
'maximumquestions' => 10,
]);
$modgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank->id,
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user = $coregenerator->create_user();
// End of setup.
$attempt = new attempt($adaptivequizforattempt, $user->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
// These are not relevant for this test when updating an attempt.
$difflogit = 0;
$standarderror = 0;
$measure = 0;
adaptivequiz_update_attempt_data($uniqueid, $adaptivequiz->id, $user->id, $difflogit, $standarderror, $measure);
$this->assertFalse(adaptivequiz_min_attempts_reached($uniqueid, $adaptivequiz->id, $user->id));
adaptivequiz_update_attempt_data($uniqueid, $adaptivequiz->id, $user->id, $difflogit, $standarderror, $measure);
$this->assertFalse(adaptivequiz_min_attempts_reached($uniqueid, $adaptivequiz->id, $user->id));
adaptivequiz_update_attempt_data($uniqueid, $adaptivequiz->id, $user->id, $difflogit, $standarderror, $measure);
$this->assertTrue(adaptivequiz_min_attempts_reached($uniqueid, $adaptivequiz->id, $user->id));
}
/**
* KNIGHT (Feature 2): who may review an attempt - teachers (viewreport) may review any; a student may
* review only their own attempt and only when the activity enables showownattemptresult.
*/
public function test_user_can_review_attempt(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$aqoff = $aqgenerator->create_instance(['course' => $course->id, 'showownattemptresult' => 0]);
$contextoff = context_module::instance($aqoff->cmid);
$aqon = $aqgenerator->create_instance(['course' => $course->id, 'showownattemptresult' => 1]);
$contexton = context_module::instance($aqon->cmid);
$student = $generator->create_and_enrol($course, 'student');
$otherstudent = $generator->create_and_enrol($course, 'student');
$teacher = $generator->create_and_enrol($course, 'editingteacher');
$completed = (object) ['userid' => $student->id, 'attemptstate' => attempt_state::COMPLETED];
$inprogress = (object) ['userid' => $student->id, 'attemptstate' => attempt_state::IN_PROGRESS];
// Teachers with viewreport may review any attempt, regardless of the setting or its state.
$this->assertTrue(adaptivequiz_user_can_review_attempt($aqoff, $completed, $contextoff, $teacher->id));
$this->assertTrue(adaptivequiz_user_can_review_attempt($aqoff, $inprogress, $contextoff, $teacher->id));
// Student, own completed attempt, setting on: allowed.
$this->assertTrue(adaptivequiz_user_can_review_attempt($aqon, $completed, $contexton, $student->id));
// Student, own completed attempt, setting off: denied.
$this->assertFalse(adaptivequiz_user_can_review_attempt($aqoff, $completed, $contextoff, $student->id));
// Student, someone else's completed attempt, setting on: denied.
$this->assertFalse(adaptivequiz_user_can_review_attempt($aqon, $completed, $contexton, $otherstudent->id));
// Student, own in-progress attempt, setting on: denied - no reviewing a live attempt.
$this->assertFalse(adaptivequiz_user_can_review_attempt($aqon, $inprogress, $contexton, $student->id));
}
/**
* KNIGHT (Feature 3): counts completed attempts across all users of an activity.
*/
public function test_count_completed_attempts(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$adaptivequiz = $aqgenerator->create_instance(['course' => $course->id]);
$insert = function (int $userid, string $state, int $uniqueid) use ($adaptivequiz, $DB): void {
$DB->insert_record('adaptivequiz_attempt', (object) [
'instance' => $adaptivequiz->id,
'userid' => $userid,
'uniqueid' => $uniqueid,
'attemptstate' => $state,
'attemptstopcriteria' => '',
'questionsattempted' => 0,
'difficultysum' => 0,
'standarderror' => 1.0,
'measure' => 0.0,
'timecreated' => 1,
'timemodified' => 1,
]);
};
$insert(11, attempt_state::COMPLETED, 101);
$insert(22, attempt_state::COMPLETED, 102);
$insert(33, attempt_state::IN_PROGRESS, 103);
// Two completed across all users; the in-progress one is not counted.
$this->assertSame(2, adaptivequiz_count_completed_attempts($adaptivequiz->id));
}
/**
* Data provider for {@see test_question_check_reminder_due()}.
*
* @return array<string, array{int, int, int, bool}>
*/
public static function question_check_reminder_due_provider(): array {
// Columns: trigger, questionschecked (count at last review), completedattempts, expected.
return [
'disabled (trigger 0)' => [0, 0, 100, false],
'never reviewed, not yet reached' => [5, 0, 4, false],
'never reviewed, reached' => [5, 0, 5, true],
'never reviewed, well past' => [5, 0, 12, true],
'reviewed at 5, only +4 since' => [5, 5, 9, false],
'reviewed at 5, +5 since' => [5, 5, 10, true],
'reviewed at 7, not enough since' => [5, 7, 11, false],
'reviewed at 7, enough since' => [5, 7, 12, true],
];
}
/**
* KNIGHT (Feature 3): whether the question-analysis reminder is currently due.
*/
#[DataProvider('question_check_reminder_due_provider')]
public function test_question_check_reminder_due(int $trigger, int $checkedcount, int $completed, bool $expected): void {
$adaptivequiz = (object) ['questionchecktrigger' => $trigger, 'questionschecked' => $checkedcount];
$this->assertSame($expected, adaptivequiz_question_check_reminder_due($adaptivequiz, $completed));
}
/**
* KNIGHT (Feature 4): the ability measure maps onto the activity's level scale.
*/
public function test_ability_from_measure(): void {
$adaptivequiz = (object) ['lowestlevel' => 1, 'highestlevel' => 9];
// No measure -> null.
$this->assertNull(adaptivequiz_ability_from_measure((object) [], $adaptivequiz));
// Measure 0 -> logistic 0.5 -> midpoint of [1, 9].
$this->assertEqualsWithDelta(5.0, adaptivequiz_ability_from_measure((object) ['measure' => 0], $adaptivequiz), 0.0001);
// Large positive measure -> close to the highest level.
$this->assertEqualsWithDelta(9.0, adaptivequiz_ability_from_measure((object) ['measure' => 20], $adaptivequiz), 0.001);
// Large negative measure -> close to the lowest level.
$this->assertEqualsWithDelta(1.0, adaptivequiz_ability_from_measure((object) ['measure' => -20], $adaptivequiz), 0.001);
}
/**
* Data provider for {@see test_competency_feedback_levels()}.
*
* @return array<string, array{float, array{feedback: ?int, feedforward: ?int, feedup: ?int}}>
*/
public static function competency_feedback_levels_provider(): array {
return [
'on a whole level' => [3.0, ['feedback' => 2, 'feedforward' => 3, 'feedup' => 4]],
'just above, within margin' => [3.15, ['feedback' => 2, 'feedforward' => 3, 'feedup' => 4]],
// Exactly on the 0.2 margin: must count as within it (float error must not tip it out).
'below, exactly on margin' => [2.8, ['feedback' => 2, 'feedforward' => 3, 'feedup' => 4]],
'above, exactly on margin' => [3.2, ['feedback' => 2, 'feedforward' => 3, 'feedup' => 4]],
// Just outside the margin: between two levels, no feedforward.
'just outside margin' => [3.25, ['feedback' => 3, 'feedforward' => null, 'feedup' => 4]],
'halfway between levels' => [3.5, ['feedback' => 3, 'feedforward' => null, 'feedup' => 4]],
];
}
/**
* KNIGHT (Feature 4): which levels feed the feedback / feedforward / feed-up blocks.
*
* @param float $ability
* @param array $expected
*/
#[DataProvider('competency_feedback_levels_provider')]
public function test_competency_feedback_levels(float $ability, array $expected): void {
$this->assertSame($expected, adaptivequiz_competency_feedback_levels($ability));
}
/**
* KNIGHT (Feature 4): saving acts only on the submitted competencydesc{level} fields. It must not
* depend on lowestlevel/highestlevel (which live in a separate form and are absent from the normal
* activity save), and it must not wipe descriptions for levels the form did not show.
*/
public function test_save_competency_descriptions(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$adaptivequiz = $aqgenerator->create_instance(['course' => $course->id]);
// Nothing stored yet.
$this->assertFalse($DB->record_exists('adaptivequiz_competencydesc', ['adaptivequizid' => $adaptivequiz->id]));
// A description for a level not among the submitted fields (e.g. outside the current range) - must survive.
$DB->insert_record(
'adaptivequiz_competencydesc',
(object) ['adaptivequizid' => $adaptivequiz->id, 'level' => 7, 'description' => 'High']
);
// The normal activity save submits only the competencydesc fields, without lowestlevel/highestlevel.
$data = (object) ['competencydesc1' => 'Beginner', 'competencydesc2' => '', 'competencydesc3' => 'Advanced'];
adaptivequiz_save_competency_descriptions($adaptivequiz->id, $data);
$this->assertTrue($DB->record_exists('adaptivequiz_competencydesc', ['adaptivequizid' => $adaptivequiz->id]));
$stored = $DB->get_records_menu(
'adaptivequiz_competencydesc',
['adaptivequizid' => $adaptivequiz->id],
'level',
'level, description'
);
// Levels 1 and 3 stored, empty level 2 skipped, and the unshown level 7 preserved.
$this->assertSame([1 => 'Beginner', 3 => 'Advanced', 7 => 'High'], $stored);
// Editing again: clearing a field removes it, changing one updates it, level 7 still survives.
$data2 = (object) ['competencydesc1' => '', 'competencydesc2' => 'Intermediate', 'competencydesc3' => 'Advanced'];
adaptivequiz_save_competency_descriptions($adaptivequiz->id, $data2);
$stored = $DB->get_records_menu(
'adaptivequiz_competencydesc',
['adaptivequizid' => $adaptivequiz->id],
'level',
'level, description'
);
$this->assertSame([2 => 'Intermediate', 3 => 'Advanced', 7 => 'High'], $stored);
}
/**
* Data provider for {@see test_competency_feedback_selectable()}.
*
* @return array<string, array{?int, ?int, bool}>
*/
public static function competency_feedback_selectable_provider(): array {
// Columns: lowestlevel, highestlevel, expected (positive, ascending, max 10 levels).
return [
'range not set' => [null, null, false],
'lowest only' => [1, null, false],
'two levels' => [1, 2, true],
'mid-scale within the cap' => [5, 14, true],
'exactly at the cap' => [1, 10, true],
'single level (not ascending)' => [5, 5, false],
'inverted range' => [5, 3, false],
'zero lowest (not positive)' => [0, 9, false],
'negative lowest' => [-1, 5, false],
'one over the cap' => [1, 11, false],
'well over the cap' => [1, 100, false],
];
}
/**
* KNIGHT (Feature 4): the competency feedback is selectable only for a set range within the level cap.
*
* @param int|null $lowest
* @param int|null $highest
* @param bool $expected
*/
#[DataProvider('competency_feedback_selectable_provider')]
public function test_competency_feedback_selectable(?int $lowest, ?int $highest, bool $expected): void {
$this->assertSame($expected, adaptivequiz_competency_feedback_selectable($lowest, $highest));
}
}
<?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 mod_adaptivequiz\output;
use advanced_testcase;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* Tests for the KNIGHT competency feedback renderable (Feature 4).
*
* @package mod_adaptivequiz
* @copyright 2026 KNIGHT, Hochschule fuer Technik Stuttgart
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(competency_feedback::class)]
final class competency_feedback_test extends advanced_testcase {
/**
* With the ability on a whole level, the feedback / feedforward / feed-up blocks use the level
* below, the level itself and the level above respectively.
*/
public function test_export_builds_feedback_blocks(): void {
global $DB, $PAGE;
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$adaptivequiz = $aqgenerator->create_instance(
['course' => $course->id, 'lowestlevel' => 1, 'highestlevel' => 9]
);
foreach ([4 => 'Below', 5 => 'Current', 6 => 'Above'] as $level => $desc) {
$DB->insert_record(
'adaptivequiz_competencydesc',
(object) ['adaptivequizid' => $adaptivequiz->id, 'level' => $level, 'description' => $desc]
);
}
// Measure 0 -> logistic 0.5 -> ability 5.0 (midpoint of [1, 9]) -> whole level 5.
$attempt = (object) ['measure' => 0.0];
$renderable = new competency_feedback($adaptivequiz, $attempt);
$data = $renderable->export_for_template($PAGE->get_renderer('mod_adaptivequiz'));
$this->assertSame(4, $data->feedback_level);
$this->assertSame(5, $data->feedforward_level);
$this->assertSame(6, $data->feedup_level);
$this->assertSame('Below', $data->feedback_block);
$this->assertSame('Current', $data->feedforward_block);
$this->assertSame('Above', $data->feedup_block);
$this->assertTrue($data->show_feedback);
$this->assertTrue($data->show_feedforward);
$this->assertTrue($data->show_feedup);
}
/**
* A level without a configured description simply does not show that block.
*/
public function test_export_hides_blocks_without_descriptions(): void {
global $DB, $PAGE;
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$adaptivequiz = $aqgenerator->create_instance(
['course' => $course->id, 'lowestlevel' => 1, 'highestlevel' => 9]
);
// Only the current level has a description.
$DB->insert_record(
'adaptivequiz_competencydesc',
(object) ['adaptivequizid' => $adaptivequiz->id, 'level' => 5, 'description' => 'Current']
);
$attempt = (object) ['measure' => 0.0];
$renderable = new competency_feedback($adaptivequiz, $attempt);
$data = $renderable->export_for_template($PAGE->get_renderer('mod_adaptivequiz'));
$this->assertTrue($data->show_feedforward);
$this->assertFalse($data->show_feedback);
$this->assertFalse($data->show_feedup);
}
}
<?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 mod_adaptivequiz\output;
use advanced_testcase;
use mod_adaptivequiz\local\attempt\attempt_state;
use mod_adaptivequiz_renderer;
use moodle_page;
use moodle_url;
use PHPUnit\Framework\Attributes\CoversClass;
use ReflectionClass;
use stdClass;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/adaptivequiz/renderer.php');
/**
* Tests for the user attempts overview table.
*
* @package mod_adaptivequiz
* @copyright 2026 KNIGHT, Hochschule fuer Technik Stuttgart
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(user_attempts_overview::class)]
final class user_attempts_overview_test extends advanced_testcase {
/**
* KNIGHT (Feature 2): the review column appears only when the activity lets students review.
*/
public function test_review_column_present_only_when_enabled(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$student = $generator->create_and_enrol($course, 'student');
$renderer = new mod_adaptivequiz_renderer(new moodle_page(), 'mod_adaptivequiz');
$baseurl = new moodle_url('/mod/adaptivequiz/view.php', ['id' => 1]);
$aqon = $aqgenerator->create_instance(['course' => $course->id, 'showownattemptresult' => 1]);
$tableon = new user_attempts_overview($renderer);
$tableon->init($baseurl, $aqon, $student->id);
$this->assertArrayHasKey('review', $this->columns_of($tableon));
$aqoff = $aqgenerator->create_instance(['course' => $course->id, 'showownattemptresult' => 0]);
$tableoff = new user_attempts_overview($renderer);
$tableoff->init($baseurl, $aqoff, $student->id);
$this->assertArrayNotHasKey('review', $this->columns_of($tableoff));
}
/**
* KNIGHT (Feature 2): the review icon links to the review page for completed attempts only.
*/
public function test_review_icon_links_to_review_page_for_completed_attempts(): void {
$this->resetAfterTest();
$renderer = new mod_adaptivequiz_renderer(new moodle_page(), 'mod_adaptivequiz');
$table = new user_attempts_overview($renderer);
$completed = $this->call_col_review($table, (object) ['id' => 123, 'state' => attempt_state::COMPLETED]);
$this->assertStringContainsString('reviewattempt.php', $completed);
$this->assertStringContainsString('attempt=123', $completed);
$inprogress = $this->call_col_review($table, (object) ['id' => 123, 'state' => attempt_state::IN_PROGRESS]);
$this->assertSame('', $inprogress);
}
/**
* KNIGHT (Feature 4): the score links to the competency feedback only when the feature is enabled
* and the attempt is completed.
*/
public function test_score_links_to_feedback_only_when_enabled(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$student = $generator->create_and_enrol($course, 'student');
$renderer = new mod_adaptivequiz_renderer(new moodle_page(), 'mod_adaptivequiz');
$baseurl = new moodle_url('/mod/adaptivequiz/view.php', ['id' => 42]);
$completed = (object) ['id' => 123, 'state' => attempt_state::COMPLETED,
'measure' => 0.5, 'highestlevel' => 9, 'lowestlevel' => 1];
// Feature enabled: a completed attempt's score links to the feedback page.
$aqon = $aqgenerator->create_instance(['course' => $course->id, 'competencyfeedbackenable' => 1]);
$tableon = new user_attempts_overview($renderer);
$tableon->init($baseurl, $aqon, $student->id);
$linked = $this->call_col_measure($tableon, $completed);
$this->assertStringContainsString('feedback.php', $linked);
$this->assertStringContainsString('attempt=123', $linked);
// Even when enabled, an in-progress attempt's score is not linked.
$inprogress = (object) ['id' => 123, 'state' => attempt_state::IN_PROGRESS,
'measure' => 0.5, 'highestlevel' => 9, 'lowestlevel' => 1];
$this->assertStringNotContainsString('feedback.php', $this->call_col_measure($tableon, $inprogress));
// Feature disabled: the score is shown without a feedback link.
$aqoff = $aqgenerator->create_instance(['course' => $course->id, 'competencyfeedbackenable' => 0]);
$tableoff = new user_attempts_overview($renderer);
$tableoff->init($baseurl, $aqoff, $student->id);
$this->assertStringNotContainsString('feedback.php', $this->call_col_measure($tableoff, $completed));
}
/**
* Reads the protected columns list of the table.
*
* @param user_attempts_overview $table
* @return array
*/
private function columns_of(user_attempts_overview $table): array {
$property = (new ReflectionClass($table))->getProperty('columns');
$property->setAccessible(true);
return $property->getValue($table);
}
/**
* Invokes the protected col_review() formatter.
*
* @param user_attempts_overview $table
* @param stdClass $row
* @return string
*/
private function call_col_review(user_attempts_overview $table, stdClass $row): string {
$method = (new ReflectionClass($table))->getMethod('col_review');
$method->setAccessible(true);
return $method->invoke($table, $row);
}
/**
* Invokes the protected col_measure() formatter.
*
* @param user_attempts_overview $table
* @param stdClass $row
* @return string
*/
private function call_col_measure(user_attempts_overview $table, stdClass $row): string {
$method = (new ReflectionClass($table))->getMethod('col_measure');
$method->setAccessible(true);
return $method->invoke($table, $row);
}
}
<?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 mod_adaptivequiz;
use advanced_testcase;
use xmldb_field;
use xmldb_table;
use PHPUnit\Framework\Attributes\CoversNothing;
/**
* KNIGHT (Feature 3): the 2026062509 upgrade widens the existing questionschecked column from the
* boolean (int/1) used by the KNIGHT 4.x line to an int/10 counter. This verifies the widen preserves
* the stored value, i.e. the in-place migration from a production 404_KNIGHT database is lossless.
*
* @package mod_adaptivequiz
* @copyright 2026 KNIGHT, Hochschule fuer Technik Stuttgart
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversNothing]
final class questionschecked_widen_test extends advanced_testcase {
/**
* Narrowing questionschecked to the 4.x boolean width and widening it back (as the upgrade does)
* must keep the stored value and then accept counts beyond the old boolean range.
*/
public function test_widening_questionschecked_preserves_value(): void {
global $DB;
$this->resetAfterTest();
$dbman = $DB->get_manager();
$table = new xmldb_table('adaptivequiz');
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$adaptivequiz = $aqgenerator->create_instance(['course' => $course->id]);
// Simulate the KNIGHT 4.x schema: questionschecked is a boolean (int/1) that has been acknowledged.
$narrow = new xmldb_field('questionschecked', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0');
$dbman->change_field_precision($table, $narrow);
$DB->set_field('adaptivequiz', 'questionschecked', 1, ['id' => $adaptivequiz->id]);
// Perform the in-place widen the 2026062509 upgrade step does for the 404_KNIGHT line.
$wide = new xmldb_field('questionschecked', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
$dbman->change_field_precision($table, $wide);
// The value survives the widen, and the column now stores a count beyond the old boolean width.
$this->assertEquals(1, $DB->get_field('adaptivequiz', 'questionschecked', ['id' => $adaptivequiz->id]));
$DB->set_field('adaptivequiz', 'questionschecked', 42, ['id' => $adaptivequiz->id]);
$this->assertEquals(42, $DB->get_field('adaptivequiz', 'questionschecked', ['id' => $adaptivequiz->id]));
$this->assertArrayHasKey('questionschecked', $DB->get_columns('adaptivequiz', false));
}
}
<?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 mod_adaptivequiz;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot.'/mod/adaptivequiz/locallib.php');
require_once($CFG->dirroot.'/mod/adaptivequiz/renderer.php');
require_once($CFG->dirroot.'/tag/lib.php');
use advanced_testcase;
use context_module;
use mod_adaptivequiz\local\attempt\attempt_state;
use mod_adaptivequiz_renderer;
use moodle_page;
use moodle_url;
use PHPUnit\Framework\Attributes\CoversClass;
use stdClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2013 Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz_renderer::class)]
class renderer_test extends advanced_testcase {
/**
* This function tests the output from the get_js_module.
*/
public function test_adaptivequiz_get_js_module(): void {
$dummypage = new moodle_page();
$target = 'mod_adaptivequiz';
$renderer = new mod_adaptivequiz_renderer($dummypage, $target);
$output = $renderer->adaptivequiz_get_js_module();
$this->assertArrayHasKey('name', $output);
$this->assertContains('mod_adaptivequiz', $output);
$this->assertArrayHasKey('fullpath', $output);
$this->assertContains('/mod/adaptivequiz/module.js', $output);
$this->assertArrayHasKey('requires', $output);
$this->assertEquals(
['base', 'dom', 'event-delegate', 'event-key', 'core_question_engine', 'moodle-core-formchangechecker'],
$output['requires']
);
$this->assertArrayHasKey('strings', $output);
}
/**
* This function tests how init_metadata() handlss an integer
*/
public function test_init_metadata_with_integer() {
$dummypage = new moodle_page();
$target = 'mod_adaptivequiz';
$renderer = new mod_adaptivequiz_renderer($dummypage, $target);
$mockquba = $this->createPartialMock('question_usage_by_activity', ['render_question_head_html']);
$mockquba->expects($this->once())
->method('render_question_head_html')
->willReturn('');
// Only testing that the mock object's method is called once.
$renderer->init_metadata($mockquba, 1);
}
/**
* This function tests the output from print_form_and_button()
*/
public function test_print_form_and_button() {
$dummypage = new moodle_page();
$target = 'mod_adaptivequiz';
$renderer = new mod_adaptivequiz_renderer($dummypage, $target);
$url = new moodle_url('/test/phpunittest/test.php', array('cmid' => 99));
$text = 'phpunit test button';
$output = $renderer->print_form_and_button($url, $text);
$this->assertStringContainsString('<form', $output);
$this->assertStringContainsString('<input', $output);
$this->assertStringContainsString('type="submit"', $output);
$this->assertStringContainsString('/test/phpunittest/test.php', $output);
$this->assertStringContainsString('cmid=99', $output);
$this->assertStringContainsString('phpunit test button', $output);
$this->assertStringContainsString('<center>', $output);
$this->assertStringContainsString('</center>', $output);
$this->assertStringContainsString('</form>', $output);
}
/**
* KNIGHT (Feature 5): the current question's difficulty level is rendered only when the
* showquestiondifficultylevel flag is on.
*/
public function test_question_submit_form_shows_difficulty_only_when_enabled(): void {
$dummypage = new moodle_page();
$renderer = new mod_adaptivequiz_renderer($dummypage, 'mod_adaptivequiz');
$mockquba = $this->createPartialMock('question_usage_by_activity', ['render_question', 'get_id']);
$mockquba->method('render_question')->willReturn('');
$mockquba->method('get_id')->willReturn(1);
$label = get_string('attemptquestion_difficulty_level', 'adaptivequiz');
// Enabled: the difficulty label and the level value are shown.
$enabled = $renderer->question_submit_form(99, $mockquba, 1, 7, 1, true);
$this->assertStringContainsString($label, $enabled);
$this->assertStringContainsString('difficulty-display', $enabled);
$this->assertStringContainsString('7', $enabled);
// Disabled (and default): the difficulty label is absent.
$disabled = $renderer->question_submit_form(99, $mockquba, 1, 7, 1, false);
$this->assertStringNotContainsString($label, $disabled);
$this->assertStringNotContainsString('difficulty-display', $disabled);
$default = $renderer->question_submit_form(99, $mockquba, 1, 7, 1);
$this->assertStringNotContainsString($label, $default);
}
/**
* KNIGHT (Feature 6): builds a graded (finished) truefalse question usage for feedback rendering.
*
* @param bool $answercorrectly Whether the recorded response is the correct one.
* @return array{0: \question_usage_by_activity, 1: int} The usage and the question's slot.
*/
private function create_finished_truefalse_usage(bool $answercorrectly): array {
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$qcat = $questiongenerator->create_question_category();
$question = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$quba = \question_engine::make_questions_usage_by_activity('mod_adaptivequiz', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$slot = $quba->add_question(\question_bank::load_question($question->id));
$quba->start_all_questions();
// The generated truefalse question's correct answer is 'True' (answer = 1).
$quba->process_action($slot, ['answer' => $answercorrectly ? 1 : 0]);
$quba->finish_all_questions();
return [$quba, $slot];
}
/**
* KNIGHT (Feature 6): builds a finished multiple-response question graded as partially correct
* (one of two correct options selected -> fraction 0.5). Shuffling is disabled so the option order
* is deterministic.
*
* @return array{0: \question_usage_by_activity, 1: int} The usage and the question's slot.
*/
private function create_finished_partial_multichoice_usage(): array {
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$qcat = $questiongenerator->create_question_category();
$question = $questiongenerator->create_question(
'multichoice',
'two_of_four',
['category' => $qcat->id, 'shuffleanswers' => 0]
);
$quba = \question_engine::make_questions_usage_by_activity('mod_adaptivequiz', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$slot = $quba->add_question(\question_bank::load_question($question->id));
$quba->start_all_questions();
// Select only the first correct option ("One", fraction 0.5) -> partially correct.
$quba->process_action($slot, ['choice0' => 1, 'choice1' => 0, 'choice2' => 0, 'choice3' => 0]);
$quba->finish_all_questions();
return [$quba, $slot];
}
/**
* KNIGHT (Feature 6): a partially correct answer is coloured green or red depending on whether it
* reaches the activity's acceptance threshold - i.e. whether it counted as correct for the quiz.
*/
public function test_question_feedback_colours_partial_by_acceptance_threshold(): void {
$this->resetAfterTest();
$renderer = new mod_adaptivequiz_renderer(new moodle_page(), 'mod_adaptivequiz');
[$quba, $slot] = $this->create_finished_partial_multichoice_usage();
// Sanity: the answer is graded as partially correct (fraction 0.5).
$this->assertEquals('partiallycorrect', $quba->get_question_state($slot)->get_feedback_class());
// Threshold below the achieved fraction: it counts as correct -> green, text still "Partially correct".
$counts = $renderer->question_feedback($quba, $slot, false, 0.4);
$this->assertStringContainsString('alert-success', $counts);
$this->assertStringNotContainsString('alert-danger', $counts);
$this->assertStringContainsString('Partially correct', $counts);
// Threshold above the achieved fraction: it counts as incorrect -> red.
$countsnot = $renderer->question_feedback($quba, $slot, false, 0.6);
$this->assertStringContainsString('alert-danger', $countsnot);
$this->assertStringNotContainsString('alert-success', $countsnot);
$this->assertStringContainsString('Partially correct', $countsnot);
}
/**
* KNIGHT (Feature 6): the immediate feedback shows only the feedback (and, on request, the correct
* answer) - never the question itself - and conveys correctness.
*/
public function test_question_feedback_shows_only_feedback_not_the_question(): void {
$this->resetAfterTest();
$renderer = new mod_adaptivequiz_renderer(new moodle_page(), 'mod_adaptivequiz');
$heading = get_string('immediatefeedbackheading', 'adaptivequiz');
// Correct answer, no solution: the question's feedback is shown, but neither the question
// formulation nor the correct-answer reveal.
[$quba, $slot] = $this->create_finished_truefalse_usage(true);
$nosolution = $renderer->question_feedback($quba, $slot, false);
$this->assertStringContainsString($heading, $nosolution);
$this->assertStringContainsString('alert-success', $nosolution);
$this->assertStringNotContainsString('alert-danger', $nosolution);
$this->assertStringContainsString('This is the right answer.', $nosolution);
$this->assertStringNotContainsString('formulation', $nosolution);
$this->assertStringNotContainsString('rightanswer', $nosolution);
// Solution requested: the correct answer is revealed, still without restating the question.
$withsolution = $renderer->question_feedback($quba, $slot, true);
$this->assertStringContainsString('rightanswer', $withsolution);
$this->assertStringNotContainsString('formulation', $withsolution);
// Wrong answer: the wrong-answer feedback is shown (correctness conveyed), still no question.
[$wrongquba, $wrongslot] = $this->create_finished_truefalse_usage(false);
$wrongfeedback = $renderer->question_feedback($wrongquba, $wrongslot, false);
$this->assertStringContainsString('alert-danger', $wrongfeedback);
$this->assertStringContainsString('This is the wrong answer.', $wrongfeedback);
$this->assertStringNotContainsString('formulation', $wrongfeedback);
}
/**
* This function tests the output from format_report_table_headers()
*/
public function test_format_report_table_headers() {
$dummypage = new moodle_page();
$target = 'mod_adaptivequiz';
$renderer = new mod_adaptivequiz_renderer($dummypage, $target);
$dummycm = new stdClass();
$dummycm->id = 99;
$output = $renderer->format_report_table_headers($dummycm, 'stderror', 'ASC');
$this->assertEquals(6, count($output));
$this->assertStringContainsString('/mod/adaptivequiz/viewreport.php', $output[0]);
$this->assertStringContainsString('sort=firstname&amp;sortdir=ASC', $output[0]);
$this->assertStringContainsString('sort=lastname&amp;sortdir=ASC', $output[0]);
$this->assertStringContainsString('/mod/adaptivequiz/viewreport.php', $output[1]);
$this->assertStringContainsString('sort=email&amp;sortdir=ASC', $output[1]);
$this->assertStringContainsString('/mod/adaptivequiz/viewreport.php', $output[2]);
$this->assertStringContainsString('sort=attempts&amp;sortdir=ASC', $output[2]);
$this->assertStringContainsString('/mod/adaptivequiz/viewreport.php', $output[3]);
$this->assertStringContainsString('sort=measure&amp;sortdir=ASC', $output[3]);
$this->assertStringContainsString('/mod/adaptivequiz/viewreport.php', $output[4]);
$this->assertStringContainsString('sort=stderror&amp;sortdir=DESC', $output[4]);
$this->assertStringContainsString('/mod/adaptivequiz/viewreport.php', $output[5]);
$this->assertStringContainsString('sort=timemodified&amp;sortdir=ASC', $output[5]);
}
/**
* KNIGHT: the close/delete attempt icons are managing actions, so they must only be shown to users
* who hold mod/adaptivequiz:manage - a view-only role (non-editing teacher) sees just the review icon.
*/
public function test_attempt_action_icons_respect_manage_capability(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$adaptivequiz = $aqgenerator->create_instance(['course' => $course->id]);
$context = context_module::instance($adaptivequiz->cmid);
$page = new moodle_page();
$page->set_context($context);
$renderer = new mod_adaptivequiz_renderer($page, 'mod_adaptivequiz');
$attempt = (object) ['id' => 123, 'attemptstate' => attempt_state::IN_PROGRESS];
// A non-editing teacher may view but not manage: only the review icon, no close/delete.
$this->setUser($generator->create_and_enrol($course, 'teacher'));
$viewonly = $renderer->individual_user_attempt_actions($attempt);
$this->assertStringContainsString('reviewattempt.php', $viewonly);
$this->assertStringNotContainsString('closeattempt.php', $viewonly);
$this->assertStringNotContainsString('delattempt.php', $viewonly);
// An editing teacher may manage: close and delete icons are present.
$this->setUser($generator->create_and_enrol($course, 'editingteacher'));
$full = $renderer->individual_user_attempt_actions($attempt);
$this->assertStringContainsString('closeattempt.php', $full);
$this->assertStringContainsString('delattempt.php', $full);
}
/**
* KNIGHT (Feature 2): the attempt review shows all tabs to teachers, but only the question details
* tab to a student reviewing their own attempt.
*/
public function test_attempt_review_tabs_limit_to_question_details_for_students(): void {
$this->resetAfterTest();
$page = new moodle_page();
$renderer = new mod_adaptivequiz_renderer($page, 'mod_adaptivequiz');
$url = new moodle_url('/mod/adaptivequiz/reviewattempt.php', ['attempt' => 1]);
$summary = get_string('reportattemptsummarytab', 'adaptivequiz');
$graph = get_string('reportattemptgraphtab', 'adaptivequiz');
$distribution = get_string('reportattemptanswerdistributiontab', 'adaptivequiz');
$details = get_string('reportattemptquestionsdetailstab', 'adaptivequiz');
// Teachers: all four tabs.
$full = $renderer->attempt_review_tabs($url, 'questionsdetails', true);
$this->assertStringContainsString($summary, $full);
$this->assertStringContainsString($graph, $full);
$this->assertStringContainsString($distribution, $full);
$this->assertStringContainsString($details, $full);
// Student: question details only.
$studenttabs = $renderer->attempt_review_tabs($url, 'questionsdetails', false);
$this->assertStringContainsString($details, $studenttabs);
$this->assertStringNotContainsString($summary, $studenttabs);
$this->assertStringNotContainsString($graph, $studenttabs);
$this->assertStringNotContainsString($distribution, $studenttabs);
}
}
<?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/>.
/**
* Plugin basic info.
*
* @package mod_adaptivequiz
* @copyright 2013 Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2026062517;
$plugin->release = '2.6.0dev-knight';
$plugin->maturity = MATURITY_ALPHA;
$plugin->requires = 2025041400;
$plugin->cron = 0;
$plugin->component = 'mod_adaptivequiz';
<?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/>.
/**
* Adaptive testing main view page script.
*
* @package mod_adaptivequiz
* @copyright 2013 Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__ . '/../../config.php');
require_once($CFG->libdir . '/tablelib.php');
require_once($CFG->dirroot.'/mod/adaptivequiz/locallib.php');
use core\output\notification;
use core_question\local\bank\question_bank_helper;
use mod_adaptivequiz\item_administration_params_helper;
use mod_adaptivequiz\item_bank;
use mod_adaptivequiz\local\report\questions_difficulty_range;
use mod_adaptivequiz\local\report\users_attempts\filter\filter;
use mod_adaptivequiz\local\report\users_attempts\filter\filter_form;
use mod_adaptivequiz\local\report\users_attempts\filter\filter_options;
use mod_adaptivequiz\local\report\users_attempts\user_preferences\filter_user_preferences;
use mod_adaptivequiz\local\report\users_attempts\users_attempts_table;
use mod_adaptivequiz\local\report\users_attempts\user_preferences\user_preferences_form;
use mod_adaptivequiz\local\report\users_attempts\user_preferences\user_preferences_repository;
use mod_adaptivequiz\local\report\users_attempts\user_preferences\user_preferences;
use mod_adaptivequiz\output\user_attempt_summary;
use mod_adaptivequiz\output\user_attempts_overview;
$id = optional_param('id', 0, PARAM_INT);
$downloadusersattempts = optional_param('download', '', PARAM_ALPHA);
$n = optional_param('n', 0, PARAM_INT);
$resetfilter = optional_param('resetfilter', 0, PARAM_INT);
if ($id) {
$cm = get_coursemodule_from_id('adaptivequiz', $id, 0, false, MUST_EXIST);
$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST);
$adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $cm->instance], '*', MUST_EXIST);
} else if ($n) {
$adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $n], '*', MUST_EXIST);
$course = $DB->get_record('course', ['id' => $adaptivequiz->course], '*', MUST_EXIST);
$cm = get_coursemodule_from_instance('adaptivequiz', $adaptivequiz->id, $course->id, false, MUST_EXIST);
} else {
throw new moodle_exception('invalidarguments');
}
require_login($course, true, $cm);
$context = context_module::instance($cm->id);
$PAGE->set_url('/mod/adaptivequiz/view.php', ['id' => $cm->id]);
$PAGE->set_context($context);
$PAGE->add_body_class('limitedwidth');
/** @var mod_adaptivequiz_renderer $renderer */
$renderer = $PAGE->get_renderer('mod_adaptivequiz');
$canviewattemptsreport = has_capability('mod/adaptivequiz:viewreport', $context);
if ($canviewattemptsreport) {
$reportuserprefs = user_preferences_repository::get();
$reportuserprefsform = new user_preferences_form($PAGE->url->out());
if ($prefsformdata = $reportuserprefsform->get_data()) {
$reportuserprefs = user_preferences::from_plain_object($prefsformdata);
if (!$reportuserprefs->persistent_filter() && $reportuserprefs->has_filter_preference()) {
$reportuserprefs = $reportuserprefs->without_filter_preference();
}
user_preferences_repository::save($reportuserprefs);
}
$reportuserprefsform->set_data($reportuserprefs->as_array());
$filter = filter::from_vars($adaptivequiz->id, groups_get_activity_group($cm, true));
if ($resetfilter) {
$filter->fill_from_array(['users' => filter_options::users_option_default(),
'includeinactiveenrolments' => filter_options::INCLUDE_INACTIVE_ENROLMENTS_DEFAULT]);
}
$reportfilterform = new filter_form($PAGE->url->out(), ['actionurl' => $PAGE->url]);
if ($reportuserprefs->persistent_filter() && $reportuserprefs->has_filter_preference()) {
$filter->fill_from_preference($reportuserprefs->filter());
$reportfilterform->set_data($reportuserprefs->filter()->as_array());
}
if ($resetfilter) {
$filterdefaultsarray = ['users' => filter_options::users_option_default(),
'includeinactiveenrolments' => filter_options::INCLUDE_INACTIVE_ENROLMENTS_DEFAULT];
$filter->fill_from_array($filterdefaultsarray);
if ($reportuserprefs->persistent_filter()) {
user_preferences_repository::save(
$reportuserprefs->with_filter_preference(filter_user_preferences::from_array($filterdefaultsarray))
);
}
$reportfilterform->set_data($filterdefaultsarray);
}
if ($filterformdata = $reportfilterform->get_data()) {
$filter->fill_from_array((array) $filterformdata);
if ($reportuserprefs->persistent_filter()) {
user_preferences_repository::save(
$reportuserprefs->with_filter_preference(filter_user_preferences::from_array((array) $filterformdata))
);
}
}
$attemptsreporttable = new users_attempts_table($renderer, $cm->id,
questions_difficulty_range::from_activity_instance($adaptivequiz), $PAGE->url, $context, $filter);
$attemptsreporttable->is_downloading($downloadusersattempts,
get_string('reportattemptsdownloadfilename', 'adaptivequiz', format_string($adaptivequiz->name)));
if ($attemptsreporttable->is_downloading()) {
$attemptsreporttable->out(1, false);
exit;
}
}
$event = \mod_adaptivequiz\event\course_module_viewed::create([
'objectid' => $PAGE->cm->instance,
'context' => $PAGE->context,
]);
$event->add_record_snapshot('course', $PAGE->course);
$event->add_record_snapshot($PAGE->cm->modname, $adaptivequiz);
$event->trigger();
$PAGE->set_title(format_string($adaptivequiz->name));
$PAGE->set_heading(format_string($course->fullname));
$hasqbanks = false;
$hasitemadmparams = false;
$qbankmigrated = question_bank_helper::has_bank_migration_task_completed_successfully();
if ($qbankmigrated) {
$hasqbanks = item_bank::adaptive_quiz_instance_has_question_banks_or_categories_linked($adaptivequiz->id);
$hasitemadmparams = item_administration_params_helper::is_all_valid_for_adaptivequiz($adaptivequiz);
}
$itembankconfigured = $hasqbanks && $hasitemadmparams;
echo $OUTPUT->header();
if ($canviewattemptsreport) {
if (!$qbankmigrated) {
$defaultqbankmod = question_bank_helper::get_default_question_bank_activity_name();
echo $renderer->notification(
message: get_string('transfernotfinished', 'mod_' . $defaultqbankmod),
type: notification::NOTIFY_WARNING,
closebutton: false
);
}
if ($qbankmigrated && !$itembankconfigured) {
echo $renderer->notification(
message: get_string('itembanknotconfiguredinfomanager', 'adaptivequiz'),
type: notification::NOTIFY_WARNING,
closebutton: false
);
}
// KNIGHT (Feature 3): remind teachers to review the question analysis once enough attempts have
// accumulated and the reminder has not yet been acknowledged.
if (adaptivequiz_question_check_reminder_due($adaptivequiz, adaptivequiz_count_completed_attempts($adaptivequiz->id))) {
echo $renderer->notification(
message: get_string('questioncheckmessage', 'adaptivequiz'),
type: notification::NOTIFY_INFO,
closebutton: false
);
}
}
if (has_capability('mod/adaptivequiz:attempt', $context)) {
$attemptallowedbycount = false;
if ($qbankmigrated && $itembankconfigured) {
// Counting attempts makes sense only when item bank is verified.
$completedattemptscount = adaptivequiz_count_user_previous_attempts($adaptivequiz->id, $USER->id);
$attemptallowedbycount = adaptivequiz_allowed_attempt($adaptivequiz->attempts, $completedattemptscount);
}
$attempturl = null;
$attemptnotification = '';
if (!$qbankmigrated) {
// Managers have their own message in case question bank tasks haven't completed.
if (!$canviewattemptsreport) {
$attemptnotification = get_string('itembanknotconfiguredinfostudent', 'adaptivequiz');
}
}
if ($qbankmigrated) {
if ($itembankconfigured && $attemptallowedbycount) {
$attempturl = new moodle_url('/mod/adaptivequiz/attempt.php', ['cmid' => $cm->id]);
}
if ($itembankconfigured && !$attemptallowedbycount) {
$attemptnotification = get_string('noattemptsallowed', 'adaptivequiz');
}
if (!$itembankconfigured) {
// Managers have their own message in case item bank is not configured.
if (!$canviewattemptsreport) {
$attemptnotification = get_string('itembanknotconfiguredinfostudent', 'adaptivequiz');
}
}
}
echo $renderer->start_attempt($attempturl, $attemptnotification);
$allattemptscount = $DB->count_records('adaptivequiz_attempt',
['instance' => $adaptivequiz->id, 'userid' => $USER->id]);
// KNIGHT (Feature 2): when students may review their own attempts, the attempts overview (which
// carries the review link) is shown even for single-attempt activities, which makes the separate
// per-attempt summary redundant - so show the summary only when the overview is not shown.
$showoverview = $allattemptscount && ($adaptivequiz->attempts != 1 || !empty($adaptivequiz->showownattemptresult));
if ($allattemptscount && $adaptivequiz->attempts == 1 && !$showoverview) {
$sql = 'SELECT id, attemptstate, measure, timemodified
FROM {adaptivequiz_attempt}
WHERE instance = ? AND userid = ?
ORDER BY timemodified DESC';
if ($userattempts = $DB->get_records_sql($sql, [$adaptivequiz->id, $USER->id], 0, 1)) {
$userattempt = $userattempts[array_key_first($userattempts)];
echo $renderer->heading(get_string('attempt_summary', 'adaptivequiz'), 3, 'text-center');
echo $renderer->render(new user_attempt_summary($adaptivequiz, $userattempt));
}
}
if ($showoverview) {
// Use a singular heading when the activity allows only one attempt.
$headingkey = $adaptivequiz->attempts == 1 ? 'attemptuserprevious' : 'attemptsuserprevious';
echo $renderer->heading(get_string($headingkey, 'adaptivequiz'), 3);
$attemptstable = new user_attempts_overview($renderer);
$attemptstable->init($PAGE->url, $adaptivequiz, $USER->id);
$attemptstable->out(10, false);
}
if (!$allattemptscount) {
echo html_writer::div(get_string('attemptsusernoprevious', 'adaptivequiz'), 'alert alert-info text-center');
}
}
if ($canviewattemptsreport) {
echo $renderer->heading(get_string('activityreports', 'adaptivequiz'), '3', 'text-center');
groups_print_activity_menu($cm, new moodle_url('/mod/adaptivequiz/view.php', ['id' => $cm->id]));
echo $renderer->container_start('usersattemptstable-wrapper');
$attemptsreporttable->out($reportuserprefs->rows_per_page(), $reportuserprefs->show_initials_bar());
echo $renderer->container_end();
$reportuserprefsform->display();
$reportfilterform->display();
$resetfilterul = $PAGE->url;
$resetfilterul->param('resetfilter', 1);
echo $renderer->reset_users_attempts_filter_action($resetfilterul);
}
echo $OUTPUT->footer();
<?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/>.
/**
* Adaptive quiz view attempt report script.
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(dirname(__FILE__).'/../../config.php');
require_once($CFG->dirroot.'/mod/adaptivequiz/locallib.php');
use mod_adaptivequiz\local\report\individual_user_attempts\filter as user_attempts_table;
use mod_adaptivequiz\local\report\questions_difficulty_range;
use mod_adaptivequiz\local\report\individual_user_attempts\table as individual_user_attempts_table;
$id = required_param('cmid', PARAM_INT);
$userid = required_param('userid', PARAM_INT);
$cm = get_coursemodule_from_id('adaptivequiz', $id, 0, false, MUST_EXIST);
$adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $cm->instance], '*', MUST_EXIST);
$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST);
$user = $DB->get_record('user', ['id' => $userid], '*', MUST_EXIST);
require_login($course, true, $cm);
$context = context_module::instance($cm->id);
require_capability('mod/adaptivequiz:viewreport', $context);
$PAGE->set_context($context);
$PAGE->set_url('/mod/adaptivequiz/viewattemptreport.php', ['cmid' => $cm->id, 'userid' => $user->id]);
$a = new stdClass();
$a->quizname = format_string($adaptivequiz->name);
$a->username = fullname($user);
$title = get_string('reportindividualuserattemptpageheading', 'adaptivequiz', $a);
$PAGE->set_title($title);
$PAGE->set_heading(format_string($course->fullname));
/** @var mod_adaptivequiz_renderer $renderer */
$renderer = $PAGE->get_renderer('mod_adaptivequiz');
$header = $renderer->print_header();
$footer = $renderer->print_footer();
echo $header;
echo $renderer->heading($title);
$attemptstable = new individual_user_attempts_table(
$renderer,
user_attempts_table::from_vars($cm->instance, $user->id),
$PAGE->url,
questions_difficulty_range::from_activity_instance($adaptivequiz),
$cm->id
);
$attemptstable->out(20, false);
echo $footer;
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment