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
@mod @mod_adaptivequiz
Feature: View analysis of questions usage in adaptive quizzes
In order to assess how items from the question bank are administered and answered in adaptive quizzes
As a teacher
I need a report with analysis of questions usage
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | John | The Teacher | johntheteacher@example.com |
| student1 | Peter | The Student | peterthestudent@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | course | idnumber |
| qbank | Qbank 1 | C1 | qbank1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Activity module | qbank1 | Adaptive Quiz Questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | answer |
| Adaptive Quiz Questions | truefalse | TF1 | First question | True |
| Adaptive Quiz Questions | truefalse | TF2 | Second question | True |
And the following "core_question > Tags" exist:
| question | tag |
| TF1 | adpq_1 |
| TF2 | adpq_2 |
And the following "activity" exists:
| activity | adaptivequiz |
| idnumber | adaptivequiz1 |
| course | C1 |
| name | Adaptive Quiz |
| startinglevel | 1 |
| lowestlevel | 1 |
| highestlevel | 2 |
| minimumquestions | 1 |
| maximumquestions | 2 |
| standarderror | 5 |
| questionpoolnamed | Adaptive Quiz Questions |
And the following "mod_adaptivequiz > links with question banks" exist:
| adaptivequiz | idnumber |
| Adaptive Quiz | qbank1 |
And I am on the "adaptivequiz1" "Activity" page logged in as "student1"
And I click on "Start attempt" "button"
And I click on "True" "radio" in the "First question" "question"
And I press "Submit answer"
And I click on "True" "radio" in the "Second question" "question"
And I press "Submit answer"
And I press "Continue"
And I log out
@javascript
Scenario: Navigate to reviewing of an attempt listed in single question analysis
When I am on the "adaptivequiz1" "Activity" page logged in as "teacher1"
And I click on "Question Analysis" "link"
And I click on "TF1" "link"
And I click on "Review attempt" "link" in the "Peter The Student" "table_row"
Then I should see "Adaptive Quiz - reviewing attempt by Peter The Student"
@mod @mod_adaptivequiz
Feature: View students results in adaptive quiz
In order to control what results students have on attempting adaptive quizzes
As a teacher
I need an access to attempts reporting
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | John | The Teacher | johntheteacher@example.com |
| student1 | Peter | The Student | peterthestudent@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | course | idnumber |
| qbank | Qbank 1 | C1 | qbank1 |
And the following "question categories" exist:
| contextlevel | reference | name |
| Activity module | qbank1 | Adaptive Quiz Questions |
And the following "questions" exist:
| questioncategory | qtype | name | questiontext | answer |
| Adaptive Quiz Questions | truefalse | TF1 | First question | True |
| Adaptive Quiz Questions | truefalse | TF2 | Second question | True |
And the following "core_question > Tags" exist:
| question | tag |
| TF1 | adpq_2 |
| TF2 | adpq_3 |
And the following "activity" exists:
| activity | adaptivequiz |
| idnumber | adaptivequiz1 |
| course | C1 |
| name | Adaptive Quiz |
| startinglevel | 2 |
| lowestlevel | 2 |
| highestlevel | 3 |
| minimumquestions | 2 |
| maximumquestions | 20 |
| standarderror | 5 |
| questionpoolnamed | Adaptive Quiz Questions |
And the following "mod_adaptivequiz > links with question banks" exist:
| adaptivequiz | idnumber |
| Adaptive Quiz | qbank1 |
And I am on the "adaptivequiz1" "Activity" page logged in as "student1"
And I click on "Start attempt" "button"
And I click on "True" "radio" in the "First question" "question"
And I press "Submit answer"
And I click on "True" "radio" in the "Second question" "question"
And I press "Submit answer"
And I press "Continue"
And I log out
@javascript
Scenario: Attempts report
When I am on the "adaptivequiz1" "Activity" page logged in as "teacher1"
Then I should see "Attempts report"
And "Peter The Student" "table_row" should exist
And "Peter The Student" row "Number of attempts" column of "usersattemptstable" table should contain "1"
@javascript
Scenario: Deleted user should not appear in the attempts report
Given the following "user" exists:
| username | student2 |
| firstname | Henry |
| lastname | The Student |
| email | henrythestudent@example.com |
And the following "course enrolment" exists:
| user | student2 |
| course | C1 |
| role | student |
And I am on the "adaptivequiz1" "Activity" page logged in as "student2"
And I click on "Start attempt" "button"
And I click on "True" "radio" in the "First question" "question"
And I press "Submit answer"
And I click on "True" "radio" in the "Second question" "question"
And I press "Submit answer"
And I press "Continue"
And I log out
And I log in as "admin"
And I navigate to "Users > Accounts > Bulk user actions" in site administration
And I set the field "Available" to "Henry The Student"
And I press "Add to selection"
And I set the field "id_action" to "Delete"
And I press "Go"
And I press "Yes"
And I log out
When I am on the "adaptivequiz1" "Activity" page logged in as "teacher1"
Then "Henry The Student" "table_row" should not exist
@javascript
Scenario: Individual user attempts report
When I am on the "adaptivequiz1" "Activity" page logged in as "teacher1"
And I click on "1" "link" in the "Peter The Student" "table_row"
Then I should see "Adaptive Quiz - individual user attempts report for Peter The Student"
And "Completed" "table_row" should exist
And "Completed" row "Reason for stopping attempt" column of "individualuserattemptstable" table should contain "Unable to fetch a question for level 3"
And "Completed" row "Sum of questions attempted" column of "individualuserattemptstable" table should contain "2"
@javascript
Scenario: View attempt summary
When I am on the "adaptivequiz1" "Activity" page logged in as "teacher1"
And I click on "1" "link" in the "Peter The Student" "table_row"
And I click on "Review attempt" "link" in the "Completed" "table_row"
Then I should see "Peter The Student (peterthestudent@example.com)" in the "User" "table_row"
@javascript
Scenario: View attempt questions details
When I am on the "adaptivequiz1" "Activity" page logged in as "teacher1"
And I click on "1" "link" in the "Peter The Student" "table_row"
And I click on "Review attempt" "link" in the "Completed" "table_row"
And I click on "Questions Details" "link"
# Info on the first question
Then I should see "Correct" in the "[id^=question-][id$=-1] .info .state" "css_element"
# Info on the second question
And I should see "Correct" in the "[id^=question-][id$=-2] .info .state" "css_element"
<?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\completion;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/adaptivequiz/locallib.php');
use advanced_testcase;
use cm_info;
use context_module;
use mod_adaptivequiz\local\attempt;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2022 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\completion\custom_completion::class)]
final class custom_completion_test extends advanced_testcase {
public function test_it_defines_completion_state_based_on_attempt_completion(): void {
global $DB;
$this->resetAfterTest();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$modgenerator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$course = $this->getDataGenerator()->create_course([
'enablecompletion' => 1,
]);
$questioncategory = $questiongenerator->create_question_category([
'name' => 'My category',
]);
$question = $questiongenerator->create_question('shortanswer', null, [
'category' => $questioncategory->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,
'questionpool' => [
$questioncategory->id,
],
'completion' => COMPLETION_TRACKING_AUTOMATIC,
'completionattemptcompleted' => 1,
]);
$user = $this->getDataGenerator()->create_user();
// End of setup.
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$attempt = new attempt($adaptivequizforattempt, $user->id);
$cm = get_coursemodule_from_id(modulename: 'adaptivequiz', cmid: $adaptivequiz->cmid, courseid: 0, sectionnum: false,
strictness: MUST_EXIST);
$cminfo = cm_info::create($cm);
$completion = new custom_completion($cminfo, $user->id);
$this->assertEquals(COMPLETION_INCOMPLETE, $completion->get_state('completionattemptcompleted'));
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$attemptrecord = $attempt->get_attempt();
$attemptuniqueid = $DB->get_field('adaptivequiz_attempt', 'uniqueid', ['id' => $attemptrecord->id], MUST_EXIST);
adaptivequiz_complete_attempt($attemptuniqueid, $adaptivequiz, $context, $user->id, '1', 'php unit test');
$this->assertEquals(COMPLETION_COMPLETE, $completion->get_state('completionattemptcompleted'));
}
}
<?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\external;
use basic_testcase;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* Testing exporting of the ability measure data.
*
* @package mod_adaptivequiz
* @copyright 2025 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\external\ability_measure_exporter::class)]
final class ability_measure_exporter_test extends basic_testcase {
public function test_it_exports_the_required_data(): void {
global $PAGE;
$attempt = (object) [
'measure' => 3.42429,
];
$adaptivequiz = (object) [
'minimumquestions' => 25,
'maximumquestions' => 10,
'highestlevel' => 15,
'lowestlevel' => 1,
];
$exporter = new ability_measure_exporter([
'highestlevel' => $adaptivequiz->highestlevel,
'lowestlevel' => $adaptivequiz->lowestlevel,
], [
'attempt' => $attempt,
]);
$exported = $exporter->export($PAGE->get_renderer('core'));
self::assertEquals((object) [
'abilitymeasurevalue' => 14.56,
'highestlevel' => 15,
'lowestlevel' => 1,
], $exported);
}
}
<?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\external;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/../../../../webservice/tests/helpers.php');
use context_module;
use externallib_advanced_testcase;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2026 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\external\search_question_banks::class)]
class search_question_banks_test extends externallib_advanced_testcase {
public function test_it_returns_qbanks_for_assignment_in_adaptive_quiz_instance(): void {
global $SITE;
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course1 = $coregenerator->create_course(['shortname' => 'c1']);
$course2 = $coregenerator->create_course(['shortname' => 'c2']);
$course3 = $coregenerator->create_course(['shortname' => 'c3']);
$qbank1 = $qbankgenerator->create_instance(
['course' => $course1->id, 'name' => 'This Course Question Bank']
);
$qbank2 = $qbankgenerator->create_instance(
['course' => $course2->id, 'name' => 'Another Course Question Bank 1']
);
$qbank3 = $qbankgenerator->create_instance(
['course' => $SITE->id, 'name' => 'System Question Bank']
);
$qbank4 = $qbankgenerator->create_instance(
['course' => $course3->id, 'name' => 'Another Course Question Bank 2']
);
$adaptivequiz = $adaptivequizgenerator->create_instance(['course' => $course1->id]);
$cm = get_coursemodule_from_instance(
modulename: 'adaptivequiz',
instance: $adaptivequiz->id,
strictness: MUST_EXIST
);
$context = context_module::instance($cm->id);
$manager = $coregenerator->create_user();
$coregenerator->enrol_user($manager->id, $course1->id);
self::setUser($manager);
// End of setup.
// Test it returns everything from everywhere.
$result = search_question_banks::execute($context->id);
self::assertArrayHasKey('questionbanks', $result);
self::assertCount(4, $result['questionbanks']);
foreach ($result['questionbanks'] as $resultitem) {
self::assertArrayHasKey('value', $resultitem);
self::assertArrayHasKey('label', $resultitem);
}
// Test it returns what's in the given course only.
$result = search_question_banks::execute(contextid: $context->id, incourseid: $course1->id);
self::assertArrayHasKey('questionbanks', $result);
self::assertCount(1, $result['questionbanks']);
self::assertArrayHasKey('label', $result['questionbanks'][0]);
self::assertStringContainsString('This Course Question Bank', $result['questionbanks'][0]['label']);
// Test it returns what's in other courses only.
$result = search_question_banks::execute(contextid: $context->id, notincourseid: $course1->id);
self::assertArrayHasKey('questionbanks', $result);
self::assertEqualsCanonicalizing(
[
['value' => $qbank2->id, 'label' => "$course2->shortname - $qbank2->name"],
['value' => $qbank3->id, 'label' => "$SITE->shortname - $qbank3->name"],
['value' => $qbank4->id, 'label' => "$course3->shortname - $qbank4->name"],
],
$result['questionbanks']);
// Test it filters by a search term.
$result = search_question_banks::execute(contextid: $context->id, notincourseid: $course1->id, search: 'sys');
self::assertCount(1, $result['questionbanks']);
self::assertArrayHasKey('label', $result['questionbanks'][0]);
self::assertStringContainsString('System Question Bank', $result['questionbanks'][0]['label']);
}
public function test_it_will_not_return_qbanks_already_assigned_to_the_adaptive_quiz_instance(): void {
global $SITE;
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course1 = $coregenerator->create_course(['shortname' => 'c1']);
$course2 = $coregenerator->create_course(['shortname' => 'c2']);
$course3 = $coregenerator->create_course(['shortname' => 'c3']);
$qbank1 = $qbankgenerator->create_instance(
['course' => $course1->id, 'name' => 'This Course Question Bank 1']
);
$qbank2 = $qbankgenerator->create_instance(
['course' => $course1->id, 'name' => 'This Course Question Bank 2']
);
$qbank3 = $qbankgenerator->create_instance(
['course' => $course2->id, 'name' => 'Another Course Question Bank 1']
);
$qbank4 = $qbankgenerator->create_instance(
['course' => $SITE->id, 'name' => 'System Question Bank']
);
$qbank5 = $qbankgenerator->create_instance(
['course' => $course3->id, 'name' => 'Another Course Question Bank 2']
);
$adaptivequiz = $adaptivequizgenerator->create_instance(['course' => $course1->id]);
foreach ([$qbank1->id, $qbank4->id, $qbank5->id] as $qbankid) {
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbankid,
]);
}
$cm = get_coursemodule_from_instance(
modulename: 'adaptivequiz',
instance: $adaptivequiz->id,
strictness: MUST_EXIST
);
$context = context_module::instance($cm->id);
$manager = $coregenerator->create_user();
$coregenerator->enrol_user($manager->id, $course1->id);
self::setUser($manager);
// End of setup.
$result = search_question_banks::execute($context->id, incourseid: $course1->id, search: 'question');
self::assertArrayHasKey('questionbanks', $result);
self::assertCount(1, $result['questionbanks']);
self::assertArrayHasKey('label', $result['questionbanks'][0]);
self::assertStringContainsString('This Course Question Bank 2', $result['questionbanks'][0]['label']);
$result = search_question_banks::execute($context->id, notincourseid: $course1->id, search: 'question');
self::assertArrayHasKey('questionbanks', $result);
self::assertCount(1, $result['questionbanks']);
self::assertArrayHasKey('label', $result['questionbanks'][0]);
self::assertStringContainsString('Another Course Question Bank 1', $result['questionbanks'][0]['label']);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!-- Tests no logs - Only query 3 should be processed -->
<dataset>
<table name="adaptivequiz_question">
<column>id</column>
<column>instance</column>
<column>questioncategory</column>
<row>
<value>1</value>
<value>12</value>
<value>2</value>
</row>
<row>
<value>2</value>
<value>12</value>
<value>4</value>
</row>
<row>
<value>3</value>
<value>12</value>
<value>6</value>
</row>
<row>
<value>4</value>
<value>12</value>
<value>8</value>
</row>
<row>
<value>5</value>
<value>12</value>
<value>10</value>
</row>
<row>
<value>6</value>
<value>12</value>
<value>12</value>
</row>
<row>
<value>7</value>
<value>13</value>
<value>1</value>
</row>
</table>
<table name="modules">
<column>id</column>
<column>name</column>
<row>
<value>50</value>
<value>adaptivequiz</value>
</row>
</table>
<table name="course">
<column>id</column>
<column>category</column>
<column>fullname</column>
<column>shortname</column>
<row>
<value>2</value>
<value>1</value>
<value>101</value>
<value>101</value>
</row>
</table>
<table name="course_sections">
<column>id</column>
<column>course</column>
<column>section</column>
<column>sequence</column>
<row>
<value>10</value>
<value>2</value>
<value>1</value>
<value>5,6</value>
</row>
</table>
<table name="course_modules">
<column>id</column>
<column>course</column>
<column>module</column>
<column>instance</column>
<column>section</column>
<row>
<value>5</value>
<value>2</value>
<value>50</value>
<value>13</value>
<value>10</value>
</row>
<row>
<value>6</value>
<value>2</value>
<value>50</value>
<value>1</value>
<value>10</value>
</row>
</table>
<table name="adaptivequiz">
<column>id</column>
<column>course</column>
<column>name</column>
<column>intro</column>
<column>attemptfeedback</column>
<column>highestlevel</column>
<column>lowestlevel</column>
<column>minimumquestions</column>
<column>maximumquestions</column>
<column>standarderror</column>
<column>startinglevel</column>
<row>
<value>1</value>
<value>2</value>
<value>Test re-using quba</value>
<value>intro asdf</value>
<value>attempt feedback</value>
<value>100</value>
<value>1</value>
<value>15</value>
<value>20</value>
<value>1.0</value>
<value>50</value>
</row>
<row>
<value>13</value>
<value>2</value>
<value>Test min_attempts_reached</value>
<value>intro asdf</value>
<value>attempt feedback</value>
<value>100</value>
<value>1</value>
<value>15</value>
<value>20</value>
<value>1.0</value>
<value>50</value>
</row>
<row>
<value>330</value>
<value>2</value>
<value>Test re-using quba</value>
<value>intro asdf</value>
<value>attempt feedback</value>
<value>100</value>
<value>1</value>
<value>15</value>
<value>20</value>
<value>1.0</value>
<value>50</value>
</row>
</table>
<table name="adaptivequiz_attempt">
<column>id</column>
<column>instance</column>
<column>userid</column>
<column>uniqueid</column>
<column>attemptstate</column>
<column>questionsattempted</column>
<column>standarderror</column>
<column>difficultysum</column>
<row>
<value>1</value>
<value>1</value>
<value>2</value>
<value>3</value>
<value>inprogress</value>
<value>0</value>
<value>1</value>
<value>1</value>
</row>
<row>
<value>2</value>
<value>13</value>
<value>3</value>
<value>3</value>
<value>complete</value>
<value>0</value>
<value>1</value>
<value>1</value>
</row>
<row>
<value>3</value>
<value>13</value>
<value>4</value>
<value>4</value>
<value>complete</value>
<value>16</value>
<value>1</value>
<value>1</value>
</row>
<row>
<value>5</value>
<value>330</value>
<value>2</value>
<value>330</value>
<value>inprogress</value>
<value>0</value>
<value>1</value>
<value>1</value>
</row>
</table>
<table name="question_usages">
<column>id</column>
<column>contextid</column>
<column>component</column>
<column>preferredbehaviour</column>
<row>
<value>330</value>
<value>330</value>
<value>mod_adaptivequiz</value>
<value>immediatefeedback</value>
</row>
</table>
<table name="context">
<column>id</column>
<column>contextlevel</column>
<column>instanceid</column>
<column>path</column>
<column>depth</column>
<row>
<value>16</value>
<value>50</value>
<value>2</value>
<value>/1/3/16</value>
<value>3</value>
</row>
<row>
<value>330</value>
<value>70</value>
<value>5</value>
<value>/1/3/16/330</value>
<value>4</value>
</row>
</table>
<table name="question_attempts">
<column>id</column>
<column>questionusageid</column>
<column>slot</column>
<column>questionid</column>
<column>maxmark</column>
<column>minfraction</column>
<column>rightanswer</column>
<column>timemodified</column>
<row>
<value>1</value>
<value>330</value>
<value>1</value>
<value>1</value>
<value>1.0</value>
<value>0.0</value>
<value>true</value>
<value>0</value>
</row>
<row>
<value>2</value>
<value>330</value>
<value>2</value>
<value>2</value>
<value>1.0</value>
<value>0.0</value>
<value>answer 1 (correct)</value>
<value>0</value>
</row>
</table>
<table name="question_attempt_steps">
<column>id</column>
<column>questionattemptid</column>
<column>sequencenumber</column>
<column>state</column>
<column>timecreated</column>
<column>userid</column>
<row>
<value>1</value>
<value>1</value>
<value>0</value>
<value>todo</value>
<value>0</value>
<value>2</value>
</row>
<row>
<value>2</value>
<value>1</value>
<value>1</value>
<value>gradedwrong</value>
<value>1</value>
<value>2</value>
</row>
<row>
<value>3</value>
<value>2</value>
<value>0</value>
<value>complete</value>
<value>1</value>
<value>2</value>
</row>
</table>
</dataset>
<?xml version="1.0" encoding="UTF-8"?>
<dataset>
<table name="context">
<column>id</column>
<column>contextlevel</column>
<column>instanceid</column>
<column>path</column>
<column>depth</column>
<row>
<value>16</value>
<value>50</value>
<value>200</value>
<value>/1/3/16</value>
<value>3</value>
</row>
<row>
<value>330</value>
<value>70</value>
<value>5</value>
<value>/1/3/16/330</value>
<value>4</value>
</row>
</table>
<table name="modules">
<column>id</column>
<column>name</column>
<row>
<value>50</value>
<value>adaptivequiz</value>
</row>
</table>
<table name="course">
<column>id</column>
<column>category</column>
<column>fullname</column>
<column>shortname</column>
<row>
<value>200</value>
<value>1</value>
<value>101</value>
<value>101</value>
</row>
</table>
<table name="course_sections">
<column>id</column>
<column>course</column>
<column>section</column>
<column>sequence</column>
<row>
<value>10</value>
<value>200</value>
<value>1</value>
<value>5</value>
</row>
</table>
<table name="course_modules">
<column>id</column>
<column>course</column>
<column>module</column>
<column>instance</column>
<column>section</column>
<column>completion</column>
<row>
<value>5</value>
<value>200</value>
<value>50</value>
<value>330</value>
<value>10</value>
<value>2</value>
</row>
</table>
<table name="question_categories">
<column>id</column>
<column>name</column>
<column>contextid</column>
<column>info</column>
<row>
<value>1</value>
<value>Default for 101</value>
<value>16</value>
<value>default question category</value>
</row>
<row>
<value>2</value>
<value>Default for Miscellaneous</value>
<value>3</value>
<value>default question category</value>
</row>
<row>
<value>3</value>
<value>Default for System</value>
<value>1</value>
<value>default question category</value>
</row>
</table>
<table name="question">
<column>id</column>
<column>category</column>
<column>parent</column>
<column>name</column>
<column>questiontext</column>
<column>generalfeedback</column>
<column>defaultmark</column>
<column>qtype</column>
<row>
<value>1</value>
<value>1</value>
<value>0</value>
<value>true or false 1</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>2</value>
<value>1</value>
<value>0</value>
<value>multiple choice 1</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>multichoice</value>
</row>
</table>
<table name="qtype_multichoice_options">
<column>id</column>
<column>questionid</column>
<column>layout</column>
<column>single</column>
<column>shuffleanswers</column>
<column>correctfeedback</column>
<column>correctfeedbackformat</column>
<column>partiallycorrectfeedback</column>
<column>partiallycorrectfeedbackformat</column>
<column>incorrectfeedback</column>
<column>incorrectfeedbackformat</column>
<column>answernumbering</column>
<column>shownumcorrect</column>
<column>showstandardinstruction</column>
<row>
<value>1</value>
<value>2</value>
<value>0</value>
<value>1</value>
<value>1</value>
<value>Your answer is correct.</value>
<value>1</value>
<value>Your answer is partially correct.</value>
<value>1</value>
<value>Your answer is incorrect.</value>
<value>1</value>
<value>abc</value>
<value>1</value>
<value>0</value>
</row>
</table>>
<table name="question_answers">
<column>id</column>
<column>question</column>
<column>answer</column>
<column>fraction</column>
<column>feedback</column>
<row>
<value>1</value>
<value>1</value>
<value>true</value>
<value>1.0</value>
<value>feedback for true</value>
</row>
<row>
<value>2</value>
<value>1</value>
<value>false</value>
<value>0.0</value>
<value>feedback for false</value>
</row>
<row>
<value>3</value>
<value>2</value>
<value>answer 1 (correct)</value>
<value>1.0</value>
<value>feedback for answer 1</value>
</row>
<row>
<value>4</value>
<value>2</value>
<value>answer 2 (incorrect)</value>
<value>0.0</value>
<value>feedback for answer 2</value>
</row>
<row>
<value>5</value>
<value>2</value>
<value>answer 3 (incorrect)</value>
<value>0.0</value>
<value>feedback for answer 3</value>
</row>
<row>
<value>6</value>
<value>2</value>
<value>answer 4 (incorrect)</value>
<value>0.0</value>
<value>feedback for answer 4</value>
</row>
<row>
<value>7</value>
<value>2</value>
<value>answer 5 (incorrect)</value>
<value>0.0</value>
<value>feedback for answer %</value>
</row>
</table>
<table name="question_attempts">
<column>id</column>
<column>questionusageid</column>
<column>slot</column>
<column>questionid</column>
<column>maxmark</column>
<column>minfraction</column>
<column>rightanswer</column>
<column>timemodified</column>
<row>
<value>1</value>
<value>330</value>
<value>1</value>
<value>1</value>
<value>1.0</value>
<value>0.0</value>
<value>true</value>
<value>0</value>
</row>
<row>
<value>2</value>
<value>330</value>
<value>2</value>
<value>2</value>
<value>1.0</value>
<value>0.0</value>
<value>answer 1 (correct)</value>
<value>0</value>
</row>
</table>
<table name="question_attempt_steps">
<column>id</column>
<column>questionattemptid</column>
<column>sequencenumber</column>
<column>state</column>
<column>timecreated</column>
<column>userid</column>
<row>
<value>1</value>
<value>1</value>
<value>0</value>
<value>todo</value>
<value>0</value>
<value>2</value>
</row>
<row>
<value>2</value>
<value>1</value>
<value>1</value>
<value>gradedwrong</value>
<value>1</value>
<value>2</value>
</row>
<row>
<value>3</value>
<value>2</value>
<value>0</value>
<value>complete</value>
<value>1</value>
<value>2</value>
</row>
</table>
<table name="question_truefalse">
<column>id</column>
<column>question</column>
<column>trueanswer</column>
<column>falseanswer</column>
<row>
<value>1</value>
<value>1</value>
<value>1</value>
<value>2</value>
</row>
</table>
<table name="tag">
<column>id</column>
<column>userid</column>
<column>tagcollid</column>
<column>name</column>
<column>rawname</column>
<column>tagtype</column>
<column>flag</column>
<row>
<value>1</value>
<value>2</value>
<value>1</value>
<value>adpq_5</value>
<value>adpq_5</value>
<value>default</value>
<value>0</value>
</row>
<row>
<value>2</value>
<value>2</value>
<value>1</value>
<value>phpunittag_5</value>
<value>phpunittag_5</value>
<value>default</value>
<value>0</value>
</row>
<row>
<value>3</value>
<value>2</value>
<value>1</value>
<value>phpunittag_888</value>
<value>phpunittag_888</value>
<value>default</value>
<value>0</value>
</row>
</table>
<table name="tag_instance">
<column>id</column>
<column>tagid</column>
<column>itemtype</column>
<column>itemid</column>
<column>tiuserid</column>
<row>
<value>1</value>
<value>1</value>
<value>question</value>
<value>1</value>
<value>0</value>
</row>
</table>
<table name="adaptivequiz">
<column>id</column>
<column>course</column>
<column>name</column>
<column>intro</column>
<column>attemptfeedback</column>
<column>highestlevel</column>
<column>lowestlevel</column>
<column>minimumquestions</column>
<column>maximumquestions</column>
<column>standarderror</column>
<column>startinglevel</column>
<column>completionattemptcompleted</column>
<row>
<value>330</value>
<value>200</value>
<value>Test re-using quba</value>
<value>intro asdf</value>
<value>attempt feedback</value>
<value>100</value>
<value>1</value>
<value>15</value>
<value>20</value>
<value>1.0</value>
<value>50</value>
<value>1</value>
</row>
</table>
<table name="adaptivequiz_attempt">
<column>id</column>
<column>instance</column>
<column>userid</column>
<column>uniqueid</column>
<column>attemptstate</column>
<column>questionsattempted</column>
<column>standarderror</column>
<row>
<value>1</value>
<value>220</value>
<value>2</value>
<value>1110</value>
<value>inprogress</value>
<value>0</value>
<value>1</value>
</row>
<row>
<value>2</value>
<value>221</value>
<value>2</value>
<value>1111</value>
<value>inprogress</value>
<value>10</value>
<value>1</value>
</row>
<row>
<value>3</value>
<value>222</value>
<value>2</value>
<value>1112</value>
<value>inprogress</value>
<value>20</value>
<value>1</value>
</row>
<row>
<value>4</value>
<value>223</value>
<value>2</value>
<value>1113</value>
<value>inprogress</value>
<value>40</value>
<value>1</value>
</row>
<row>
<value>5</value>
<value>330</value>
<value>2</value>
<value>330</value>
<value>inprogress</value>
<value>0</value>
<value>1</value>
</row>
</table>
<table name="question_usages">
<column>id</column>
<column>contextid</column>
<column>component</column>
<column>preferredbehaviour</column>
<row>
<value>330</value>
<value>330</value>
<value>mod_adaptivequiz</value>
<value>immediatefeedback</value>
</row>
</table>
</dataset>
<?xml version="1.0" encoding="UTF-8"?>
<dataset>
<table name="adaptivequiz_attempt">
<column>id</column>
<column>instance</column>
<column>userid</column>
<column>uniqueid</column>
<column>attemptstate</column>
<column>questionsattempted</column>
<column>standarderror</column>
<column>difficultysum</column>
<column>measure</column>
<row>
<value>1</value>
<value>220</value>
<value>2</value>
<value>1110</value>
<value>inprogress</value>
<value>0</value>
<value>1.2</value>
<value>99</value>
<value>2.222</value>
</row>
</table>
<table name="adaptivequiz">
<column>id</column>
<column>course</column>
<column>name</column>
<column>intro</column>
<column>attemptfeedback</column>
<column>highestlevel</column>
<column>lowestlevel</column>
<column>minimumquestions</column>
<column>maximumquestions</column>
<column>standarderror</column>
<column>startinglevel</column>
<row>
<value>220</value>
<value>1</value>
<value>Test for retrieve_standard_error()</value>
<value>intro asdf</value>
<value>attempt feedback</value>
<value>100</value>
<value>1</value>
<value>15</value>
<value>20</value>
<value>9.9</value>
<value>50</value>
</row>
</table>
</dataset>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<dataset>
<table name="context">
<column>id</column>
<column>contextlevel</column>
<column>instanceid</column>
<column>path</column>
<column>depth</column>
<row>
<value>16</value>
<value>50</value>
<value>2</value>
<value>/1/3/16</value>
<value>3</value>
</row>
</table>
<table name="course">
<column>id</column>
<column>category</column>
<column>fullname</column>
<column>shortname</column>
<row>
<value>2</value>
<value>1</value>
<value>101</value>
<value>101</value>
</row>
</table>
<table name="question_categories">
<column>id</column>
<column>name</column>
<column>contextid</column>
<column>info</column>
<column>stamp</column>
<row>
<value>1</value>
<value>Default for 101</value>
<value>16</value>
<value>default question category</value>
<value>example.loc+161208110835+ZNzubk</value>
</row>
<row>
<value>2</value>
<value>Default for Miscellaneous</value>
<value>3</value>
<value>default question category</value>
<value>example.loc+161208112421+aQXIHP</value>
</row>
<row>
<value>3</value>
<value>Default for System</value>
<value>1</value>
<value>default question category</value>
<value>example.loc+161208112421+HX4iPR</value>
</row>
<row>
<value>4</value>
<value>Test reattempt</value>
<value>16</value>
<value>Data test for fetch_question</value>
<value>example.loc+161208112853+RAFrV1</value>
</row>
<row>
<value>5</value>
<value>retrieve_tags_with_question_count</value>
<value>16</value>
<value>test_retrieve_tags_with_question_count</value>
<value>example.loc+161208112930+1oAcF9</value>
</row>
<row>
<value>6</value>
<value>find_questions_with_tags_with_multiple_quest_in_quest_category 1</value>
<value>16</value>
<value>find_questions_with_tags_with_multiple_quest_in_quest_category</value>
<value>example.loc+161208120113+5PvXXH</value>
</row>
<row>
<value>7</value>
<value>find_questions_with_tags_with_multiple_quest_in_quest_category 2</value>
<value>16</value>
<value>find_questions_with_tags_with_multiple_quest_in_quest_category</value>
<value>example.loc+170110181010+Ig7DE6</value>
</row>
<row>
<value>8</value>
<value>find_questions_with_tags_with_multiple_quest_in_quest_category 3</value>
<value>16</value>
<value>find_questions_with_tags_with_multiple_quest_in_quest_category</value>
<value>example.loc+170110181307+5LFZK2</value>
</row>
</table>
<table name="question">
<column>id</column>
<column>category</column>
<column>parent</column>
<column>name</column>
<column>questiontext</column>
<column>generalfeedback</column>
<column>defaultmark</column>
<column>qtype</column>
<row>
<value>1</value>
<value>1</value>
<value>0</value>
<value>true or false 1</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>2</value>
<value>4</value>
<value>0</value>
<value>true or false 2</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>3</value>
<value>4</value>
<value>0</value>
<value>true or false 3</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>4</value>
<value>4</value>
<value>0</value>
<value>true or false 4</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>5</value>
<value>4</value>
<value>0</value>
<value>true or false 5</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>6</value>
<value>4</value>
<value>0</value>
<value>true or false 6</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>7</value>
<value>5</value>
<value>0</value>
<value>TF test 1</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>8</value>
<value>5</value>
<value>0</value>
<value>TF test 2</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>9</value>
<value>5</value>
<value>0</value>
<value>TF test 3</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>10</value>
<value>5</value>
<value>0</value>
<value>TF test 4</value>
<value>question text</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>11</value>
<value>6</value>
<value>0</value>
<value>multiple_quest_in_quest_category 1</value>
<value>multiple_quest_in_quest_category 1</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>12</value>
<value>7</value>
<value>0</value>
<value>multiple_quest_in_quest_category 2</value>
<value>multiple_quest_in_quest_category 2</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
<row>
<value>13</value>
<value>8</value>
<value>0</value>
<value>multiple_quest_in_quest_category 3</value>
<value>multiple_quest_in_quest_category 3</value>
<value>general feedback</value>
<value>1.0</value>
<value>truefalse</value>
</row>
</table>
<table name="question_answers">
<column>id</column>
<column>question</column>
<column>answer</column>
<column>fraction</column>
<column>feedback</column>
<row>
<value>1</value>
<value>1</value>
<value>true</value>
<value>1.0</value>
<value>feedback for true</value>
</row>
<row>
<value>2</value>
<value>1</value>
<value>false</value>
<value>0.0</value>
<value>feedback for false</value>
</row>
</table>
<table name="question_truefalse">
<column>id</column>
<column>question</column>
<column>trueanswer</column>
<column>falseanswer</column>
<row>
<value>1</value>
<value>1</value>
<value>1</value>
<value>2</value>
</row>
</table>
<table name="course_modules">
<column>id</column>
<column>course</column>
<column>module</column>
<column>falseanswer</column>
</table>
<table name="tag">
<column>id</column>
<column>userid</column>
<column>tagcollid</column>
<column>name</column>
<column>rawname</column>
<column>flag</column>
<row>
<value>1</value>
<value>2</value>
<value>1</value>
<value>adpq_5</value>
<value>adpq_5</value>
<value>0</value>
</row>
<row>
<value>2</value>
<value>2</value>
<value>1</value>
<value>phpunittag_5</value>
<value>phpunittag_5</value>
<value>0</value>
</row>
<row>
<value>3</value>
<value>2</value>
<value>1</value>
<value>phpunittag_888</value>
<value>phpunittag_888</value>
<value>0</value>
</row>
<row>
<value>4</value>
<value>2</value>
<value>1</value>
<value>adpq_6</value>
<value>adpq_6</value>
<value>0</value>
</row>
<row>
<value>5</value>
<value>2</value>
<value>1</value>
<value>adpq_7</value>
<value>adpq_7</value>
<value>0</value>
</row>
<row>
<value>6</value>
<value>2</value>
<value>1</value>
<value>adpq_8</value>
<value>adpq_8</value>
<value>0</value>
</row>
<row>
<value>7</value>
<value>2</value>
<value>1</value>
<value>adpq_9</value>
<value>adpq_9</value>
<value>0</value>
</row>
<row>
<value>8</value>
<value>2</value>
<value>1</value>
<value>adpq_10</value>
<value>adpq_10</value>
<value>0</value>
</row>
<row>
<value>22</value>
<value>2</value>
<value>1</value>
<value>test1_22</value>
<value>test1_22</value>
<value>0</value>
</row>
<row>
<value>23</value>
<value>2</value>
<value>1</value>
<value>test1_23</value>
<value>test1_23</value>
<value>0</value>
</row>
<row>
<value>24</value>
<value>2</value>
<value>1</value>
<value>test1_24</value>
<value>test1_24</value>
<value>0</value>
</row>
<row>
<value>25</value>
<value>2</value>
<value>2</value>
<value>adpq_5</value>
<value>adpq_5</value>
<value>0</value>
</row>
</table>
<table name="tag_instance">
<column>id</column>
<column>tagid</column>
<column>component</column>
<column>itemtype</column>
<column>itemid</column>
<column>tiuserid</column>
<row>
<value>1</value>
<value>1</value>
<value>core_question</value>
<value>question</value>
<value>1</value>
<value>0</value>
</row>
<row>
<value>2</value>
<value>4</value>
<value>core_question</value>
<value>question</value>
<value>2</value>
<value>0</value>
</row>
<row>
<value>3</value>
<value>5</value>
<value>core_question</value>
<value>question</value>
<value>3</value>
<value>0</value>
</row>
<row>
<value>4</value>
<value>6</value>
<value>core_question</value>
<value>question</value>
<value>4</value>
<value>0</value>
</row>
<row>
<value>5</value>
<value>7</value>
<value>core_question</value>
<value>question</value>
<value>5</value>
<value>0</value>
</row>
<row>
<value>6</value>
<value>8</value>
<value>core_question</value>
<value>question</value>
<value>6</value>
<value>0</value>
</row>
<row>
<value>7</value>
<value>1</value>
<value>core_question</value>
<value>question</value>
<value>8</value>
<value>0</value>
</row>
<row>
<value>8</value>
<value>8</value>
<value>core_question</value>
<value>question</value>
<value>9</value>
<value>0</value>
</row>
<row>
<value>9</value>
<value>8</value>
<value>core_question</value>
<value>question</value>
<value>10</value>
<value>0</value>
</row>
<row>
<value>22</value>
<value>22</value>
<value>core_question</value>
<value>question</value>
<value>11</value>
<value>0</value>
</row>
<row>
<value>23</value>
<value>23</value>
<value>core_question</value>
<value>question</value>
<value>12</value>
<value>0</value>
</row>
<row>
<value>24</value>
<value>24</value>
<value>core_question</value>
<value>question</value>
<value>13</value>
<value>0</value>
</row>
<row>
<value>25</value>
<value>25</value>
<value>core</value>
<value>course</value>
<value>2</value>
<value>0</value>
</row>
<row>
<value>26</value>
<value>2</value>
<value>core_question</value>
<value>question</value>
<value>5</value>
<value>0</value>
</row>
</table>
<table name="adaptivequiz_question">
<column>id</column>
<column>instance</column>
<column>questioncategory</column>
<row>
<value>1</value>
<value>1</value>
<value>11</value>
</row>
<row>
<value>2</value>
<value>1</value>
<value>22</value>
</row>
</table>
</dataset>
\ No newline at end of file
<?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\form;
use advanced_testcase;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* PHPUnit tests for the item administration params form.
*
* @package mod_adaptivequiz
* @copyright 2026 KNIGHT, Hochschule fuer Technik Stuttgart
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(item_administration_params_form::class)]
final class item_administration_params_form_test extends advanced_testcase {
/**
* Runs the form validation for a given acceptance threshold value.
*
* The other scoring fields are kept valid except for a deliberately invalid minimumquestions,
* which makes validation() return early - before the question-bank checks that would require a
* real course module - so the acceptance threshold validation can be inspected in isolation.
*
* @param mixed $threshold
* @return array
*/
private function validate_with_threshold($threshold): array {
$form = new item_administration_params_form();
return $form->validation([
'id' => 0,
'highestlevel' => 4,
'lowestlevel' => 1,
'startinglevel' => 2,
'minimumquestions' => 0,
'maximumquestions' => 10,
'standarderror' => 5,
'acceptancethreshold' => $threshold,
], []);
}
public function test_acceptance_threshold_within_two_decimals_is_accepted(): void {
$this->resetAfterTest();
$this->assertArrayNotHasKey('acceptancethreshold', $this->validate_with_threshold(0.5));
$this->assertArrayNotHasKey('acceptancethreshold', $this->validate_with_threshold(0.75));
$this->assertArrayNotHasKey('acceptancethreshold', $this->validate_with_threshold(0));
$this->assertArrayNotHasKey('acceptancethreshold', $this->validate_with_threshold(1));
}
public function test_acceptance_threshold_with_more_than_two_decimals_is_rejected(): void {
$this->resetAfterTest();
$this->assertArrayHasKey('acceptancethreshold', $this->validate_with_threshold(0.001));
$this->assertArrayHasKey('acceptancethreshold', $this->validate_with_threshold(0.123));
}
public function test_acceptance_threshold_out_of_bounds_is_rejected(): void {
$this->resetAfterTest();
$this->assertArrayHasKey('acceptancethreshold', $this->validate_with_threshold(-0.1));
$this->assertArrayHasKey('acceptancethreshold', $this->validate_with_threshold(1.5));
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Behat data generator.
*
* @package mod_adaptivequiz
* @copyright 2026 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class behat_mod_adaptivequiz_generator extends behat_generator_base {
/**
* Get a list of the entities that can be created for this component.
*/
protected function get_creatable_entities(): array {
return [
'links with question banks' => [
'singular' => 'link with question bank',
'datagenerator' => 'link_with_question_bank',
'required' => ['adaptivequiz', 'idnumber'],
'switchids' => ['adaptivequiz' => 'adaptivequizid'],
],
];
}
/**
* Looks up the ID of an adaptive quiz from its name.
*
* @param string $name
* @return int The ID.
*/
protected function get_adaptivequiz_id(string $name): int {
global $DB;
return $DB->get_field('adaptivequiz', 'id', ['name' => $name], MUST_EXIST);
}
/**
* Preprocess discussion data.
*
* @param array $data Raw data.
* @return array Processed data.
*/
protected function preprocess_link_with_question_bank(array $data) {
$cminfo = $this->get_cm_by_activity_name('qbank', $data['idnumber']);
unset($data['idnumber']);
$data['qbankid'] = $cminfo->instance;
return $data;
}
}
<?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/>.
use core\exception\coding_exception;
use mod_adaptivequiz\attempt as read_attempt;
use mod_adaptivequiz\item_bank;
use mod_adaptivequiz\local\attempt as attempt_entity;
/**
* Generator for the module.
*
* @package mod_adaptivequiz
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2024 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class mod_adaptivequiz_generator extends testing_module_generator {
/**
* Creates new module instance.
*
* For params description see the parent's method.
* IMPORTANT: do not throw any exceptions on missing data, as this method may be called by Moodle's core tests. Try to
* find a proper default value for each property.
*
* @param array|stdClass|null $record
* @param array|null $options
* @return stdClass
*/
public function create_instance($record = null, array $options = null) {
global $CFG;
require_once($CFG->dirroot .'/mod/adaptivequiz/locallib.php');
$record = (object)(array)$record;
$defaultsettings = [
'introformat' => FORMAT_MOODLE,
'attempts' => 0,
'grademethod' => ADAPTIVEQUIZ_GRADEHIGHEST,
'password' => '',
'attemptfeedbackenable' => 0,
'attemptfeedback' => '',
'attemptfeedbackformat' => FORMAT_MOODLE,
'showabilitymeasurefeedback' => 0,
'showabilitymeasuresummary' => 0,
'attemptonlast' => 0,
'highestlevel' => 111,
'lowestlevel' => 1,
'minimumquestions' => 1,
'maximumquestions' => 111,
'standarderror' => 1.1,
'startinglevel' => 11,
'timecreated' => time(),
'timemodified' => time(),
'debuginfoenable' => 0,
];
foreach ($defaultsettings as $name => $value) {
if (!isset($record->{$name})) {
$record->{$name} = $value;
}
}
return parent::create_instance($record, $options);
}
/**
* Generates links between the given adaptive quiz instance and question banks.
*
* Note, the question banks data passed is not validated.
*
* @param array $data 'adaptivequizid' and 'qbankid' are the required keys.
*/
public function create_link_with_question_bank(array $data): void {
if (!array_key_exists('adaptivequizid', $data)) {
throw new coding_exception("Id of an 'adaptivequiz' instance is required when linking qbanks");
}
if (!array_key_exists('qbankid', $data)) {
throw new coding_exception("Id of a 'qbank' instance is required when linking qbanks");
}
item_bank::assign_qbanks_to_adaptivequiz($data['adaptivequizid'], [$data['qbankid']]);
}
/**
* Generates links between the given adaptive quiz instance and a single question category.
*
* Note, the passed data is not validated.
*
* @param array $data 'adaptivequizid' and 'qcategoryid' are the required keys.
*/
public function create_link_with_question_category(array $data): void {
global $DB;
if (!array_key_exists('adaptivequizid', $data)) {
throw new coding_exception("Id of an 'adaptivequiz' instance is required when linking question categories");
}
if (!array_key_exists('qcategoryid', $data)) {
throw new coding_exception("Id of a question category is required when linking question categories");
}
$DB->insert_record('adaptivequiz_question', [
'instance' => $data['adaptivequizid'],
'questioncategory' => $data['qcategoryid'],
]);
}
/**
* Generates a 'fresh' attempt for user.
*
* Note, that this runs the entire logic of starting an attempt, etc. This means it's assumed that the adaptive quiz instance
* is set up with a proper questions pool containing a minimal number of actual questions, etc.
*
* @param int $userid
* @param int $adaptivequizid
* @param context_module $context
*/
public function create_in_progress_attempt(int $userid, int $adaptivequizid, context_module $context): read_attempt {
global $DB;
$adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $adaptivequizid], '*', MUST_EXIST);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$attempt = new attempt_entity($adaptivequizforattempt, $userid);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
return read_attempt::get_record(['uniqueid' => $uniqueid], MUST_EXIST);
}
/**
* Generates a completed attempt for user.
*
* The note from {@see self::create_in_progress_attempt()} is applicable here as well.
*
* @param int $userid
* @param int $adaptivequizid
* @param context_module $context
* @param array $attemptdata Raw values for the following fields can be passed: 'standarderror', 'measure'.
* @param string $stoppagereason
*/
public function create_completed_attempt(
int $userid,
int $adaptivequizid,
context_module $context,
array $attemptdata,
string $stoppagereason
): read_attempt {
global $DB;
$adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $adaptivequizid], '*', MUST_EXIST);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$attempt = new attempt_entity($adaptivequizforattempt, $userid);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$uniqueid = $attempt->get_quba()->get_id();
$standarderror = array_key_exists('standarderror', $attemptdata) ? $attemptdata['standarderror'] : 0.0;
if (array_key_exists('measure', $attemptdata)) {
adaptivequiz_update_attempt_data($uniqueid, $adaptivequizid, $userid, $difflogit = 0, $standarderror,
$attemptdata['measure']);
}
adaptivequiz_complete_attempt(uniqueid: $uniqueid, adaptivequiz: $adaptivequiz, context: $context, userid: $userid,
standarderror: $standarderror, statusmessage: $stoppagereason);
return read_attempt::get_record(['uniqueid' => $uniqueid], MUST_EXIST);
}
}
<?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 context_module;
use mod_adaptivequiz\local\attempt\attempt_state;
use mod_adaptivequiz_generator;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* Adaptive PHPUnit data generator testcase.
*
* @package mod_adaptivequiz
* @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
*/
#[CoversClass(\mod_adaptivequiz_generator::class)]
final class generator_test extends advanced_testcase {
public function test_generator() {
global $DB, $SITE;
$this->resetAfterTest();
$this->assertEquals(0, $DB->count_records('adaptivequiz'));
$generator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$this->assertInstanceOf('mod_adaptivequiz_generator', $generator);
$this->assertEquals('adaptivequiz', $generator->get_modulename());
$generator->create_instance(['course' => $SITE->id]);
$generator->create_instance(['course' => $SITE->id]);
$adaptivequiz = $generator->create_instance(['course' => $SITE->id]);
$this->assertEquals(3, $DB->count_records('adaptivequiz'));
$cm = get_coursemodule_from_instance('adaptivequiz', $adaptivequiz->id);
$this->assertEquals($adaptivequiz->id, $cm->instance);
$this->assertEquals('adaptivequiz', $cm->modname);
$this->assertEquals($SITE->id, $cm->course);
$context = context_module::instance($cm->id);
$this->assertEquals($adaptivequiz->cmid, $context->instanceid);
}
public function test_it_creates_links_with_question_banks(): void {
global $DB;
$this->resetAfterTest();
/** @var mod_adaptivequiz_generator $generator */
$generator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$course = $this->getDataGenerator()->create_course();
$qbankgenerator = $this->getDataGenerator()->get_plugin_generator('mod_qbank');
$adaptivequiz = $generator->create_instance(['course' => $course->id]);
$qbank1 = $qbankgenerator->create_instance(['course' => $course->id]);
$qbank2 = $qbankgenerator->create_instance(['course' => $course->id]);
foreach ([$qbank1->id, $qbank2->id] as $qbankid) {
$generator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbankid,
]);
}
$result = $DB->get_fieldset('adaptivequiz_qbank', 'qbankid', ['adaptivequizid' => $adaptivequiz->id]);
self::assertCount(2, $result);
self::assertEqualsCanonicalizing([$qbank1->id, $qbank2->id], $result);
}
public function test_it_creates_links_with_single_question_categories(): void {
global $DB;
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
/** @var \core_question_generator $questiongenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
/** @var mod_adaptivequiz_generator $generator */
$generator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$course = $this->getDataGenerator()->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, $course->id);
$qbankcontext = context_module::instance($qbankcm->id);
$qcat1 = $questiongenerator->create_question_category([
'contextid' => $qbankcontext->id,
'name' => 'My category 1',
]);
$qcat2 = $questiongenerator->create_question_category([
'contextid' => $qbankcontext->id,
'name' => 'My category 2',
]);
$adaptivequiz1 = $generator->create_instance(['course' => $course->id]);
$adaptivequiz2 = $generator->create_instance(['course' => $course->id]);
$generator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz1->id,
'qcategoryid' => $qcat1->id,
]);
$generator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz1->id,
'qcategoryid' => $qcat2->id,
]);
// Assertions.
$result = $DB->get_fieldset('adaptivequiz_question', 'questioncategory', ['instance' => $adaptivequiz1->id]);
self::assertCount(2, $result);
self::assertEqualsCanonicalizing([$qcat1->id, $qcat2->id], $result);
self::assertEquals(0, $DB->count_records('adaptivequiz_question', ['instance' => $adaptivequiz2->id]));
}
public function test_it_creates_an_in_progress_attempt(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var mod_adaptivequiz_generator $generator */
$generator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
/** @var \core_question_generator $questiongenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
$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 = $generator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 1,
'maximumquestions' => 2,
]);
$generator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank->id,
]);
$user = $coregenerator->create_user();
$context = context_module::instance($adaptivequiz->cmid);
// End of setup.
$attempt = $generator->create_in_progress_attempt($user->id, $adaptivequiz->id, $context);
$this->assertEquals(attempt_state::IN_PROGRESS, $attempt->get('attemptstate'));
$this->assertGreaterThan(0, $attempt->get('uniqueid'));
$this->assertEquals($user->id, $attempt->get('userid'));
$this->assertEquals($adaptivequiz->id, $attempt->get('instance'));
$this->assertEmpty($attempt->get('attemptstopcriteria'));
}
public function test_it_creates_a_completed_attempt(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var mod_adaptivequiz_generator $generator */
$generator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
/** @var \core_question_generator $questiongenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
$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 = $generator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 1,
'maximumquestions' => 2,
]);
$generator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank->id,
]);
$user = $coregenerator->create_user();
$context = context_module::instance($adaptivequiz->cmid);
// End of setup.
$attemptdata = [
'standarderror' => 0.83666,
'measure' => -3.34409,
];
$stoppagereason = 'Maximum number of questions attempted';
$attempt = $generator->create_completed_attempt(
$user->id,
$adaptivequiz->id,
$context,
$attemptdata,
$stoppagereason
);
$this->assertEquals(attempt_state::COMPLETED, $attempt->get('attemptstate'));
$this->assertGreaterThan(0, $attempt->get('uniqueid'));
$this->assertEquals($user->id, $attempt->get('userid'));
$this->assertEquals($adaptivequiz->id, $attempt->get('instance'));
$this->assertEquals('Maximum number of questions attempted', $attempt->get('attemptstopcriteria'));
$this->assertEquals(0.83666, $attempt->get('standarderror'));
$this->assertEquals(-3.34409, $attempt->get('measure'));
}
}
<?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 context_module;
use mod_adaptivequiz_generator;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2026 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\item_bank_helper::class)]
class item_bank_helper_test extends advanced_testcase {
public function test_it_counts_questions_with_the_given_difficulty_level_for_an_adaptivequiz_instance(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \core_question_generator $questionsgenerator */
$questionsgenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course1 = $coregenerator->create_course();
$course2 = $coregenerator->create_course();
$qbank1 = $qbankgenerator->create_instance(['course' => $course1->id]);
$qbank2 = $qbankgenerator->create_instance(['course' => $course2->id]);
$qbank3 = $qbankgenerator->create_instance(['course' => $course2->id]);
$adaptivequiz = $adaptivequizgenerator->create_instance(['course' => $course1->id]);
foreach ([$qbank1->id, $qbank2->id] as $qbankid) {
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbankid,
]);
}
$qbank3cm = get_coursemodule_from_instance('qbank', $qbank3->id, $course2->id);
$qbank3context = context_module::instance($qbank3cm->id);
$qbank3cat = $questionsgenerator->create_question_category([
'contextid' => $qbank3context->id,
'name' => 'My category 1',
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz->id,
'qcategoryid' => $qbank3cat->id,
]);
$qbank1cm = get_coursemodule_from_instance('qbank', $qbank1->id, 0, false, MUST_EXIST);
$qbank1context = context_module::instance($qbank1cm->id);
$qbank1cat = question_get_default_category($qbank1context->id);
$question1 = $questionsgenerator->create_question('truefalse', null, ['category' => $qbank1cat->id]);
$questionsgenerator->create_question_tag(['questionid' => $question1->id, 'tag' => 'adpq_1']);
$question2 = $questionsgenerator->create_question('truefalse', null, ['category' => $qbank1cat->id]);
$questionsgenerator->create_question_tag(['questionid' => $question2->id, 'tag' => 'adpq_2']);
$question3 = $questionsgenerator->create_question('truefalse', null, ['category' => $qbank1cat->id]);
$questionsgenerator->create_question_tag(['questionid' => $question3->id, 'tag' => 'adpq_001']);
$qbank2cm = get_coursemodule_from_instance('qbank', $qbank2->id, 0, false, MUST_EXIST);
$qbank2context = context_module::instance($qbank2cm->id);
$qbank2cat = question_get_default_category($qbank2context->id);
$question4 = $questionsgenerator->create_question('truefalse', null, ['category' => $qbank2cat->id]);
$questionsgenerator->create_question_tag(['questionid' => $question4->id, 'tag' => 'truefalse_1']);
$question5 = $questionsgenerator->create_question('truefalse', null, ['category' => $qbank2cat->id]);
$questionsgenerator->create_question_tag(['questionid' => $question5->id, 'tag' => 'adpq_3']);
$question6 = $questionsgenerator->create_question('truefalse', null, ['category' => $qbank3cat->id]);
$questionsgenerator->create_question_tag(['questionid' => $question6->id, 'tag' => 'adpq_1']);
$question7 = $questionsgenerator->create_question('truefalse', null, ['category' => $qbank3cat->id]);
$questionsgenerator->create_question_tag(['questionid' => $question7->id, 'tag' => 'adpq_3']);
// End of setup.
$result = item_bank_helper::count_adaptivequiz_questions_with_difficulty_level($adaptivequiz->id, 1);
self::assertEquals(2, $result);
$result = item_bank_helper::count_adaptivequiz_questions_with_difficulty_level($adaptivequiz->id, 2);
self::assertEquals(1, $result);
$result = item_bank_helper::count_adaptivequiz_questions_with_difficulty_level($adaptivequiz->id, 3);
self::assertEquals(2, $result);
}
public function test_it_gets_question_categories_from_item_bank(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \core_question_generator $questiongenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course = $coregenerator->create_course();
$qbank1 = $qbankgenerator->create_instance(['course' => $course->id]);
$qbank2 = $qbankgenerator->create_instance(['course' => $course->id]);
$qbank1cm = get_coursemodule_from_instance('qbank', $qbank1->id, $course->id, false, MUST_EXIST);
$qbank1context = context_module::instance($qbank1cm->id);
$qbankcat1 = question_get_default_category($qbank1context->id);
$qbankcat2 = question_get_top_category($qbank1context->id);
$qbank2cm = get_coursemodule_from_instance('qbank', $qbank2->id, $course->id, false, MUST_EXIST);
$qbank2context = context_module::instance($qbank2cm->id);
$qbankcat3 = question_get_default_category($qbank2context->id);
$qbankcat4 = question_get_top_category($qbank2context->id);
// Contains 'single' question categories linked to the activity.
$qbank3 = $qbankgenerator->create_instance(['course' => $course->id]);
$qbank3cm = get_coursemodule_from_instance('qbank', $qbank3->id, $course->id, false, MUST_EXIST);
$qbank3context = context_module::instance($qbank3cm->id);
$singlecat1 = $questiongenerator->create_question_category([
'contextid' => $qbank3context->id,
'name' => 'Single category 1',
]);
$singlecat2 = $questiongenerator->create_question_category([
'contextid' => $qbank3context->id,
'name' => 'Single category 2',
]);
$adaptivequiz = $adaptivequizgenerator->create_instance(['course' => $course->id]);
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank1->id,
]);
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank2->id,
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz->id,
'qcategoryid' => $singlecat1->id,
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz->id,
'qcategoryid' => $singlecat2->id,
]);
// End of setup.
$result = item_bank_helper::get_question_categories($adaptivequiz->id);
$expect = [$qbankcat1->id, $qbankcat2->id, $qbankcat3->id, $qbankcat4->id, $singlecat1->id, $singlecat2->id];
self::assertEqualsCanonicalizing($expect, $result);
}
}
<?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 context_module;
use mod_adaptivequiz_generator;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* A test class.
*
* @package mod_adaptivequiz
* @copyright 2026 Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\mod_adaptivequiz\item_bank::class)]
class item_bank_test extends advanced_testcase {
public function test_it_assigns_question_banks_to_an_adaptive_quiz_instance(): void {
global $DB;
$this->resetAfterTest();
$course1 = $this->getDataGenerator()->create_course();
$course2 = $this->getDataGenerator()->create_course();
$qbank1 = $this->getDataGenerator()
->get_plugin_generator('mod_qbank')
->create_instance(['course' => $course1->id]);
$qbank1cm = get_coursemodule_from_instance('qbank', $qbank1->id, 0, false, MUST_EXIST);
$qbank1context = context_module::instance($qbank1cm->id);
$qbank2 = $this->getDataGenerator()
->get_plugin_generator('mod_qbank')
->create_instance(['course' => $course2->id]);
$qbank2cm = get_coursemodule_from_instance('qbank', $qbank2->id, 0, false, MUST_EXIST);
$qbank2context = context_module::instance($qbank2cm->id);
$adaptivequiz = $this->getDataGenerator()
->get_plugin_generator('mod_adaptivequiz')
->create_instance(['course' => $course1->id]);
item_bank::assign_qbanks_to_adaptivequiz($adaptivequiz->id, [$qbank1->id, $qbank2->id]);
$result = $DB->get_records(
'adaptivequiz_qbank',
['adaptivequizid' => $adaptivequiz->id],
'',
'qbankid, qbankcontextid'
);
self::assertCount(2, $result);
self::assertEqualsCanonicalizing(
[
(object) ['qbankid' => $qbank1->id, 'qbankcontextid' => $qbank1context->id],
(object) ['qbankid' => $qbank2->id, 'qbankcontextid' => $qbank2context->id],
],
$result
);
}
public function test_it_unassigns_a_question_bank_from_an_adaptive_quiz_instance(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course1 = $coregenerator->create_course();
$course2 = $coregenerator->create_course();
$qbank1 = $qbankgenerator->create_instance(['course' => $course1->id]);
$qbank2 = $qbankgenerator->create_instance(['course' => $course1->id]);
$qbank3 = $qbankgenerator->create_instance(['course' => $course2->id]);
$qbank4 = $qbankgenerator->create_instance(['course' => $course2->id]);
$adaptivequiz1 = $adaptivequizgenerator->create_instance(['course' => $course1->id]);
foreach ([$qbank1->id, $qbank2->id] as $qbankid) {
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz1->id,
'qbankid' => $qbankid,
]);
}
$adaptivequiz2 = $adaptivequizgenerator->create_instance(['course' => $course2->id]);
foreach ([$qbank3->id, $qbank4->id] as $qbankid) {
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz2->id,
'qbankid' => $qbankid,
]);
}
$manager = $coregenerator->create_user();
$coregenerator->enrol_user($manager->id, $course1->id, 'manager');
self::setUser($manager);
// End of setup.
item_bank::unassign_qbank_from_adaptivequiz($adaptivequiz1->id, $qbank1->id);
$result = item_bank::get_question_banks_assigned_to_adaptivequiz($adaptivequiz1->id);
self::assertCount(1, $result);
$remainingqbank = $result[array_key_first($result)];
self::assertEquals($qbank2->id, $remainingqbank->id);
// Assert other instances are not affected.
$result = item_bank::get_question_banks_assigned_to_adaptivequiz($adaptivequiz2->id, 'id, name, course');
self::assertCount(2, $result);
self::assertEqualsCanonicalizing(
[
(object) ['id' => $qbank3->id, 'name' => $qbank3->name, 'course' => $course2->id],
(object) ['id' => $qbank4->id, 'name' => $qbank4->name, 'course' => $course2->id],
],
$result
);
}
public function test_it_unassigns_a_single_question_category_from_an_adaptive_quiz_instance(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
/** @var \core_question_generator $questiongenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
$course = $coregenerator->create_course();
$qbank = $qbankgenerator->create_instance(['course' => $course->id]);
$qbankcm = get_coursemodule_from_instance('qbank', $qbank->id, $course->id);
$qbankcontext = context_module::instance($qbankcm->id);
$qcat1 = $questiongenerator->create_question_category([
'contextid' => $qbankcontext->id,
'name' => 'My category 1',
]);
$qcat2 = $questiongenerator->create_question_category([
'contextid' => $qbankcontext->id,
'name' => 'My category 2',
]);
$qcat3 = $questiongenerator->create_question_category([
'contextid' => $qbankcontext->id,
'name' => 'My category 3',
]);
$qcat4 = $questiongenerator->create_question_category([
'contextid' => $qbankcontext->id,
'name' => 'My category 4',
]);
$adaptivequiz1 = $adaptivequizgenerator->create_instance(['course' => $course->id]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz1->id,
'qcategoryid' => $qcat1->id,
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz1->id,
'qcategoryid' => $qcat2->id,
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz1->id,
'qcategoryid' => $qcat3->id,
]);
$adaptivequiz2 = $adaptivequizgenerator->create_instance(['course' => $course->id]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz2->id,
'qcategoryid' => $qcat4->id,
]);
$manager = $coregenerator->create_user();
$coregenerator->enrol_user($manager->id, $course->id, 'manager');
self::setUser($manager);
// End of setup.
item_bank::unassign_question_category_from_adaptivequiz($adaptivequiz1->id, $qcat3->id);
$result = item_bank::get_question_categories_assigned_to_adaptivequiz($adaptivequiz1->id, 'id, name');
self::assertEqualsCanonicalizing(
[
(object) [
'id' => $qcat1->id,
'name' => $qcat1->name,
'cmid' => $qbankcm->id,
'qbankname' => $qbank->name,
],
(object) [
'id' => $qcat2->id,
'name' => $qcat2->name,
'cmid' => $qbankcm->id,
'qbankname' => $qbank->name,
],
],
$result
);
// Assert other instances are not affected.
$result = item_bank::get_question_categories_assigned_to_adaptivequiz($adaptivequiz2->id, 'id, name');
self::assertEqualsCanonicalizing(
[
(object) [
'id' => $qcat4->id,
'name' => $qcat4->name,
'cmid' => $qbankcm->id,
'qbankname' => $qbank->name,
],
],
$result
);
}
public function test_it_gets_question_banks_assigned_to_an_adaptive_quiz_instance(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
$course1 = $coregenerator->create_course();
$course2 = $coregenerator->create_course();
$course3 = $coregenerator->create_course();
$course4 = $coregenerator->create_course();
$qbank1 = $qbankgenerator->create_instance(['course' => $course1->id]);
$qbank2 = $qbankgenerator->create_instance(['course' => $course2->id]);
$qbank3 = $qbankgenerator->create_instance(['course' => $course3->id]);
$qbankgenerator->create_instance(['course' => $course4->id]);
$adaptivequiz = $adaptivequizgenerator->create_instance(['course' => $course1->id]);
foreach ([$qbank1->id, $qbank2->id, $qbank3->id] as $qbankid) {
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbankid,
]);
}
$result = item_bank::get_question_banks_assigned_to_adaptivequiz($adaptivequiz->id, 'id, name, course');
self::assertCount(3, $result);
self::assertEqualsCanonicalizing(
[
(object) ['id' => $qbank1->id, 'name' => $qbank1->name, 'course' => $course1->id],
(object) ['id' => $qbank2->id, 'name' => $qbank2->name, 'course' => $course2->id],
(object) ['id' => $qbank3->id, 'name' => $qbank3->name, 'course' => $course3->id],
],
$result
);
// Test filtering by courses.
$result = item_bank::get_question_banks_assigned_to_adaptivequiz(
$adaptivequiz->id,
'id, name, course',
incourseid: $course1->id
);
self::assertCount(1, $result);
self::assertEqualsCanonicalizing(
[(object) ['id' => $qbank1->id, 'name' => $qbank1->name, 'course' => $course1->id]],
$result
);
$result = item_bank::get_question_banks_assigned_to_adaptivequiz(
$adaptivequiz->id,
'id, name, course',
notincourseid: $course1->id
);
self::assertCount(2, $result);
self::assertEqualsCanonicalizing(
[
(object) ['id' => $qbank2->id, 'name' => $qbank2->name, 'course' => $course2->id],
(object) ['id' => $qbank3->id, 'name' => $qbank3->name, 'course' => $course3->id],
],
$result
);
}
public function test_it_gets_question_categories_assigned_to_an_adaptive_quiz_instance(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
/** @var \core_question_generator $questiongenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
$course1 = $coregenerator->create_course();
$course2 = $coregenerator->create_course();
$qbank1 = $qbankgenerator->create_instance(['course' => $course1->id]);
$qbank1cm = get_coursemodule_from_instance('qbank', $qbank1->id, $course1->id);
$qbank1context = context_module::instance($qbank1cm->id);
$qbank2 = $qbankgenerator->create_instance(['course' => $course2->id]);
$qbank2cm = get_coursemodule_from_instance('qbank', $qbank2->id, $course2->id);
$qbank2context = context_module::instance($qbank2cm->id);
$qcat1 = $questiongenerator->create_question_category([
'contextid' => $qbank1context->id,
'name' => 'My category 1',
]);
$qcat2 = $questiongenerator->create_question_category([
'contextid' => $qbank1context->id,
'name' => 'My category 2',
]);
$qcat3 = $questiongenerator->create_question_category([
'contextid' => $qbank2context->id,
'name' => 'My category 3',
]);
$qcat4 = $questiongenerator->create_question_category([
'contextid' => $qbank2context->id,
'name' => 'My category 3',
]);
$adaptivequiz1 = $adaptivequizgenerator->create_instance(['course' => $course1->id]);
$adaptivequiz2 = $adaptivequizgenerator->create_instance(['course' => $course2->id]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz1->id,
'qcategoryid' => $qcat1->id,
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz1->id,
'qcategoryid' => $qcat2->id,
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz2->id,
'qcategoryid' => $qcat3->id,
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz2->id,
'qcategoryid' => $qcat4->id,
]);
// End of setup.
$result = item_bank::get_question_categories_assigned_to_adaptivequiz($adaptivequiz1->id, 'id, name');
self::assertEqualsCanonicalizing(
[
(object) [
'id' => $qcat1->id,
'name' => $qcat1->name,
'cmid' => $qbank1cm->id,
'qbankname' => $qbank1->name,
],
(object) [
'id' => $qcat2->id,
'name' => $qcat2->name,
'cmid' => $qbank1cm->id,
'qbankname' => $qbank1->name,
],
],
$result
);
}
public function test_it_tells_whether_an_adaptive_quiz_instance_has_any_question_banks_assigned(): void {
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
/** @var \mod_qbank_generator $qbankgenerator */
$qbankgenerator = $coregenerator->get_plugin_generator('mod_qbank');
/** @var \core_question_generator $questiongenerator */
$questiongenerator = $coregenerator->get_plugin_generator('core_question');
$course1 = $coregenerator->create_course();
$course2 = $coregenerator->create_course();
$qbank1 = $qbankgenerator->create_instance(['course' => $course1->id]);
$qbank2 = $qbankgenerator->create_instance(['course' => $course2->id]);
$qbank3 = $qbankgenerator->create_instance(['course' => $course2->id]);
$adaptivequiz1 = $adaptivequizgenerator->create_instance(['course' => $course1->id]);
$adaptivequiz2 = $adaptivequizgenerator->create_instance(['course' => $course1->id]);
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz2->id,
'qbankid' => $qbank1->id,
]);
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz2->id,
'qbankid' => $qbank2->id,
]);
$adaptivequiz3 = $adaptivequizgenerator->create_instance(['course' => $course2->id]);
$qbank3cm = get_coursemodule_from_instance('qbank', $qbank3->id, $course2->id);
$qbank3context = context_module::instance($qbank3cm->id);
$qcat = $questiongenerator->create_question_category([
'contextid' => $qbank3context->id,
'name' => 'My category 1',
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz3->id,
'qcategoryid' => $qcat->id,
]);
$adaptivequiz4 = $adaptivequizgenerator->create_instance(['course' => $course2->id]);
$adaptivequizgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz4->id,
'qbankid' => $qbank2->id,
]);
$adaptivequizgenerator->create_link_with_question_category([
'adaptivequizid' => $adaptivequiz4->id,
'qcategoryid' => $qcat->id,
]);
// End of setup.
$result = item_bank::adaptive_quiz_instance_has_question_banks_or_categories_linked($adaptivequiz1->id);
self::assertFalse($result);
$result = item_bank::adaptive_quiz_instance_has_question_banks_or_categories_linked($adaptivequiz2->id);
self::assertTrue($result);
$result = item_bank::adaptive_quiz_instance_has_question_banks_or_categories_linked($adaptivequiz3->id);
self::assertTrue($result);
$result = item_bank::adaptive_quiz_instance_has_question_banks_or_categories_linked($adaptivequiz4->id);
self::assertTrue($result);
}
}
<?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/lib.php');
use advanced_testcase;
use mod_adaptivequiz\local\attempt\attempt_state;
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\DataProvider;
use stdClass;
/**
* Adaptive lib.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_delete_instance')]
#[CoversFunction('adaptivequiz_print_recent_mod_activity')]
#[CoversFunction('adaptivequiz_update_item_administration_params')]
#[CoversFunction('adaptivequiz_add_instance')]
class lib_test extends advanced_testcase {
/**
* This functions loads data via the tests/fixtures/mod_adaptivequiz.xml file
* @return void
*/
protected function setup_test_data_xml() {
$this->dataset_from_files(
[__DIR__.'/fixtures/mod_adaptivequiz.xml']
)->to_database();
}
/**
* KNIGHT (Feature 4): creating an activity persists its per-level competency descriptions.
*/
public function test_add_instance_saves_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');
// The generator passes the competency fields straight through to adaptivequiz_add_instance().
$adaptivequiz = $aqgenerator->create_instance([
'course' => $course->id,
'lowestlevel' => 1,
'highestlevel' => 3,
'competencydesc1' => 'Beginner',
'competencydesc3' => 'Advanced',
]);
$stored = $DB->get_records_menu(
'adaptivequiz_competencydesc',
['adaptivequizid' => $adaptivequiz->id],
'level',
'level, description'
);
$this->assertSame([1 => 'Beginner', 3 => 'Advanced'], $stored);
}
/**
* KNIGHT (Feature 4): deleting an activity also removes its competency descriptions.
*/
public function test_delete_instance_removes_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, 'competencydesc1' => 'Beginner']);
$this->assertTrue($DB->record_exists('adaptivequiz_competencydesc', ['adaptivequizid' => $adaptivequiz->id]));
adaptivequiz_delete_instance($adaptivequiz->id);
$this->assertFalse($DB->record_exists('adaptivequiz_competencydesc', ['adaptivequizid' => $adaptivequiz->id]));
}
/**
* KNIGHT (Feature 4): changing the difficulty range through item administration disables the
* competency feedback when the new range no longer qualifies.
*/
public function test_item_admin_range_change_reconciles_competency_feedback(): 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, 'lowestlevel' => 1, 'highestlevel' => 5, 'competencyfeedbackenable' => 1]
);
$field = fn() => $DB->get_field('adaptivequiz', 'competencyfeedbackenable', ['id' => $adaptivequiz->id]);
$this->assertEquals(1, $field());
// A still-valid range change keeps the feature enabled.
adaptivequiz_update_item_administration_params(
(object) ['id' => $adaptivequiz->id, 'lowestlevel' => 1, 'highestlevel' => 8]
);
$this->assertEquals(1, $field());
// Widening the range past the level cap auto-disables the feature.
adaptivequiz_update_item_administration_params(
(object) ['id' => $adaptivequiz->id, 'lowestlevel' => 1, 'highestlevel' => 20]
);
$this->assertEquals(0, $field());
}
/**
* KNIGHT (Feature 4): the 2026062512 upgrade enables competency feedback for existing activities that
* already have descriptions and a valid level range, and leaves the others off.
*/
public function test_upgrade_enables_competency_feedback_for_existing_valid_activities(): void {
global $CFG, $DB;
require_once($CFG->dirroot . '/mod/adaptivequiz/db/upgrade.php');
require_once($CFG->libdir . '/upgradelib.php');
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course = $generator->create_course();
/** @var \mod_adaptivequiz_generator $aqgenerator */
$aqgenerator = $generator->get_plugin_generator('mod_adaptivequiz');
$descvalid = $aqgenerator->create_instance(['course' => $course->id, 'lowestlevel' => 1, 'highestlevel' => 5]);
$DB->insert_record(
'adaptivequiz_competencydesc',
(object) ['adaptivequizid' => $descvalid->id, 'level' => 2, 'description' => 'x']
);
$desctoolarge = $aqgenerator->create_instance(['course' => $course->id, 'lowestlevel' => 1, 'highestlevel' => 20]);
$DB->insert_record(
'adaptivequiz_competencydesc',
(object) ['adaptivequizid' => $desctoolarge->id, 'level' => 2, 'description' => 'x']
);
$nodesc = $aqgenerator->create_instance(['course' => $course->id, 'lowestlevel' => 1, 'highestlevel' => 5]);
// Simulate the pre-upgrade state: the feature is off everywhere, plugin one version behind.
$DB->set_field('adaptivequiz', 'competencyfeedbackenable', 0);
set_config('version', 2026062511, 'mod_adaptivequiz');
// Run only the 2026062512 migration step.
xmldb_adaptivequiz_upgrade(2026062511);
$enabled = fn($id) => (int) $DB->get_field('adaptivequiz', 'competencyfeedbackenable', ['id' => $id]);
$this->assertSame(1, $enabled($descvalid->id));
$this->assertSame(0, $enabled($desctoolarge->id));
$this->assertSame(0, $enabled($nodesc->id));
}
/**
* Provide input data to the parameters of the test_questioncat_association_insert() method.
*/
public function questioncat_association_records() {
$data = array();
$adaptivequiz = new stdClass();
$adaptivequiz->questionpool = array(1, 2, 3, 4);
$data[] = array(1, $adaptivequiz);
$adaptivequiz = new stdClass();
$adaptivequiz->questionpool = array(1, 2);
$data[] = array(2, $adaptivequiz);
$adaptivequiz = new stdClass();
$adaptivequiz->questionpool = array(1, 2, 4);
$data[] = array(3, $adaptivequiz);
return $data;
}
/**
* This function tests the removal of an activity instance and all related data.
*/
public function test_adaptivequiz_delete_instance() {
global $DB;
$this->resetAfterTest();
$modgenerator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$course = $this->getDataGenerator()->create_course();
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'questionpool' => [],
]);
adaptivequiz_delete_instance($adaptivequiz->id);
$this->assertEquals(0, $DB->count_records('adaptivequiz', ['id' => $adaptivequiz->id]));
$this->assertEquals(0, $DB->count_records('adaptivequiz_question', ['instance' => $adaptivequiz->id]));
$this->assertEquals(0, $DB->count_records('adaptivequiz_attempt', ['instance' => $adaptivequiz->id]));
$this->assertEquals(0, $DB->count_records('question_usages', ['id' => $adaptivequiz->id]));
}
/**
* This function tests the output from adaptivequiz_print_recent_mod_activity().
*/
public function test_adaptivequiz_print_recent_mod_activity_details_true() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$dummy->user = new stdClass();
$dummy->user->id = 2;
$dummy->user->fullname = 'user-phpunit';
$dummy->user->alternatename = 'user-phpunit';
$dummy->user->picture = '';
$dummy->user->firstname = 'user';
$dummy->user->middlename = '-';
$dummy->user->lastname = 'phpunit';
$dummy->user->imagealt = '';
$dummy->user->email = 'a@a.com';
$dummy->user->firstnamephonetic = 'user';
$dummy->user->lastnamephonetic = 'phpunit';
$dummy->content = new stdClass();
$dummy->content->attemptstate = attempt_state::IN_PROGRESS;
$dummy->content->questionsattempted = '12';
$dummy->timestamp = 1234;
$dummy->type = 'mod_adaptivequiz';
$dummy->name = 'my-phpunit-test';
$dummy->cmid = 99;
$output = adaptivequiz_print_recent_mod_activity($dummy, 1, true, array('mod_adaptivequiz' => 'adaptivequiz'), true, true);
$this->assertStringContainsString('<table', $output);
$this->assertStringContainsString('<tr>', $output);
$this->assertStringContainsString('<td', $output);
$this->assertStringContainsString('mod/adaptivequiz/view.php?id=99', $output);
$this->assertStringContainsString('/user/view.php?id=2', $output);
$this->assertStringContainsString('user phpunit', $output);
$this->assertStringContainsString('my-phpunit-test', $output);
}
/**
* This function tests the output from adaptivequiz_print_recent_mod_activity().
*/
public function test_adaptivequiz_print_recent_mod_activity_details_false() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$dummy->user = new stdClass();
$dummy->user->id = 2;
$dummy->user->fullname = 'user-phpunit';
$dummy->user->alternatename = 'user-phpunit';
$dummy->user->picture = '';
$dummy->user->firstname = 'user';
$dummy->user->middlename = '-';
$dummy->user->lastname = 'phpunit';
$dummy->user->imagealt = '';
$dummy->user->email = 'a@a.com';
$dummy->user->firstnamephonetic = 'user';
$dummy->user->lastnamephonetic = 'phpunit';
$dummy->content = new stdClass();
$dummy->content->attemptstate = attempt_state::IN_PROGRESS;
$dummy->content->questionsattempted = '12';
$dummy->timestamp = 1234;
$dummy->type = 'mod_adaptivequiz';
$dummy->name = 'my-phpunit-test';
$dummy->cmid = 99;
$output = adaptivequiz_print_recent_mod_activity($dummy, 1, false, array('mod_adaptivequiz' => 'adaptivequiz'), true, true);
$this->assertStringContainsString('<table', $output);
$this->assertStringContainsString('<tr>', $output);
$this->assertStringContainsString('<td', $output);
$this->assertStringContainsString('/user/view.php?id=2', $output);
$this->assertStringContainsString('user phpunit', $output);
}
/**
* Tests how fields related to item administration settings are updated with the dedicated function.
*/
public function test_item_administration_params_can_be_updated_for_an_adaptive_quiz_instance(): void {
global $DB;
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
$course = $coregenerator->create_course();
$adaptivequiz = $adaptivequizgenerator->create_instance(['name' => 'Adaptive Quiz', 'course' => $course->id]);
$updatedata = (object) [
'id' => $adaptivequiz->id,
'highestlevel' => 100,
'lowestlevel' => 1,
'startinglevel' => 50,
'minimumquestions' => 20,
'maximumquestions' => 100,
'standarderror' => 9,
'acceptancethreshold' => 0.75,
'questionchecktrigger' => 5,
];
adaptivequiz_update_item_administration_params($updatedata);
$adaptivequizupdated = $DB->get_record('adaptivequiz', ['id' => $adaptivequiz->id], '*', MUST_EXIST);
$expected = ['highestlevel' => 100, 'lowestlevel' => 1, 'startinglevel' => 50, 'minimumquestions' => 20,
'maximumquestions' => 100, 'standarderror' => 9, 'acceptancethreshold' => 0.75, 'questionchecktrigger' => 5];
self::assertEquals($expected, [
'highestlevel' => $adaptivequizupdated->highestlevel,
'lowestlevel' => $adaptivequizupdated->lowestlevel,
'startinglevel' => $adaptivequizupdated->startinglevel,
'minimumquestions' => $adaptivequizupdated->minimumquestions,
'maximumquestions' => $adaptivequizupdated->maximumquestions,
'standarderror' => $adaptivequizupdated->standarderror,
'acceptancethreshold' => $adaptivequizupdated->acceptancethreshold,
'questionchecktrigger' => $adaptivequizupdated->questionchecktrigger,
]);
}
/**
* Tests no other fields are updated by the function related to item administration settings.
*
* @param string $exceptionmessage An empty string if no exception is expected.
* @param array $fields An array of [key => value] properties for adaptivequiz instance.
*/
#[DataProvider('item_administration_params_data')]
public function test_item_administration_params_update_cannot_affect_other_adaptivequiz_properties(
string $exceptionmessage,
array $fields
): void {
global $DB;
$this->resetAfterTest();
$coregenerator = $this->getDataGenerator();
/** @var \mod_adaptivequiz_generator $adaptivequizgenerator */
$adaptivequizgenerator = $coregenerator->get_plugin_generator('mod_adaptivequiz');
$course = $coregenerator->create_course();
$adaptivequiz = $adaptivequizgenerator->create_instance(['name' => 'Adaptive Quiz', 'course' => $course->id]);
$updatedata = (object) array_merge(['id' => $adaptivequiz->id], $fields);
if ($exceptionmessage) {
$this->expectExceptionMessage($exceptionmessage);
}
adaptivequiz_update_item_administration_params($updatedata);
$adaptivequizupdated = $DB->get_record('adaptivequiz', ['id' => $adaptivequiz->id], '*', MUST_EXIST);
self::assertEquals($adaptivequiz->name, $adaptivequizupdated->name);
}
/**
* Data provider for test_item_administration_params_update_cannot_affect_other_adaptivequiz_properties().
*/
public static function item_administration_params_data(): array {
return [
'empty_fieldset_left' => [
'exceptionmessage' => 'moodle_database::update_record_raw() no fields found.',
'fields' => [
'name' => 'Adaptive Quiz edited name',
],
],
'with_some_unexpected_fields' => [
'exceptionmessage' => '',
'fields' => [
'highestlevel' => 120,
'name' => 'Adaptive Quiz edited name',
],
],
];
}
}
<?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;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot.'/mod/adaptivequiz/locallib.php');
use advanced_testcase;
use context_module;
use mod_adaptivequiz\local\attempt\attempt_state;
use PHPUnit\Framework\Attributes\CoversClass;
use question_engine;
use stdClass;
/**
* Tests for the attempt 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\local\attempt::class)]
final class attempt_test extends advanced_testcase {
/** @var stdClass $activityinstance adaptivequiz activity instance object */
protected $activityinstance = null;
/** @var stdClass $cm a partially completed course module object */
protected $cm = null;
/** @var stdClass $user a user object */
protected $user = null;
/**
* This function creates a default user and activity instance using generator classes
* The activity parameters created are are follows:
* lowest difficulty level: 1
* highest difficulty level: 10
* minimum question attempts: 2
* maximum question attempts: 10
* standard error: 1.1
* starting level: 5
* question category ids: 1
* course id: 2
* @return void
*/
protected function setup_generator_data() {
// Create test user.
$this->user = $this->getDataGenerator()->create_user();
$this->setUser($this->user);
$this->setAdminUser();
// Create activity.
$generator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$options = array(
'highestlevel' => 10,
'lowestlevel' => 1,
'minimumquestions' => 2,
'maximumquestions' => 10,
'standarderror' => 1.1,
'startinglevel' => 5,
'questionpool' => array(1),
'course' => 1
);
$this->activityinstance = $generator->create_instance($options);
$this->cm = new stdClass();
$this->cm->id = $this->activityinstance->cmid;
}
/**
* This function tests retrieving an adaptivequiz attempt record.
*/
public function test_get_attempt() {
$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 = $this->getDataGenerator()->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();
$data = $attempt->get_attempt();
unset($data->id);
unset($data->timecreated);
unset($data->timemodified);
// Cast the float values to eliminate the data representation issues.
$data->difficultysum = (float) $data->difficultysum;
$data->measure = (float) $data->measure;
$this->assertEquals((object) [
'instance' => $adaptivequiz->id,
'userid' => $user->id,
'uniqueid' => (string) $uniqueid,
'attemptstate' => attempt_state::IN_PROGRESS,
'questionsattempted' => '0',
'attemptstopcriteria' => '',
'standarderror' => '999.00000',
'difficultysum' => '0.0000000',
'measure' => '0.00000',
], $data);
}
public function test_it_fails_to_set_quba_for_an_attempt_with_an_invalid_argument(): void {
$this->resetAfterTest(true);
$this->setup_generator_data();
$this->activityinstance->context = context_module::instance($this->cm->id);
$adaptiveattempt = new attempt($this->activityinstance, $this->user->id);
$this->expectException('coding_exception');
$adaptiveattempt->set_quba(new stdClass());
}
/**
* This function tests the accessor methods for question_usage_by_activity ($quba) property
*/
public function test_set_get_quba() {
$this->resetAfterTest(true);
$this->setup_generator_data();
$this->activityinstance->context = context_module::instance($this->cm->id);
$adaptiveattempt = new attempt($this->activityinstance, $this->user->id);
// Test a non initialized quba.
$this->assertNull($adaptiveattempt->get_quba());
// Test a property initializes quba.
$mockquba = $this->getMockBuilder('question_usage_by_activity')
->disableOriginalConstructor()
->getMock();
$adaptiveattempt->set_quba($mockquba);
$this->assertInstanceOf('question_usage_by_activity', $adaptiveattempt->get_quba());
}
/**
* This function tests whether the user's attempt has exceeded the maximum number of questions attempted.
*/
public function test_max_questions_answered(): 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' => 3,
]);
$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();
$this->assertFalse($attempt->max_questions_answered());
$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 reloads the attempt.
$attempt->get_attempt();
$this->assertFalse($attempt->max_questions_answered());
adaptivequiz_update_attempt_data($uniqueid, $adaptivequiz->id, $user->id, $difflogit, $standarderror, $measure);
$attempt->get_attempt();
$this->assertFalse($attempt->max_questions_answered());
adaptivequiz_update_attempt_data($uniqueid, $adaptivequiz->id, $user->id, $difflogit, $standarderror, $measure);
$attempt->get_attempt();
$this->assertTrue($attempt->max_questions_answered());
}
/**
* This function tests whether the user's attempt has met the minimum number of questions attempted.
*/
public function test_min_questions_answered(): 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' => 2,
'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();
$this->assertFalse($attempt->min_questions_answered());
$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 reloads the attempt.
$attempt->get_attempt();
$this->assertFalse($attempt->min_questions_answered());
adaptivequiz_update_attempt_data($uniqueid, $adaptivequiz->id, $user->id, $difflogit, $standarderror, $measure);
$attempt->get_attempt();
$this->assertFalse($attempt->min_questions_answered());
adaptivequiz_update_attempt_data($uniqueid, $adaptivequiz->id, $user->id, $difflogit, $standarderror, $measure);
$attempt->get_attempt();
$this->assertTrue($attempt->min_questions_answered());
}
/**
* This function test that one value is returned or an empty array is returned
*/
public function test_return_random_question() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$dummy->id = 220;
$dummy->minimumquestions = 21;
$userid = 2;
$adaptiveattempt = new attempt($dummy, $userid);
$result = $adaptiveattempt->return_random_question(array());
$this->assertEquals(0, $result);
$result = (string) $adaptiveattempt->return_random_question(
[1 => 'quest 1', 2 => 'quest 2', 3 => 'quest 3', 4 => 'quest 4']
);
$this->assertMatchesRegularExpression('/[1-4]/', $result);
}
/**
* This function tests the creation of a question_usage_by_activity object for an attempt
*/
public function test_initialize_quba() {
$this->resetAfterTest(true);
$this->setup_generator_data();
$this->activityinstance->context = context_module::instance($this->cm->id);
$adaptiveattempt = new attempt($this->activityinstance, $this->user->id);
$adaptiveattempt->get_attempt();
$quba = $adaptiveattempt->initialize_quba();
$this->assertInstanceOf('question_usage_by_activity', $quba);
}
/**
* This function tests the re-using of the question_usage_by_activity object, due to an incomplete attempt
*/
public function test_initialize_quba_with_existing_attempt_uniqueid(): 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 = $this->getDataGenerator()->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' => 2,
'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 = $this->getDataGenerator()->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();
$adaptiveattempt = new attempt($adaptivequizforattempt, $user->id);
$adaptiveattempt->get_attempt();
$quba = $adaptiveattempt->initialize_quba();
$this->assertInstanceOf('question_usage_by_activity', $quba);
$this->assertEquals($uniqueid, $quba->get_id());
}
/**
* This function tests retrieving the last question viewed by the student for a given attempt.
*/
public function test_find_last_quest_used_by_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);
$question1 = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$question2 = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$startinglevel = 1;
$questiongenerator->create_question_tag(['questionid' => $question1->id, 'tag' => "adpq_$startinglevel"]);
$questiongenerator->create_question_tag(['questionid' => $question2->id, 'tag' => "adpq_$startinglevel"]);
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 2,
'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();
$attempt->get_attempt();
$quba = $attempt->initialize_quba();
$slot = $attempt->find_last_quest_used_by_attempt($quba);
self::assertEquals(1, $slot);
// 'Answer' the question as the user.
$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);
// These are not relevant for this test when updating an attempt.
$difflogit = 0;
$standarderror = 0;
$measure = 0;
adaptivequiz_update_attempt_data($quba->get_id(), $adaptivequiz->id, $user->id, $difflogit, $standarderror, $measure);
$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);
self::assertEquals(2, $slot);
}
public function test_it_fails_to_find_the_last_question_used_by_attempt_with_an_invalid_argument(): void {
$this->resetAfterTest();
$dummy = new stdClass();
$adaptiveattempt = new attempt($dummy, 2);
$this->expectException('coding_exception');
$adaptiveattempt->find_last_quest_used_by_attempt($dummy);
}
/**
* This function tests retrieving the last question viewed by the student for a given attempt, but using failing data
*/
public function test_find_last_umarked_question_using_bad_data() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$adaptiveattempt = $this->getMockBuilder(attempt::class)
->disableOriginalConstructor()
->getMock();
// Test passing an invalid object instance.
$result = $adaptiveattempt->find_last_quest_used_by_attempt($dummy);
$this->assertEquals(0, $result);
// Test getting an empty slot value.
$mockquba = $this->getMockBuilder('question_usage_by_activity')
->disableOriginalConstructor()
->getMock();
$result = $adaptiveattempt->find_last_quest_used_by_attempt($mockquba);
$this->assertEquals(0, $result);
}
/**
* This function tests whether the user submitted an answer to the question
*/
public function test_was_answer_submitted_to_question_with_graded_right() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$adaptiveattempt = new attempt($dummy, 1);
$mockquba = $this->getMockBuilder('question_usage_by_activity')
->disableOriginalConstructor()
->getMock();
$mockstate = $this->createMock('question_state_gradedright', array(), array(), '', false);
$mockquba->expects($this->once())
->method('get_question_state')
->with(1)
->willReturn($mockstate);
$mockquba->expects($this->never())
->method('get_id');
$result = $adaptiveattempt->was_answer_submitted_to_question($mockquba, 1);
$this->assertTrue($result);
}
/**
* This function tests whether the user submitted an answer to the question
*/
public function test_was_answer_submitted_to_question_with_graded_wrong() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$adaptiveattempt = new attempt($dummy, 1);
$mockquba = $this->getMockBuilder('question_usage_by_activity')
->disableOriginalConstructor()
->getMock();
$mockstate = $this->createMock('question_state_gradedwrong', array(), array(), '', false);
$mockquba->expects($this->once())
->method('get_question_state')
->with(1)
->willReturn($mockstate);
$mockquba->expects($this->never())
->method('get_id');
$result = $adaptiveattempt->was_answer_submitted_to_question($mockquba, 1);
$this->assertTrue($result);
}
/**
* This function tests whether the user submitted an answer to the question
*/
public function test_was_answer_submitted_to_question_with_graded_partial() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$adaptiveattempt = new attempt($dummy, 1);
$mockquba = $this->getMockBuilder('question_usage_by_activity')
->disableOriginalConstructor()
->getMock();
$mockstate = $this->createMock('question_state_gradedpartial', array(), array(), '', false);
$mockquba->expects($this->once())
->method('get_question_state')
->with(1)
->willReturn($mockstate);
$mockquba->expects($this->never())
->method('get_id');
$result = $adaptiveattempt->was_answer_submitted_to_question($mockquba, 1);
$this->assertTrue($result);
}
/**
* This function tests whether the user submitted an answer to the question
*/
public function test_was_answer_submitted_to_question_with_graded_gaveup() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$adaptiveattempt = new attempt($dummy, 1);
$mockquba = $this->getMockBuilder('question_usage_by_activity')
->disableOriginalConstructor()
->getMock();
$mockstate = $this->createMock('question_state_gaveup', array(), array(), '', false);
$mockquba->expects($this->once())
->method('get_question_state')
->with(1)
->willReturn($mockstate);
$mockquba->expects($this->never())
->method('get_id');
$result = $adaptiveattempt->was_answer_submitted_to_question($mockquba, 1);
$this->assertTrue($result);
}
/**
* This function tests whether the user submitted an answer to the question
*/
public function test_was_answer_submitted_to_question_with_graded_todo() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$adaptiveattempt = new attempt($dummy, 1);
$mockquba = $this->getMockBuilder('question_usage_by_activity')
->disableOriginalConstructor()
->getMock();
$mockstate = $this->createMock('question_state_todo', array(), array(), '', false);
$mockquba->expects($this->once())
->method('get_question_state')
->with(1)
->willReturn($mockstate);
$mockquba->expects($this->once())
->method('get_id')
->willReturn(1);
$result = $adaptiveattempt->was_answer_submitted_to_question($mockquba, 1);
$this->assertFalse($result);
}
/**
* This function tests the accessor method for $slot
*/
public function test_set_get_question_slot_number() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$userid = 1;
$adaptiveattempt = new attempt($dummy, $userid);
$adaptiveattempt->set_question_slot_number(2);
$result = $adaptiveattempt->get_question_slot_number();
$this->assertEquals(2, $result);
}
/**
* This function tests the accessor method for $slot
*/
public function test_set_get_question_level() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$userid = 1;
$adaptiveattempt = new attempt($dummy, $userid);
$adaptiveattempt->set_level(2);
$result = $adaptiveattempt->get_level();
$this->assertEquals(2, $result);
}
/**
* This function tests results returned from get_question_mark()
*/
public function test_get_question_mark_with_quba_return_float() {
$this->resetAfterTest(true);
// Test quba returning a mark of 1.0.
$mockquba = $this->createMock('question_usage_by_activity', array(), array(), '', false);
$mockquba->expects($this->once())
->method('get_question_mark')
->willReturn(1.0);
$dummy = new stdClass();
$userid = 1;
$adaptiveattempt = new attempt($dummy, $userid);
$result = $adaptiveattempt->get_question_mark($mockquba, 1);
$this->assertEquals(1.0, $result);
}
/**
* This function tests results returned from get_question_mark()
*/
public function test_get_question_mark_with_quba_return_non_float() {
$this->resetAfterTest(true);
// Test quba returning a non float value.
$mockqubatwo = $this->createMock('question_usage_by_activity', array(), array(), '', false);
$mockqubatwo->expects($this->once())
->method('get_question_mark')
->willReturn(0);
$dummy = new stdClass();
$userid = 1;
$adaptiveattempttwo = new attempt($dummy, $userid);
$result = $adaptiveattempttwo->get_question_mark($mockqubatwo, 1);
$this->assertEquals(0, $result);
}
/**
* This function tests results returned from get_question_mark()
*/
public function test_get_question_mark_with_quba_return_non_null() {
$this->resetAfterTest(true);
// Test quba returning null.
$mockqubathree = $this->createMock('question_usage_by_activity', array(), array(), '', false);
$mockqubathree->expects($this->once())
->method('get_question_mark')
->willReturn(null);
$dummy = new stdClass();
$userid = 1;
$adaptiveattemptthree = new attempt($dummy, $userid);
$result = $adaptiveattemptthree->get_question_mark($mockqubathree, 1);
$this->assertEquals(0, $result);
}
/**
* This function tests what happens when the maximum number of questions have been answered
*/
public function test_start_attempt_max_num_of_quest_answered() {
$this->resetAfterTest(true);
$attempt = $this->createPartialMock(attempt::class,
['get_attempt', 'level_in_bounds', 'max_questions_answered']);
$attempt->method('get_attempt')->willReturn(new stdClass());
$attempt->method('level_in_bounds')->willReturn(true);
$attempt->method('max_questions_answered')->willReturn(true);
$this->assertFalse($attempt->start_attempt());
}
/**
* This function tests what happens when a question slot number is not found, but the number of submitted answers is greater
* than zero.
*/
public function test_start_attempt_quest_slot_empty_quest_submit_greater_than_one() {
$dummyadaptivequiz = new stdClass();
$dummyadaptivequiz->lowestlevel = 1;
$dummyadaptivequiz->highestlevel = 100;
$mockattemptthree = $this
->getMockBuilder(attempt::class)
->onlyMethods(
['get_attempt', 'max_questions_answered', 'initialize_quba', 'find_last_quest_used_by_attempt',
'level_in_bounds']
)
->setConstructorArgs(
[$dummyadaptivequiz, 1]
)
->getMock();
$dummyattempt = new stdClass();
$dummyattempt->questionsattempted = 1;
$mockattemptthree->expects($this->once())
->method('get_attempt')
->willReturn($dummyattempt);
$mockattemptthree->expects($this->once())
->method('level_in_bounds')
->willReturn(true);
$mockattemptthree->expects($this->once())
->method('max_questions_answered')
->willReturn(false);
$mockattemptthree->expects($this->once())
->method('initialize_quba');
$mockattemptthree->expects($this->once())
->method('find_last_quest_used_by_attempt')
->willReturn(0);
$this->assertFalse($mockattemptthree->start_attempt());
}
/**
* This function tests the return values for level_in_bounds()
*/
public function test_level_in_bounds() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$dummy->lowestlevel = 5;
$dummy->highestlevel = 10;
$userid = 1;
$adaptiveattempt = new attempt($dummy, $userid);
$adaptivequiz = $adaptiveattempt->get_adaptivequiz();
$result = $adaptiveattempt->level_in_bounds(6, $adaptivequiz);
$this->assertTrue($result);
}
/**
* This function tests retrieving of question ids.
*/
public function test_get_all_questions_in_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);
$question1 = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$question2 = $questiongenerator->create_question('truefalse', null, ['category' => $qcat->id]);
$startinglevel = 1;
$questiongenerator->create_question_tag(['questionid' => $question1->id, 'tag' => "adpq_$startinglevel"]);
$questiongenerator->create_question_tag(['questionid' => $question2->id, 'tag' => "adpq_$startinglevel"]);
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'startinglevel' => $startinglevel,
'lowestlevel' => 1,
'highestlevel' => 10,
'minimumquestions' => 2,
'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();
$attempt->get_attempt();
$quba = $attempt->initialize_quba();
$slot = $attempt->find_last_quest_used_by_attempt($quba);
// 'Answer' the question as the user.
$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);
// These are not relevant for this test when updating an attempt.
$difflogit = 0;
$standarderror = 0;
$measure = 0;
adaptivequiz_update_attempt_data($quba->get_id(), $adaptivequiz->id, $user->id, $difflogit, $standarderror, $measure);
$attempt = new attempt($adaptivequizforattempt, $user->id);
$attempt->set_level($adaptivequiz->startinglevel);
$attempt->start_attempt();
$questionidlist = $attempt->get_all_questions_in_attempt($quba->get_id());
$this->assertEquals(2, count($questionidlist));
$this->assertEqualsCanonicalizing([
(string) $question1->id,
(string) $question2->id,
], array_values($questionidlist));
}
public function test_it_can_check_if_a_user_has_a_completed_attempt_on_a_quiz(): 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,
]);
$context = context_module::instance($adaptivequiz->cmid);
$modgenerator->create_link_with_question_bank([
'adaptivequizid' => $adaptivequiz->id,
'qbankid' => $qbank->id,
]);
$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->assertTrue(attempt::user_has_completed_on_quiz($adaptivequiz->id, $user->id));
}
}
<?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;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot.'/mod/adaptivequiz/locallib.php');
use advanced_testcase;
use context_module;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use question_usage_by_activity;
use stdClass;
/**
* PHPUnit tests for catalgo 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\local\catalgo::class)]
final class catalgo_test extends advanced_testcase {
/**
* This function tests instantiating the catalgo class without an instance of question_usage_by_activity.
*/
public function test_init_catalgo_no_quba_object_instance() {
$this->resetAfterTest(true);
$this->expectException('coding_exception');
$algo = new catalgo(new stdClass(), 1, true, 1);
}
/**
* This function tests instantiating the catalgo class with a non-positive integer and throwing an exception.
*/
public function test_init_catalgo_negative_int_throw_except() {
$this->resetAfterTest(true);
$mockquba = $this->createMock('question_usage_by_activity', array(), array(), '', false);
$this->expectException('coding_exception');
$algo = new catalgo($mockquba, -1);
}
/**
* This function tests instantiating the catalgo class without setting the level argument.
*/
public function test_init_catalgo_no_level_throw_except() {
$this->resetAfterTest(true);
$mockquba = $this->createMock('question_usage_by_activity', array(), array(), '', false);
$this->expectException('coding_exception');
$algo = new catalgo($mockquba, 1, true);
}
/**
* This function tests the retrieval of an attempt record.
*/
public function test_retrieve_attempt_record(): void {
$this->resetAfterTest();
$modgenerator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$course = $this->getDataGenerator()->create_course();
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'questionpool' => [],
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user = $this->getDataGenerator()->create_user();
$attempt = new attempt($adaptivequizforattempt, $user->id);
$mockquba = $this->createMock('question_usage_by_activity');
// End of setup.
$attemptrecord = $attempt->get_attempt();
$algo = new catalgo($mockquba, $attemptrecord->id, true, 1);
$result = $algo->retrieve_attempt_record($attemptrecord->id);
// Cast the float values to eliminate the data representation issues.
$result->difficultysum = (float) $result->difficultysum;
$result->measure = (float) $result->measure;
$this->assertEquals((object) [
'id' => (string) $attemptrecord->id,
'highestlevel' => $adaptivequiz->highestlevel,
'lowestlevel' => $adaptivequiz->lowestlevel,
'acceptancethreshold' => '0.00',
'difficultysum' => '0.0000000',
'questionsattempted' => '0',
'standarderror' => '999.00000',
'measure' => '0.00000',
], $result);
}
/**
* This function tests the retrieval of using illegible attempt id.
*/
public function test_retrieve_illegit_attempt_record_throw_except(): void {
$this->resetAfterTest();
$modgenerator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$course = $this->getDataGenerator()->create_course();
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'questionpool' => [],
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user = $this->getDataGenerator()->create_user();
$attempt = new attempt($adaptivequizforattempt, $user->id);
$mockquba = $this->createMock('question_usage_by_activity');
// End of setup.
$attemptrecord = $attempt->get_attempt();
$algo = new catalgo($mockquba, $attemptrecord->id, true, 1);
$this->expectException('dml_missing_record_exception');
$algo->retrieve_attempt_record($attemptrecord->id + 1);
}
/**
* This function tests was_answer_submitted_to_question() returning a false instead of a true
*/
public function test_quest_was_marked_correct_no_submit_prev_quest_fail() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(
catalgo::class,
['find_last_quest_used_by_attempt', 'was_answer_submitted_to_question', 'slot_is_correct']
);
$mockcatalgo->expects($this->once())
->method('find_last_quest_used_by_attempt')
->willReturn(99);
$mockcatalgo->expects($this->once())
->method('was_answer_submitted_to_question')
->with(99)
->willReturn(false);
$mockcatalgo->expects($this->never())
->method('slot_is_correct');
$result = $mockcatalgo->question_was_marked_correct();
$this->assertFalse($result);
}
/**
* This function tests was_answer_submitted_to_question() returning a 0 instead of a slot number
*/
public function test_quest_was_marked_correct_zero_slot_number_fail() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(catalgo::class, ['find_last_quest_used_by_attempt', 'slot_is_correct']);
$mockcatalgo->expects($this->once())
->method('find_last_quest_used_by_attempt')
->willReturn(0);
$mockcatalgo->expects($this->never())
->method('slot_is_correct');
$result = $mockcatalgo->question_was_marked_correct();
$this->assertNull($result);
}
/**
* This function tests get_question_mark() returning null
*/
public function test_quest_was_marked_correct_mark_is_null_fail() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(
catalgo::class,
['find_last_quest_used_by_attempt', 'was_answer_submitted_to_question', 'slot_is_correct']
);
$mockcatalgo->expects($this->once())
->method('find_last_quest_used_by_attempt')
->willReturn(99);
$mockcatalgo->expects($this->once())
->method('was_answer_submitted_to_question')
->with(99)
->willReturn(true);
$mockcatalgo->expects($this->once())
->method('slot_is_correct')
->withAnyParameters()
->willReturn(null);
$result = $mockcatalgo->question_was_marked_correct();
$this->assertNull($result);
}
/**
* This function tests get_question_mark() returning a mark of zero
*/
public function test_quest_was_marked_correct_mark_zero() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(
catalgo::class,
['find_last_quest_used_by_attempt', 'was_answer_submitted_to_question', 'slot_is_correct']
);
$mockcatalgo->expects($this->once())
->method('find_last_quest_used_by_attempt')
->willReturn(99);
$mockcatalgo->expects($this->once())
->method('was_answer_submitted_to_question')
->with(99)
->willReturn(true);
$mockcatalgo->expects($this->once())
->method('slot_is_correct')
->withAnyParameters()
->willReturn(false);
$result = $mockcatalgo->question_was_marked_correct();
$this->assertFalse($result);
}
/**
* This function tests get_question_mark() returning a mark of greater than zero
*/
public function test_quest_was_marked_correct_mark_non_zero() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(
catalgo::class,
['find_last_quest_used_by_attempt', 'was_answer_submitted_to_question', 'slot_is_correct']
);
$mockcatalgo->expects($this->once())
->method('find_last_quest_used_by_attempt')
->willReturn(99);
$mockcatalgo->expects($this->once())
->method('was_answer_submitted_to_question')
->with(99)
->willReturn(true);
$mockcatalgo->expects($this->once())
->method('slot_is_correct')
->withAnyParameters()
->willReturn(true);
$result = $mockcatalgo->question_was_marked_correct();
$this->assertTrue($result);
}
/**
* This function tests retrieve_standard_error(), retrieving the standard error value set for the activity.
*/
public function test_retrieve_standard_error(): void {
$this->resetAfterTest();
$modgenerator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$course = $this->getDataGenerator()->create_course();
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'standarderror' => 15,
'questionpool' => [],
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user = $this->getDataGenerator()->create_user();
$attempt = new attempt($adaptivequizforattempt, $user->id);
$mockquba = $this->createMock('question_usage_by_activity');
// End of setup.
$attemptrecord = $attempt->get_attempt();
$algo = new catalgo($mockquba, $attemptrecord->id, true, 1);
$result = $algo->retrieve_standard_error($attemptrecord->id);
$this->assertEquals($adaptivequiz->standarderror, $result);
}
/**
* This function tests retrieve_standard_error() with illegible attempt id.
*/
public function test_retrieve_standard_error_throw_excep(): void {
$this->resetAfterTest();
$modgenerator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$course = $this->getDataGenerator()->create_course();
$adaptivequiz = $modgenerator->create_instance([
'course' => $course->id,
'questionpool' => [],
]);
$context = context_module::instance($adaptivequiz->cmid);
$adaptivequizforattempt = clone($adaptivequiz);
$adaptivequizforattempt->context = $context;
$user = $this->getDataGenerator()->create_user();
$attempt = new attempt($adaptivequizforattempt, $user->id);
$mockquba = $this->createMock('question_usage_by_activity');
// End of setup.
$attemptrecord = $attempt->get_attempt();
$algo = new catalgo($mockquba, $attemptrecord->id, true, 1);
$this->expectException('dml_missing_record_exception');
$algo->retrieve_standard_error($attemptrecord->id + 1);
}
/**
* This function tests compute_next_difficulty()
* Setting 0 as the lowest level and 100 as the highest level
*/
public function test_compute_next_difficulty_zero_min_one_hundred_max() {
$this->resetAfterTest(true);
$mockquba = $this->createMock(question_usage_by_activity::class);
$catalgo = new catalgo($mockquba, 1, true, 1);
$dummy = new stdClass();
$dummy->lowestlevel = 0;
$dummy->highestlevel = 100;
// Test the next difficulty level shown to the student if the student got a level 30 question wrong,
// having attempted 1 question.
$result = $catalgo->compute_next_difficulty(30, 1, false, $dummy);
$this->assertEquals(5, $result);
// Test the next difficulty level shown to the student if the student got a level 30 question right,
// having attempted 1 question.
$result = $catalgo->compute_next_difficulty(30, 1, true, $dummy);
$this->assertEquals(76, $result);
// Test the next difficulty level shown to the student if the student got a level 80 question wrong,
// having attempted 2 questions.
$result = $catalgo->compute_next_difficulty(80, 2, false, $dummy);
$this->assertEquals(60, $result);
// Test the next difficulty level shown to the student if the student got a level 80 question right,
// having attempted 2 question.
$result = $catalgo->compute_next_difficulty(80, 2, true, $dummy);
$this->assertEquals(92, $result);
}
/**
* This function tests compute_next_difficulty()
* Setting 1 as the lowest level and 10 as the highest level
*/
public function test_compute_next_difficulty_one_min_ten_max_compute_infinity() {
$this->resetAfterTest(true);
$mockquba = $this->createMock(question_usage_by_activity::class);
$catalgo = new catalgo($mockquba, 1, true, 1);
$dummy = new stdClass();
$dummy->lowestlevel = 1;
$dummy->highestlevel = 10;
$result = $catalgo->compute_next_difficulty(1, 2, false, $dummy);
$this->assertEquals(1, $result);
$result = $catalgo->compute_next_difficulty(10, 2, true, $dummy);
$this->assertEquals(10, $result);
}
/**
* This function tests results returned from get_question_mark()
*/
public function test_get_question_mark() {
$this->resetAfterTest(true);
// Test quba returning a mark of 1.0.
$mockquba = $this->createMock(question_usage_by_activity::class);
$mockquba->expects($this->once())
->method('get_question_mark')
->willReturn(1.0);
$catalgo = new catalgo($mockquba, 1, true, 1);
$result = $catalgo->get_question_mark($mockquba, 1);
$this->assertEquals(1.0, $result);
// Test quba returning a non float value.
$mockqubatwo = $this->createMock('question_usage_by_activity');
$mockqubatwo->expects($this->once())
->method('get_question_mark')
->willReturn(1);
$catalgo = new catalgo($mockqubatwo, 1, true, 1);
$result = $catalgo->get_question_mark($mockqubatwo, 1);
$this->assertNull($result);
}
/**
* This function tests the return data from compute_right_answers()
*/
public function test_compute_right_answers() {
$this->resetAfterTest(true);
// Test use case where user got all 5 questions correct.
$mockquba = $this->createMock('question_usage_by_activity');
$mockquba->expects($this->once())
->method('get_slots')
->willReturn([1, 2, 3, 4, 5]);
$catalgo = $this->createPartialMock(catalgo::class, ['slot_is_correct']);
$catalgo->method('slot_is_correct')->willReturn(true);
$result = $catalgo->compute_right_answers($mockquba);
$this->assertEquals(5, $result);
}
/**
* This function tests the return data from compute_right_answers()
*/
public function test_compute_right_answers_none_correct() {
$this->resetAfterTest(true);
// Test use case where user got all 5 questions incorrect.
$mockquba = $this->createMock('question_usage_by_activity');
$mockquba->expects($this->once())
->method('get_slots')
->willReturn([1, 2, 3, 4, 5]);
$catalgo = $this->createPartialMock(catalgo::class, ['slot_is_correct']);
$catalgo->method('slot_is_correct')->willReturn(false);
$result = $catalgo->compute_right_answers($mockquba);
$this->assertEquals(0, $result);
}
/**
* This function tests the return data from compute_right_answers() when get_question_mark() returns a null
*/
public function test_compute_right_answers_null_return_from_get_question_mark() {
$this->resetAfterTest(true);
$mockquba = $this->createMock('question_usage_by_activity');
$mockquba->expects($this->once())
->method('get_slots')
->willReturn([1]);
// An undeterminable slot (null) must not be counted as correct.
$catalgo = $this->createPartialMock(catalgo::class, ['slot_is_correct']);
$catalgo->method('slot_is_correct')->willReturn(null);
$result = $catalgo->compute_right_answers($mockquba);
$this->assertEquals(0, $result);
}
/**
* This function tests the return data from compute_right_answers()
*/
public function test_compute_wrong_answers() {
$this->resetAfterTest(true);
// Test use case where user got all 5 questions incorrect.
$mockquba = $this->createMock('question_usage_by_activity');
$mockquba->expects($this->once())
->method('get_slots')
->willReturn([1, 2, 3, 4, 5]);
$catalgo = $this->createPartialMock(catalgo::class, ['slot_is_correct']);
$catalgo->method('slot_is_correct')->willReturn(false);
$result = $catalgo->compute_wrong_answers($mockquba);
$this->assertEquals(5, $result);
}
/**
* This function tests the return data from compute_right_answers()
*/
public function test_compute_wrong_answers_all_correct() {
$this->resetAfterTest(true);
// Test use case where user got all 5 questions correct.
$mockquba = $this->createMock('question_usage_by_activity');
$mockquba->expects($this->once())
->method('get_slots')
->willReturn([1, 2, 3, 4, 5]);
$catalgo = $this->createPartialMock(catalgo::class, ['slot_is_correct']);
$catalgo->method('slot_is_correct')->willReturn(true);
$result = $catalgo->compute_wrong_answers($mockquba);
$this->assertEquals(0, $result);
}
/**
* This function tests the return data from compute_wrong_answers() when get_question_mark() returns a null
*/
public function test_compute_wrong_answers_null_return_from_get_question_mark() {
$this->resetAfterTest(true);
$mockquba = $this->createMock('question_usage_by_activity');
$mockquba->expects($this->once())
->method('get_slots')
->willReturn([1]);
// An undeterminable slot (null) must be counted as wrong.
$catalgo = $this->createPartialMock(catalgo::class, ['slot_is_correct']);
$catalgo->method('slot_is_correct')->willReturn(null);
$result = $catalgo->compute_wrong_answers($mockquba);
$this->assertEquals(1, $result);
}
/**
* This function tests the return data from estimate_measure()
*/
public function test_estimate_measure() {
$this->resetAfterTest(true);
$mockquba = $this->createMock('question_usage_by_activity', array(), array(), '', false);
// Test an attempt with the following details:
// sum of difficulty - 20, number of questions attempted - 10, number of correct answers - 7,
// number of incorrect answers - 3.
$catalgo = new catalgo($mockquba, 1, true, 1);
$result = $catalgo->estimate_measure(20, 10, 7, 3);
$this->assertEquals(2.8473, $result);
}
/**
* This function tests the return data from estimate_standard_error()
*/
public function test_estimate_standard_error() {
$this->resetAfterTest(true);
$mockquba = $this->createMock('question_usage_by_activity', array(), array(), '', false);
// Test an attempt with the following details;
// sum of questions attempted - 10, number of correct answers - 7, number of incorrect answers - 3.
$catalgo = new catalgo($mockquba, 1, true, 1);
$result = $catalgo->estimate_standard_error(10, 7, 3);
$this->assertEquals(0.69007, $result);
}
/**
* This function tests the return data from perform_calculation_steps(), where question_was_marked_correct() returns null
*/
public function test_perform_calc_steps_marked_correct_return_null_fail() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(catalgo::class, ['retrieve_attempt_record',
'question_was_marked_correct', 'compute_next_difficulty', 'compute_right_answers', 'compute_wrong_answers',
'estimate_measure', 'estimate_standard_error', 'retrieve_standard_error',
'standard_error_within_parameters']);
$dummyattempt = new stdClass();
$dummyattempt->questionsattempted = 10;
$dummyattempt->difficultysum = 20;
$mockcatalgo->expects($this->once())
->method('retrieve_attempt_record')
->willReturn($dummyattempt);
$mockcatalgo->expects($this->once())
->method('question_was_marked_correct')
->willReturn(null); // Questions marked correctly returns null.
$mockcatalgo->expects($this->never())
->method('compute_next_difficulty');
$mockcatalgo->expects($this->never())
->method('compute_right_answers');
$mockcatalgo->expects($this->never())
->method('compute_wrong_answers');
$mockcatalgo->expects($this->never())
->method('estimate_measure');
$mockcatalgo->expects($this->never())
->method('estimate_standard_error');
$mockcatalgo->expects($this->never())
->method('retrieve_standard_error');
$mockcatalgo->expects($this->never())
->method('standard_error_within_parameters');
$result = $mockcatalgo->perform_calculation_steps();
$this->assertEquals(0, $result);
}
/**
* This function tests the return data from perform_calculation_steps(), where compute_right_answers() returns 0,
* but the next difficulty number is returned.
*/
public function test_perform_calc_steps_right_ans_ret_zero_but_return_non_zero() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(catalgo::class, ['retrieve_attempt_record',
'question_was_marked_correct', 'compute_next_difficulty', 'compute_right_answers', 'compute_wrong_answers',
'retrieve_standard_error']);
$dummyattempt = new stdClass();
$dummyattempt->questionsattempted = 10;
$dummyattempt->difficultysum = 20;
$mockcatalgo->expects($this->once())
->method('retrieve_attempt_record')
->willReturn($dummyattempt);
$mockcatalgo->expects($this->once())
->method('question_was_marked_correct')
->willReturn(true);
$mockcatalgo->expects($this->once())
->method('compute_next_difficulty')
->willReturn(30);
$mockcatalgo->expects($this->once())
->method('compute_right_answers')
->willReturn(0); // Right answers is set to zero.
$mockcatalgo->expects($this->once())
->method('compute_wrong_answers')
->willReturn(10); // Wrong answers is set to 10.
$mockcatalgo->expects($this->once())
->method('retrieve_standard_error')
->willReturn(1.45095);
$result = $mockcatalgo->perform_calculation_steps();
$this->assertNotEquals(0, $result);
}
/**
* This function tests the return data from perform_calculation_steps(), where compute_wrong_answers() returns 0,
* but the next difficulty number is returned.
*/
public function test_perform_calc_steps_wrong_ans_return_zero_but_return_non_zero() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(catalgo::class, ['retrieve_attempt_record', 'question_was_marked_correct',
'compute_next_difficulty', 'compute_right_answers', 'compute_wrong_answers', 'retrieve_standard_error']);
$dummyattempt = new stdClass();
$dummyattempt->questionsattempted = 10;
$dummyattempt->difficultysum = 20;
$mockcatalgo->expects($this->once())
->method('retrieve_attempt_record')
->willReturn($dummyattempt);
$mockcatalgo->expects($this->any())
->method('question_was_marked_correct')
->willReturn(true);
$mockcatalgo->expects($this->once())
->method('compute_next_difficulty')
->willReturn(30);
$mockcatalgo->expects($this->once())
->method('compute_right_answers')
->willReturn(10); // Right answers is set to 10.
$mockcatalgo->expects($this->once())
->method('compute_wrong_answers')
->willReturn(0); // Wrong answers is set to zero.
$mockcatalgo->expects($this->once())
->method('retrieve_standard_error')
->willReturn(1.45095);
$result = $mockcatalgo->perform_calculation_steps();
$this->assertNotEquals(0, $result);
}
/**
* This function tests the return data from perform_calculation_steps(), where questions attempted is set to 0
*/
public function test_perform_calc_steps_quest_attempted_return_zero_fail() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(catalgo::class, ['retrieve_attempt_record',
'question_was_marked_correct', 'compute_next_difficulty', 'compute_right_answers', 'compute_wrong_answers',
'estimate_measure', 'estimate_standard_error', 'retrieve_standard_error',
'standard_error_within_parameters']);
$dummyattempt = new stdClass();
$dummyattempt->questionsattempted = 0; // Questions attempted is set to zero.
$dummyattempt->difficultysum = 20;
$mockcatalgo->expects($this->once())
->method('retrieve_attempt_record')
->willReturn($dummyattempt);
$mockcatalgo->expects($this->once())
->method('question_was_marked_correct')
->willReturn(true);
$mockcatalgo->expects($this->once())
->method('compute_next_difficulty')
->willReturn(30);
$mockcatalgo->expects($this->once())
->method('compute_right_answers')
->willReturn(1);
$mockcatalgo->expects($this->once())
->method('compute_wrong_answers')
->willReturn(1);
$mockcatalgo->expects($this->never())
->method('estimate_measure');
$mockcatalgo->expects($this->never())
->method('estimate_standard_error');
$mockcatalgo->expects($this->never())
->method('retrieve_standard_error');
$mockcatalgo->expects($this->never())
->method('standard_error_within_parameters');
$result = $mockcatalgo->perform_calculation_steps();
$this->assertEquals(0, $result);
}
/**
* This function tests the return data from perform_calculation_steps(), where the sum of correct and incorrect answers
* does not equal the sum of questions attempted.
*/
public function test_perform_calc_steps_sum_corr_and_incorr_not_equl_sum_quest_attempt_fail() {
$this->resetAfterTest(true);
$mockcatalgo = $this->createPartialMock(catalgo::class, ['retrieve_attempt_record', 'question_was_marked_correct',
'compute_next_difficulty', 'compute_right_answers', 'compute_wrong_answers', 'estimate_measure',
'estimate_standard_error', 'retrieve_standard_error', 'standard_error_within_parameters']);
$dummyattempt = new stdClass();
$dummyattempt->questionsattempted = 3; // Sum of question attempted.
$dummyattempt->difficultysum = 20;
$mockcatalgo->expects($this->once())
->method('retrieve_attempt_record')
->willReturn($dummyattempt);
$mockcatalgo->expects($this->once())
->method('question_was_marked_correct')
->willReturn(true);
$mockcatalgo->expects($this->once())
->method('compute_next_difficulty')
->willReturn(30);
$mockcatalgo->expects($this->once())
->method('compute_right_answers')
->willReturn(1); // Sum of right answers.
$mockcatalgo->expects($this->once())
->method('compute_wrong_answers')
->willReturn(1); // Sum of wrong answers.
$mockcatalgo->expects($this->never())
->method('estimate_measure');
$mockcatalgo->expects($this->never())
->method('estimate_standard_error');
$mockcatalgo->expects($this->never())
->method('retrieve_standard_error');
$mockcatalgo->expects($this->never())
->method('standard_error_within_parameters');
$result = $mockcatalgo->perform_calculation_steps();
$this->assertEquals(0, $result);
}
/**
* This function tests the return data from perform_calculation_steps(), where the user answered
* the last question correctly and the attempt has not met the minimum stopping criteria.
*/
public function test_perform_calculation_steps_nostop_correct_answer() {
$this->resetAfterTest(true);
$mockquba = $this->createMock(question_usage_by_activity::class);
// Minimum stopping criteria has not been met.
$mockcatalgo = $this
->getMockBuilder(catalgo::class)
->onlyMethods(
['retrieve_attempt_record', 'question_was_marked_correct', 'compute_next_difficulty',
'compute_right_answers', 'compute_wrong_answers', 'estimate_measure', 'estimate_standard_error',
'retrieve_standard_error', 'standard_error_within_parameters']
)
->setConstructorArgs(
[$mockquba, 1, false, 50]
)
->getMock();
$dummyattempt = new stdClass();
$dummyattempt->questionsattempted = 1;
$dummyattempt->difficultysum = 50;
$mockcatalgo->expects($this->once())
->method('retrieve_attempt_record')
->willReturn($dummyattempt);
$mockcatalgo->expects($this->once())
->method('question_was_marked_correct')
->willReturn(true); // Last attempted question marked correctly.
$mockcatalgo->expects($this->once())
->method('compute_next_difficulty')
->willReturn(52);
$mockcatalgo->expects($this->never())
->method('compute_right_answers');
$mockcatalgo->expects($this->never())
->method('compute_wrong_answers');
$mockcatalgo->expects($this->never())
->method('estimate_measure');
$mockcatalgo->expects($this->never())
->method('estimate_standard_error');
$mockcatalgo->expects($this->never())
->method('retrieve_standard_error');
$mockcatalgo->expects($this->never())
->method('standard_error_within_parameters');
$result = $mockcatalgo->perform_calculation_steps();
$this->assertEquals(52, $result);
}
/**
* This function tests the return data from perform_calculation_steps(), where the user answered
* the last question incorrectly and the attempt has not met the minimum stopping criteria.
*/
public function test_perform_calculation_steps_nostop_incorrect_answer() {
$this->resetAfterTest(true);
$mockquba = $this->createMock(question_usage_by_activity::class);
// Minimum stopping criteria has not been met.
$mockcatalgo = $this
->getMockBuilder(catalgo::class)
->onlyMethods(
['retrieve_attempt_record', 'question_was_marked_correct', 'compute_next_difficulty',
'compute_right_answers', 'compute_wrong_answers', 'estimate_measure', 'estimate_standard_error',
'retrieve_standard_error', 'standard_error_within_parameters']
)
->setConstructorArgs(
[$mockquba, 1, false, 50]
)
->getMock();
$dummyattempt = new stdClass();
$dummyattempt->questionsattempted = 1;
$dummyattempt->difficultysum = 50;
$mockcatalgo->expects($this->once())
->method('retrieve_attempt_record')
->willReturn($dummyattempt);
$mockcatalgo->expects($this->once())
->method('question_was_marked_correct')
->willReturn(false); // Last attempted question marked incorrectly.
$mockcatalgo->expects($this->once())
->method('compute_next_difficulty')
->willReturn(48);
$mockcatalgo->expects($this->never())
->method('compute_right_answers');
$mockcatalgo->expects($this->never())
->method('compute_wrong_answers');
$mockcatalgo->expects($this->never())
->method('estimate_measure');
$mockcatalgo->expects($this->never())
->method('estimate_standard_error');
$mockcatalgo->expects($this->never())
->method('retrieve_standard_error');
$mockcatalgo->expects($this->never())
->method('standard_error_within_parameters');
$result = $mockcatalgo->perform_calculation_steps();
$this->assertEquals(48, $result);
}
/**
* This function tests the return data from perform_calculation_steps(), where the attempt has met
* all the criteria to determine the standard error and the function runs from beginning to end.
*/
public function test_perform_calculation_steps_stop_no_fail() {
$this->resetAfterTest(true);
$mockquba = $this->createMock(question_usage_by_activity::class);
// Minimum stopping criteria has been met.
$mockcatalgo = $this
->getMockBuilder(catalgo::class)
->onlyMethods(
['retrieve_attempt_record', 'question_was_marked_correct', 'compute_next_difficulty', 'compute_right_answers',
'compute_wrong_answers', 'retrieve_standard_error', 'standard_error_within_parameters']
)
->setConstructorArgs(
[$mockquba, 1, true, 50]
)
->getMock();
$dummyattempt = new stdClass();
$dummyattempt->questionsattempted = 2;
$dummyattempt->difficultysum = 50;
$mockcatalgo->expects($this->once())
->method('retrieve_attempt_record')
->with(1)
->willReturn($dummyattempt);
$mockcatalgo->expects($this->once())
->method('question_was_marked_correct')
->willReturn(true);
$mockcatalgo->expects($this->once())
->method('compute_next_difficulty')
->with(50, 2, true, $dummyattempt)
->willReturn(48);
$mockcatalgo->expects($this->once())
->method('compute_right_answers')
->withAnyParameters()
->willReturn(1);
$mockcatalgo->expects($this->once())
->method('compute_wrong_answers')
->withAnyParameters()
->willReturn(1);
$mockcatalgo->expects($this->once())
->method('retrieve_standard_error')
->with(1)
->willReturn(5);
$mockcatalgo->expects($this->once())
->method('standard_error_within_parameters')
->withAnyParameters() // Second parameter (is not rounded) and has decimal with many decimal places.
->willReturn(1.0);
$result = $mockcatalgo->perform_calculation_steps();
$this->assertEquals(48, $result);
}
/**
* This function tests the return value from standard_error_within_parameters().
*/
public function test_standard_error_within_parameters_return_true_then_false() {
$this->resetAfterTest(true);
$mockquba = $this->createMock('question_usage_by_activity');
$catalgo = new catalgo($mockquba, 1, true, 1);
$result = $catalgo->standard_error_within_parameters(0.02, 0.1);
$this->assertTrue($result);
$result = $catalgo->standard_error_within_parameters(0.01, 0.002);
$this->assertFalse($result);
}
/**
* This function tests the return value from get_current_diff_level().
*/
public function test_get_current_diff_level_using_level_zero() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$dummy->lowestlevel = 1;
$dummy->highestlevel = 20;
$mockquba = $this->createMock(question_usage_by_activity::class);
$mockcatalgo = $this
->getMockBuilder(catalgo::class)
->onlyMethods(
['return_current_diff_level']
)
->setConstructorArgs(
[$mockquba, 1, true, 50]
)
->getMock();
$mockcatalgo->expects($this->never())
->method('return_current_diff_level');
$this->expectException('coding_exception');
$mockcatalgo->get_current_diff_level($mockquba, 0, $dummy);
}
/**
* This function tests the return value from get_current_diff_level()
*/
public function test_get_current_diff_level_using_no_quba() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$dummy->lowestlevel = 1;
$dummy->highestlevel = 20;
$mockquba = $this->createMock(question_usage_by_activity::class);
$mockcatalgo = $this
->getMockBuilder(catalgo::class)
->onlyMethods(
['return_current_diff_level']
)
->setConstructorArgs(
[$mockquba, 1, true, 50]
)
->getMock();
$mockcatalgo->expects($this->never())
->method('return_current_diff_level');
$this->expectException('coding_exception');
$mockcatalgo->get_current_diff_level($dummy, 1, $dummy);
}
/**
* This function tests the return value from get_current_diff_level()
*/
public function test_get_current_diff_level_using_no_attempt_obj() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$mockquba = $this->createMock(question_usage_by_activity::class);
$mockcatalgo = $this
->getMockBuilder(catalgo::class)
->onlyMethods(
['return_current_diff_level']
)
->setConstructorArgs(
[$mockquba, 1, true, 50]
)
->getMock();
$mockcatalgo->expects($this->never())
->method('return_current_diff_level');
$this->expectException('coding_exception');
$mockcatalgo->get_current_diff_level($mockquba, 1, $dummy);
}
/**
* This function tests the return value from get_current_diff_level()
*/
public function test_get_current_diff_level_using_attempt_obj_missing_lowestlevel() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$dummy->highestlevel = 20;
$mockquba = $this->createMock(question_usage_by_activity::class);
$mockcatalgo = $this
->getMockBuilder(catalgo::class)
->onlyMethods(
['return_current_diff_level']
)
->setConstructorArgs(
[$mockquba, 1, true, 50]
)
->getMock();
$mockcatalgo->expects($this->never())
->method('return_current_diff_level');
$this->expectException('coding_exception');
$mockcatalgo->get_current_diff_level($mockquba, 1, $dummy);
}
/**
* This function tests the return value from get_current_diff_level()
*/
public function test_get_current_diff_level_using_attempt_obj_missing_highestlevel() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$dummy->lowestlevel = 20;
$mockquba = $this->createMock(question_usage_by_activity::class);
$mockcatalgo = $this
->getMockBuilder(catalgo::class)
->onlyMethods(
['return_current_diff_level']
)
->setConstructorArgs(
[$mockquba, 1, true, 50]
)
->getMock();
$mockcatalgo->expects($this->never())
->method('return_current_diff_level');
$this->expectException('coding_exception');
$mockcatalgo->get_current_diff_level($mockquba, 1, $dummy);
}
/**
* This function tests the return value from get_current_diff_level()
*/
public function test_get_current_diff_level() {
$this->resetAfterTest(true);
$dummy = new stdClass();
$dummy->lowestlevel = 20;
$dummy->highestlevel = 21;
$mockquba = $this->createMock(question_usage_by_activity::class);
$mockcatalgo = $this
->getMockBuilder(catalgo::class)
->onlyMethods(
['return_current_diff_level']
)
->setConstructorArgs(
[$mockquba, 1, true, 50]
)
->getMock();
$mockcatalgo->expects($this->once())
->method('return_current_diff_level')
->willReturn(3);
$result = $mockcatalgo->get_current_diff_level($mockquba, 1, $dummy);
$this->assertEquals(3, $result);
}
/**
* This function tests the output from convert_percent_to_logit()
*/
public function test_convert_percent_to_logit_using_param_less_than_zero() {
$this->resetAfterTest(true);
$this->expectException('coding_exception');
$result = catalgo::convert_percent_to_logit(-1);
}
/**
* This function tests the output from convert_percent_to_logit()
*/
public function test_convert_percent_to_logit_using_param_greater_than_decimal_five() {
$this->resetAfterTest(true);
$this->expectException('coding_exception');
catalgo::convert_percent_to_logit(0.51);
}
/**
* This function tests the output from convert_percent_to_logit()
*/
public function test_convert_percent_to_logit() {
$this->resetAfterTest(true);
$result = catalgo::convert_percent_to_logit(0.05);
$result = round($result, 1);
$this->assertEquals(0.2, $result);
}
/**
* This function tests the output from convert_logit_to_percent()
*/
public function test_convert_logit_to_percent_using_param_less_than_zero() {
$this->resetAfterTest(true);
$this->expectException('coding_exception');
catalgo::convert_logit_to_percent(-1);
}
/**
* This function tests the output from convert_logit_to_percent()
*/
public function test_convert_logit_to_percent() {
$this->resetAfterTest(true);
$result = catalgo::convert_logit_to_percent(0.2);
$result = round($result, 2);
$this->assertEquals(0.05, $result);
}
/**
* This function tests the output from map_logit_to_scale()
*/
public function test_map_logit_to_scale() {
$this->resetAfterTest(true);
$result = catalgo::map_logit_to_scale(-0.6, 16, 1);
$result = round($result, 1);
$this->assertEquals(6.3, $result);
}
/**
* Data provider for {@see test_fraction_meets_threshold()}.
*
* KNIGHT: a question counts as correct when its point fraction reaches the
* acceptance threshold. A threshold of 0 is the special "any partial credit
* counts" case (strictly greater than zero). A small tolerance absorbs
* floating-point artefacts (e.g. 0.9999999 for a fully correct multi-choice
* question), replacing the former DB-rounding workaround.
*
* @return array<string, array{float, float, bool}>
*/
public static function fraction_meets_threshold_provider(): array {
return [
'half meets 0.5' => [0.5, 0.5, true],
'just below 0.5 within tolerance' => [0.4999999, 0.5, true],
'clearly below 0.5' => [0.49, 0.5, false],
'full credit as float artefact meets 1.0' => [0.9999999, 1.0, true],
'partial credit fails 1.0' => [0.75, 1.0, false],
'threshold 0 rejects zero fraction' => [0.0, 0.0, false],
'threshold 0 accepts any partial credit' => [0.3, 0.0, true],
'zero fraction fails positive threshold' => [0.0, 0.5, false],
];
}
/**
* Tests the acceptance-threshold comparison used to decide question correctness.
*/
#[DataProvider('fraction_meets_threshold_provider')]
public function test_fraction_meets_threshold(float $fraction, float $threshold, bool $expected): void {
$this->assertSame($expected, catalgo::fraction_meets_threshold($fraction, $threshold));
}
/**
* Data provider for {@see test_normalize_fraction()}.
*
* KNIGHT: question types expose points differently - some provide a ready point
* fraction, others only a raw mark that has to be scaled by the maximum mark.
* normalize_fraction() turns either representation into a 0..1 fraction (or null
* when neither is available), guarding against a zero maximum mark.
*
* @return array<string, array{float|null, float|null, float|null, float|null}>
*/
public static function normalize_fraction_provider(): array {
return [
'fraction present is used directly' => [0.5, null, null, 0.5],
'fraction wins over mark/maxmark' => [0.5, 2.0, 4.0, 0.5],
'derive from mark and maxmark' => [null, 1.0, 2.0, 0.5],
'zero mark derives zero fraction' => [null, 0.0, 4.0, 0.0],
'zero maxmark is guarded (no division)' => [null, 1.0, 0.0, null],
'mark without maxmark cannot derive' => [null, 1.0, null, null],
'maxmark without mark cannot derive' => [null, null, 2.0, null],
'nothing available' => [null, null, null, null],
];
}
/**
* Tests turning a question's point representation into a normalized 0..1 fraction.
*/
#[DataProvider('normalize_fraction_provider')]
public function test_normalize_fraction(?float $fraction, ?float $mark, ?float $maxmark, ?float $expected): void {
$this->assertSame($expected, catalgo::normalize_fraction($fraction, $mark, $maxmark));
}
/**
* Builds a graded question usage for a single real question and returns [quba, slot].
*
* @param string $qtype Question type, e.g. 'multichoice'.
* @param string $which The test question variant from the qtype's test helper.
* @param callable $responsebuilder fn(question_usage_by_activity $quba, int $slot): array
* @return array{0: question_usage_by_activity, 1: int}
*/
private function build_graded_quba(string $qtype, string $which, callable $responsebuilder): array {
$qgen = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $qgen->create_question_category();
$questiondata = $qgen->create_question($qtype, $which, ['category' => $cat->id]);
$question = \question_bank::load_question($questiondata->id);
$quba = \question_engine::make_questions_usage_by_activity('mod_adaptivequiz', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$slot = $quba->add_question($question, 1);
$quba->start_all_questions();
$quba->process_action($slot, $responsebuilder($quba, $slot));
$quba->finish_all_questions();
return [$quba, $slot];
}
/**
* Integration test: correctness of a real partial-credit multiple-choice question is read
* from the question engine and compared against the acceptance threshold.
*
* KNIGHT: multiple-choice with partial credit was the question type that originally exposed
* the points-handling problem, so it is verified end to end against a real graded usage.
*/
public function test_question_is_correct_multichoice_partial_credit(): void {
$this->resetAfterTest();
// Full credit: both correct options selected -> fraction 1.0.
[$quba, $slot] = $this->build_graded_quba(
'multichoice',
'two_of_four',
fn($quba, $slot) => $quba->get_correct_response($slot)
);
$this->assertEqualsWithDelta(1.0, (float) $quba->get_question_fraction($slot), 1e-9);
$this->assertTrue(catalgo::question_is_correct($quba, $slot, 1.0));
$this->assertTrue(catalgo::question_is_correct($quba, $slot, 0.5));
// Partial credit: drop one of the two correct selections -> half credit.
[$quba, $slot] = $this->build_graded_quba(
'multichoice',
'two_of_four',
function ($quba, $slot) {
$response = $quba->get_correct_response($slot);
array_pop($response);
return $response;
}
);
$fraction = (float) $quba->get_question_fraction($slot);
$this->assertGreaterThan(0.0, $fraction);
$this->assertLessThan(1.0, $fraction);
// The decision must match the threshold applied to the engine's real fraction.
$this->assertSame(catalgo::fraction_meets_threshold($fraction, 1.0), catalgo::question_is_correct($quba, $slot, 1.0));
$this->assertSame(catalgo::fraction_meets_threshold($fraction, 0.5), catalgo::question_is_correct($quba, $slot, 0.5));
// Threshold 0 accepts any positive partial credit.
$this->assertTrue(catalgo::question_is_correct($quba, $slot, 0.0));
}
/**
* Data provider for {@see test_question_is_correct_extracts_points_per_type()}.
*
* One representative test variant per question type whose points handling we rely on.
* STACK is not a core question type and needs a CAS, so it is verified functionally on the
* server instead.
*
* @return array<string, array{string, string}>
*/
public static function question_type_provider(): array {
return [
'true/false' => ['truefalse', 'true'],
'matching' => ['match', 'foursubq'],
'numerical' => ['numerical', 'pi'],
'drag and drop into text' => ['ddwtos', 'fox'],
'cloze (multianswer)' => ['multianswer', 'twosubq'],
];
}
/**
* Integration test: question_is_correct() extracts usable points from every relevant question
* type and agrees with the acceptance threshold applied to the engine's real fraction.
*/
#[DataProvider('question_type_provider')]
public function test_question_is_correct_extracts_points_per_type(string $qtype, string $which): void {
$this->resetAfterTest();
[$quba, $slot] = $this->build_graded_quba(
$qtype,
$which,
fn($quba, $slot) => $quba->get_correct_response($slot)
);
$fraction = $quba->get_question_fraction($slot);
$this->assertNotNull($fraction, "Question engine reported no fraction for {$qtype}.");
foreach ([0.0, 0.5, 1.0] as $threshold) {
$decision = catalgo::question_is_correct($quba, $slot, $threshold);
$this->assertNotNull($decision, "Could not determine correctness for {$qtype} at threshold {$threshold}.");
$this->assertSame(catalgo::fraction_meets_threshold((float) $fraction, $threshold), $decision);
}
// A fully correct answer must count as correct even at the strictest threshold.
$this->assertTrue(
catalgo::question_is_correct($quba, $slot, 1.0),
"Full credit for {$qtype} was not treated as correct at threshold 1.0."
);
}
/**
* Data provider for {@see test_convert_linear_to_logit_stays_finite()}.
*
* Includes the scale boundaries and levels outside [min, max]. The latter can occur when a
* question is tagged with a difficulty outside the activity's configured range; the conversion
* must not produce a non-finite value (NAN/INF), which previously crashed the attempt reports.
*
* @return array<string, array{int, int, int}>
*/
public static function out_of_range_level_provider(): array {
return [
'at lowest boundary' => [1, 1, 4],
'at highest boundary' => [4, 1, 4],
'above highest' => [5, 1, 4],
'far above highest' => [12, 1, 4],
'below lowest' => [0, 1, 4],
'far below lowest' => [-5, 1, 4],
];
}
/**
* Tests that the linear-to-logit conversion always yields a finite value, even for levels at or
* outside the configured range.
*/
#[DataProvider('out_of_range_level_provider')]
public function test_convert_linear_to_logit_stays_finite(int $level, int $min, int $max): void {
$logit = catalgo::convert_linear_to_logit($level, $min, $max);
$this->assertIsFloat($logit);
$this->assertTrue(
is_finite($logit),
"convert_linear_to_logit({$level}, {$min}, {$max}) must be finite, got " . var_export($logit, true)
);
}
}
<?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;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot.'/mod/adaptivequiz/locallib.php');
use advanced_testcase;
use coding_exception;
use mod_adaptivequiz\local\repository\questions_number_per_difficulty;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use stdClass;
/**
* Fetch question 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
*/
#[CoversClass(\mod_adaptivequiz\local\fetchquestion::class)]
final class fetchquestion_test extends advanced_testcase {
/** @var stdClass $activityinstance adaptivequiz activity instance object */
protected $activityinstance = null;
/** @var stdClass $cm a partially completed course module object */
protected $cm = null;
/** @var stdClass $user a user object */
protected $user = null;
/**
* This function loads data into the PHPUnit tables for testing
*
* @return void
* @throws coding_exception
*/
protected function setup_test_data_xml() {
$this->dataset_from_files(
[__DIR__.'/../fixtures/mod_adaptivequiz_findquestion.xml']
)->to_database();
}
/**
* This function creates a default user and activity instance using generator classes
* The activity parameters created are are follows:
* lowest difficulty level: 1
* highest difficulty level: 10
* minimum question attempts: 2
* maximum question attempts: 10
* standard error: 1.1
* starting level: 5
* question category ids: 1
* course id: 2
* @return void
*/
protected function setup_generator_data() {
// Create test user.
$this->user = $this->getDataGenerator()->create_user();
$this->setUser($this->user);
$this->setAdminUser();
// Create activity.
$generator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$options = array(
'highestlevel' => 10,
'lowestlevel' => 1,
'minimumquestions' => 2,
'maximumquestions' => 10,
'standarderror' => 1.1,
'startinglevel' => 5,
'questionpool' => array(1),
'course' => 2
);
$this->activityinstance = $generator->create_instance($options);
$this->cm = new stdClass();
$this->cm->id = $this->activityinstance->cmid;
}
/**
* This function creates a default user and activity instance using generator classes (using a different question category)
* The activity parameters created are are follows:
* lowest difficulty level: 1
* highest difficulty level: 10
* minimum question attempts: 2
* maximum question attempts: 10
* standard error: 1.1
* starting level: 5
* question category ids: 1
* course id: 2
* @return void
*/
protected function setup_generator_data_two() {
// Create test user.
$this->user = $this->getDataGenerator()->create_user();
$this->setUser($this->user);
$this->setAdminUser();
// Create activity.
$generator = $this->getDataGenerator()->get_plugin_generator('mod_adaptivequiz');
$options = array(
'highestlevel' => 10,
'lowestlevel' => 1,
'minimumquestions' => 2,
'maximumquestions' => 10,
'standarderror' => 1.1,
'startinglevel' => 5,
'questionpool' => array(4),
'course' => 2
);
$this->activityinstance = $generator->create_instance($options);
$this->cm = new stdClass();
$this->cm->id = $this->activityinstance->cmid;
}
/**
* This function tests the retrieval of using illegible tag ids.
* @see setup_generator_data() for detail of activity instance.
*/
public function test_find_questions_fail_tag_ids() {
$this->resetAfterTest(true);
$this->setup_test_data_xml();
$this->setup_generator_data();
$attempt = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['retrieve_question_categories']
)
->setConstructorArgs(
[$this->activityinstance, 1, 1, 100]
)
->getMock();
$attempt->expects($this->exactly(2))
->method('retrieve_question_categories')
->willReturn(
[1 => 1, 2 => 2, 3 => 3]
);
$data = $attempt->find_questions_with_tags(
[99]
);
$this->assertEquals(0, count($data));
$data = $attempt->find_questions_with_tags([]);
$this->assertEquals(0, count($data));
}
/**
* This function tests the retrieval of questions using an empty set of question categories.
* @see setup_generator_data() for detail of activity instance.
*/
public function test_find_questions_fail_question_cat() {
$this->resetAfterTest(true);
$this->setup_test_data_xml();
$this->setup_generator_data();
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['retrieve_question_categories']
)
->setConstructorArgs(
[$this->activityinstance, 1, 1, 100]
)
->getMock();
$mockclass->expects($this->exactly(2))
->method('retrieve_question_categories')
->willReturn([]);
// Call class method with illegible tag id.
$data = $mockclass->find_questions_with_tags(
[99]
);
$this->assertEquals(0, count($data));
// Call class method with legit tag id.
$data = $mockclass->find_questions_with_tags(
[1]
);
$this->assertEquals(0, count($data));
}
/**
* This function tests the retrieval of questions using the exclude parameter
* @see setup_generator_data() for detail of activity instance
* @return void
*/
public function test_find_questions_exclude() {
$this->resetAfterTest(true);
$this->setup_test_data_xml();
$this->setup_generator_data();
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['retrieve_question_categories']
)
->setConstructorArgs(
[$this->activityinstance, 1, 1, 100]
)
->getMock();
$mockclass->expects($this->once())
->method('retrieve_question_categories')
->willReturn(
[1 => 1, 2 => 2, 3 => 3]
);
$data = $mockclass->find_questions_with_tags([1], [1]);
$this->assertEquals(0, count($data));
}
/**
* This functions tests the accessor methods for the $level class variable.
*/
public function test_get_set_level() {
$this->resetAfterTest(true);
$dummyclass = new stdClass();
$fetchquestion = new fetchquestion($dummyclass, 99, 1, 100);
$this->assertEquals(99, $fetchquestion->get_level());
$fetchquestion->set_level(22);
$this->assertEquals(22, $fetchquestion->get_level());
$this->expectException('coding_exception');
$fetchquestion->set_level(-22);
}
public function test_it_fails_when_instantiated_with_a_zero_difficulty_level(): void {
$this->resetAfterTest(true);
$this->expectException('coding_exception');
(new fetchquestion(new stdClass(), 0, 1, 100, ['phpunittag_']));
}
public function test_it_fails_when_instantiated_with_a_negative_difficulty_level(): void {
$this->resetAfterTest(true);
$this->expectException('coding_exception');
(new fetchquestion(new stdClass(), -11, 1, 100, ['phpunittag_']));
}
public function test_it_fails_when_instantiated_with_a_difficulty_level_as_a_string(): void {
$this->resetAfterTest(true);
$this->expectException('coding_exception');
(new fetchquestion(new stdClass(), 'asdf', 1, 100, ['phpunittag_']));
}
/**
* This functions tests the retrevial of tag ids with an associated difficulty level
* but using legit data.
*/
public function test_retrieve_tag() {
$this->resetAfterTest();
$this->setup_test_data_xml();
$dummyclass = new stdClass();
$fetchquestion = new fetchquestion($dummyclass, 5, 1, 100, ['phpunittag_']);
$data = $fetchquestion->retrieve_tag(5);
$this->assertEquals(2, count($data));
$this->assertEquals([0 => 1, 1 => 2], $data);
$fetchquestion2 = new fetchquestion($dummyclass, 888, 1, 100, ['phpunittag_']);
$data = $fetchquestion2->retrieve_tag(888);
$this->assertEquals(0, count($data));
}
/**
* This function test output from fetch_question() where initalize_tags_with_quest_count() returns an empty array
*/
public function test_fetch_question_initalize_tags_with_quest_count_return_empty_array() {
$this->resetAfterTest(true);
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['initalize_tags_with_quest_count', 'retrieve_tag', 'find_questions_with_tags']
)
->setConstructorArgs(
[new stdClass(), 5, 1, 100]
)
->getMock();
$mockclass->expects($this->once())
->method('initalize_tags_with_quest_count')
->willReturn([]);
$mockclass->expects($this->never())
->method('retrieve_tag');
$mockclass->expects($this->never())
->method('find_questions_with_tags');
$result = $mockclass->fetch_questions();
$this->assertEquals([], $result);
}
/**
* This function test output from fetch_question() where the initial requested level has available questions
*/
public function test_fetch_question_requested_level_has_questions() {
$this->resetAfterTest(true);
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['initalize_tags_with_quest_count', 'retrieve_tag', 'find_questions_with_tags']
)
->setConstructorArgs(
[new stdClass(), 5, 1, 100]
)
->getMock();
$mockclass->expects($this->once())
->method('initalize_tags_with_quest_count')
->with([], ['adpq_'], '1', '100')
->willReturn(
[5 => 2]
);
$mockclass->expects($this->once())
->method('retrieve_tag')
->with(5)
->willReturn(
[11]
);
$mockclass->expects($this->once())
->method('find_questions_with_tags')
->with(array(11), array())
->willReturn(
[22]
);
$result = $mockclass->fetch_questions();
$this->assertEquals([22], $result);
}
/**
* This function test output from fetch_question() where one level higher than requested level has available
* questions.
*/
public function test_fetch_question_one_level_higher_has_questions() {
$this->resetAfterTest(true);
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['initalize_tags_with_quest_count', 'retrieve_tag', 'find_questions_with_tags']
)
->setConstructorArgs(
[new stdClass(), 5, 1, 100]
)
->getMock();
$mockclass->expects($this->once())
->method('initalize_tags_with_quest_count')
->with([], ['adpq_'], '1', '100')
->willReturn(
[5 => 0, 6 => 1]
);
$mockclass->expects($this->once())
->method('retrieve_tag')
->with(6)
->willReturn(
[11]
);
$mockclass->expects($this->once())
->method('find_questions_with_tags')
->with([11], [])
->willReturn(
[22]
);
$result = $mockclass->fetch_questions();
$this->assertEquals([22], $result);
}
/**
* This function test output from fetch_question() where five levels higher than requested level has available
* questions.
*/
public function test_fetch_question_five_levels_higher_has_questions() {
$this->resetAfterTest(true);
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['initalize_tags_with_quest_count', 'retrieve_tag', 'find_questions_with_tags']
)
->setConstructorArgs(
[new stdClass(), 5, 1, 100]
)
->getMock();
$mockclass->expects($this->once())
->method('initalize_tags_with_quest_count')
->with([], ['adpq_'], '1', '100')
->willReturn(
[1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0, 6 => 0, 7 => 0, 8 => 0, 9 => 0, 10 => 2]
);
$mockclass->expects($this->once())
->method('retrieve_tag')
->with(10)
->willReturn(
[11]
);
$mockclass->expects($this->once())
->method('find_questions_with_tags')
->with([11], [])
->willReturn(
[22]
);
$result = $mockclass->fetch_questions();
$this->assertEquals([22], $result);
}
/**
* This function test output from fetch_question() where four levels lower than requested level has available
* questions.
*/
public function test_fetch_question_four_levels_lower_has_questions() {
$this->resetAfterTest(true);
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['initalize_tags_with_quest_count', 'retrieve_tag', 'find_questions_with_tags']
)
->setConstructorArgs(
[new stdClass(), 5, 1, 100]
)
->getMock();
$mockclass->expects($this->once())
->method('initalize_tags_with_quest_count')
->with([], ['adpq_'], '1', '100')
->willReturn(
[1 => 1, 2 => 0, 3 => 0, 4 => 0, 5 => 0, 6 => 0, 7 => 0, 8 => 0, 9 => 0, 10 => 0]
);
$mockclass->expects($this->once())
->method('retrieve_tag')
->with(1)
->willReturn(
[11]
);
$mockclass->expects($this->once())
->method('find_questions_with_tags')
->with([11], [])
->willReturn([22]);
$result = $mockclass->fetch_questions();
$this->assertEquals([22], $result);
}
/**
* This function test output from fetch_question() where searching for a question goes outside
* the min and max boundaries and stops the searching.
*/
public function test_fetch_question_search_outside_min_max_bounds() {
$this->resetAfterTest(true);
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['initalize_tags_with_quest_count', 'retrieve_tag', 'find_questions_with_tags']
)
->setConstructorArgs(
[new stdClass(), 50, 49, 51]
)
->getMock();
$mockclass->expects($this->once())
->method('initalize_tags_with_quest_count')
->with([], ['adpq_'], 49, 51)
->willReturn(
[48 => 1, 52 => 1]
);
$mockclass->expects($this->never())
->method('retrieve_tag');
$mockclass->expects($this->never())
->method('find_questions_with_tags');
$result = $mockclass->fetch_questions();
$this->assertEquals([], $result);
}
public function test_it_retrieves_all_tag_ids(): void {
$this->resetAfterTest();
$this->setup_test_data_xml();
$fetchquestion = new fetchquestion(new stdClass(), 5, 1, 100);
$result = $fetchquestion->retrieve_all_tag_ids(1, 100, ADAPTIVEQUIZ_QUESTION_TAG);
$this->assertEquals(
[5 => '1', 6 => '4', 7 => '5', 8 => '6', 9 => '7', 10 => '8'],
$result
);
}
public function test_it_throws_an_exception_when_retrieves_all_tag_ids_for_an_empty_tag_prefix(): void {
$fetchquestion = new fetchquestion(new stdClass(), 5, 1, 100);
$this->expectException('invalid_parameter_exception');
$fetchquestion->retrieve_all_tag_ids(1, 5, '');
}
/**
* A data provider.
*
* @return array
*/
public static function constructor_throw_coding_exception_provider() {
return [
[0, 1, 100],
[1, 100, 100],
[1, 100, 99],
];
}
/**
* This function tests throwing an exception by passing incorrect parameters.
*
* @param int $level the difficulty level
* @param int $min the minimum level of the attempt
* @param int $max the maximum level of the attempt
*/
#[DataProvider('constructor_throw_coding_exception_provider')]
public function test_constructor_throw_coding_exception($level, $min, $max) {
$this->resetAfterTest(true);
$this->expectException('coding_exception');
(new fetchquestion(new stdClass(), $level, $min, $max));
}
/**
* This function tests the output from initalize_tags_with_quest_count()
*/
public function test_initalize_tags_with_quest_count() {
$this->resetAfterTest();
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['retrieve_question_categories', 'retrieve_all_tag_ids', 'retrieve_tags_with_question_count']
)
->setConstructorArgs(
[new stdClass(), 1, 1, 100]
)
->getMock();
$mockclass->expects($this->once())
->method('retrieve_question_categories')
->willReturn(
[1 => 1, 2 => 2, 3 => 3]
);
$mockclass->expects($this->exactly(2))
->method('retrieve_all_tag_ids')
->withAnyParameters()
->willReturn(
[4 => 4, 5 => 5, 6 => 6]
);
$mockclass->expects($this->exactly(2))
->method('retrieve_tags_with_question_count')
->withAnyParameters()
->willReturn(
[
new questions_number_per_difficulty(1, 8),
new questions_number_per_difficulty(2, 3),
new questions_number_per_difficulty(5, 10),
]
);
$result = $mockclass->initalize_tags_with_quest_count([], ['test1_', 'test2_'], 1, 100);
$this->assertEquals([1 => 16, 2 => 6, 5 => 20], $result);
}
/**
* This function tests the output from initalize_tags_with_quest_count(), passing an already built difficulty question
* sum structure, forcing a rebuild.
*/
public function test_initalize_tags_with_quest_count_pre_built_quest_sum_struct_rebuild_true() {
$this->resetAfterTest();
$mockclass = $this
->getMockBuilder(fetchquestion::class)
->onlyMethods(
['retrieve_question_categories', 'retrieve_all_tag_ids', 'retrieve_tags_with_question_count']
)
->setConstructorArgs(
[new stdClass(), 1, 1, 100]
)
->getMock();
$mockclass->expects($this->once())
->method('retrieve_question_categories')
->willReturn(
[1 => 1, 2 => 2, 3 => 3]
);
$mockclass->expects($this->exactly(2))
->method('retrieve_all_tag_ids')
->withAnyParameters()
->willReturn(
[4 => 4, 5 => 5, 6 => 6]
);
$mockclass->expects($this->exactly(2))
->method('retrieve_tags_with_question_count')
->withAnyParameters()
->willReturn(
[
new questions_number_per_difficulty(1, 8),
new questions_number_per_difficulty(2, 3),
new questions_number_per_difficulty(5, 10),
]
);
$result = $mockclass->initalize_tags_with_quest_count([1, 2, 3, 4], ['test1_', 'test2_'], 1, 100, true);
$this->assertEquals([1 => 16, 2 => 6, 5 => 20], $result);
}
/**
* This function tests the output from decrement_question_sum_from_difficulty().
*/
public function test_decrement_question_sum_from_difficulty() {
$this->resetAfterTest(true);
$dummyclass = new stdClass();
$result = array(1 => 12);
$expected = array(1 => 11);
$fetchquestion = new fetchquestion($dummyclass, 1, 1, 2);
$result = $fetchquestion->decrement_question_sum_from_difficulty($result, 1);
$this->assertEquals($expected, $result);
}
/**
* This function tests the output from decrement_question_sum_from_difficulty(), using a key that doesn't exist.
*/
public function test_decrement_question_sum_from_difficulty_user_missing_key() {
$this->resetAfterTest(true);
$dummyclass = new stdClass();
$result = array(1 => 12);
$expected = array(1 => 12);
$fetchquestion = new fetchquestion($dummyclass, 1, 1, 2);
$result = $fetchquestion->decrement_question_sum_from_difficulty($result, 2);
$this->assertEquals($expected, $result);
}
}
<?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 advanced_testcase;
use context_module;
use context_system;
use mod_adaptivequiz\local\attempt\attempt_state;
use PHPUnit\Framework\Attributes\CoversClass;
use question_bank;
use question_engine;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/adaptivequiz/locallib.php');
/**
* PHPUnit tests for the attempt report helper.
*
* @package mod_adaptivequiz
* @copyright 2026 KNIGHT, Hochschule fuer Technik Stuttgart
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(attempt_report_helper::class)]
final class attempt_report_helper_test extends advanced_testcase {
/**
* The attempt reports must not crash when a question's tagged difficulty lies outside the
* activity's active level range. This is a legitimate situation: a question bank may hold
* questions of levels 2..5 while the item settings restrict administration to, say, 3..4, and
* an attempt's data may also predate a later narrowing of the range.
*/
public function test_reports_do_not_crash_on_difficulty_outside_configured_range(): void {
global $DB;
$this->resetAfterTest();
$coregen = $this->getDataGenerator();
$qgen = $coregen->get_plugin_generator('core_question');
/** @var \mod_adaptivequiz_generator $aqgen */
$aqgen = $coregen->get_plugin_generator('mod_adaptivequiz');
$course = $coregen->create_course();
// Active range restricted to 3..4.
$adaptivequiz = $aqgen->create_instance([
'course' => $course->id,
'lowestlevel' => 3,
'highestlevel' => 4,
'startinglevel' => 3,
]);
context_module::instance($adaptivequiz->cmid);
$cat = $qgen->create_question_category();
$qout = $qgen->create_question('truefalse', null, ['category' => $cat->id]);
$qgen->create_question_tag(['questionid' => $qout->id, 'tag' => 'adpq_5']); // Tagged at level 5, outside active range.
$qin = $qgen->create_question('truefalse', null, ['category' => $cat->id]);
$qgen->create_question_tag(['questionid' => $qin->id, 'tag' => 'adpq_3']);
// Build a finished usage whose first question carries the out-of-range difficulty.
$quba = question_engine::make_questions_usage_by_activity('mod_adaptivequiz', context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$slotout = $quba->add_question(question_bank::load_question($qout->id), 1);
$slotin = $quba->add_question(question_bank::load_question($qin->id), 1);
$quba->start_all_questions();
$quba->process_action($slotout, $quba->get_correct_response($slotout));
$quba->process_action($slotin, $quba->get_correct_response($slotin));
$quba->finish_all_questions();
question_engine::save_questions_usage_by_activity($quba);
$user = $coregen->create_user();
$now = time();
$attempt = (object) [
'instance' => $adaptivequiz->id,
'userid' => $user->id,
'uniqueid' => $quba->get_id(),
'attemptstate' => attempt_state::COMPLETED,
'attemptstopcriteria' => '',
'questionsattempted' => 2,
'difficultysum' => 0,
'standarderror' => 1.0,
'measure' => 0.0,
'timecreated' => $now,
'timemodified' => $now,
];
$attempt->id = $DB->insert_record('adaptivequiz_attempt', $attempt);
// Neither report may throw, even though one question's difficulty (5) is outside [3, 4].
$distribution = attempt_report_helper::prepare_answers_distribution_data($attempt->id);
$administration = attempt_report_helper::prepare_administration_data($attempt->id);
// Both answers are still accounted for in the distribution.
$counted = 0;
foreach ($distribution as $row) {
$counted += $row->numcorrect + $row->numwrong;
}
self::assertSame(2, $counted);
self::assertCount(2, $administration);
}
}
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