first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
+729
View File
@@ -0,0 +1,729 @@
<?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/>.
/**
* More object oriented wrappers around parts of the Moodle question bank.
*
* In due course, I expect that the question bank will be converted to a
* fully object oriented structure, at which point this file can be a
* starting point.
*
* @package moodlecore
* @subpackage questionbank
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_question\local\bank\question_version_status;
use core_question\output\question_version_info;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/../type/questiontypebase.php');
/**
* This static class provides access to the other question bank.
*
* It provides functions for managing question types and question definitions.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class question_bank {
// TODO: This limit can be deleted if someday we move all TEXTS to BIG ones. MDL-19603
const MAX_SUMMARY_LENGTH = 32000;
/** @var array question type name => question_type subclass. */
private static $questiontypes = array();
/** @var array question type name => 1. Records which question definitions have been loaded. */
private static $loadedqdefs = array();
/** @var boolean nasty hack to allow unit tests to call {@link load_question()}. */
private static $testmode = false;
private static $testdata = array();
private static $questionconfig = null;
/**
* @var array string => string The standard set of grade options (fractions)
* to use when editing questions, in the range 0 to 1 inclusive. Array keys
* are string becuase: a) we want grades to exactly 7 d.p., and b. you can't
* have float array keys in PHP.
* Initialised by {@link ensure_grade_options_initialised()}.
*/
private static $fractionoptions = null;
/** @var array string => string The full standard set of (fractions) -1 to 1 inclusive. */
private static $fractionoptionsfull = null;
/**
* @param string $qtypename a question type name, e.g. 'multichoice'.
* @return bool whether that question type is installed in this Moodle.
*/
public static function is_qtype_installed($qtypename) {
$plugindir = core_component::get_plugin_directory('qtype', $qtypename);
return $plugindir && is_readable($plugindir . '/questiontype.php');
}
/**
* Get the question type class for a particular question type.
* @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
* @param bool $mustexist if false, the missing question type is returned when
* the requested question type is not installed.
* @return question_type the corresponding question type class.
*/
public static function get_qtype($qtypename, $mustexist = true) {
global $CFG;
if (isset(self::$questiontypes[$qtypename])) {
return self::$questiontypes[$qtypename];
}
$file = core_component::get_plugin_directory('qtype', $qtypename) . '/questiontype.php';
if (!is_readable($file)) {
if ($mustexist || $qtypename == 'missingtype') {
throw new coding_exception('Unknown question type ' . $qtypename);
} else {
return self::get_qtype('missingtype');
}
}
include_once($file);
$class = 'qtype_' . $qtypename;
if (!class_exists($class)) {
throw new coding_exception("Class {$class} must be defined in {$file}.");
}
self::$questiontypes[$qtypename] = new $class();
return self::$questiontypes[$qtypename];
}
/**
* Load the question configuration data from config_plugins.
* @return object get_config('question') with caching.
*/
public static function get_config() {
if (is_null(self::$questionconfig)) {
self::$questionconfig = get_config('question');
}
return self::$questionconfig;
}
/**
* @param string $qtypename the internal name of a question type. For example multichoice.
* @return bool whether users are allowed to create questions of this type.
*/
public static function qtype_enabled($qtypename) {
$config = self::get_config();
$enabledvar = $qtypename . '_disabled';
return self::qtype_exists($qtypename) && empty($config->$enabledvar) &&
self::get_qtype($qtypename)->menu_name() != '';
}
/**
* @param string $qtypename the internal name of a question type. For example multichoice.
* @return bool whether this question type exists.
*/
public static function qtype_exists($qtypename) {
return array_key_exists($qtypename, core_component::get_plugin_list('qtype'));
}
/**
* @param $qtypename the internal name of a question type, for example multichoice.
* @return string the human_readable name of this question type, from the language pack.
*/
public static function get_qtype_name($qtypename) {
return self::get_qtype($qtypename)->local_name();
}
/**
* @return array all the installed question types.
*/
public static function get_all_qtypes() {
$qtypes = array();
foreach (core_component::get_plugin_list('qtype') as $plugin => $notused) {
try {
$qtypes[$plugin] = self::get_qtype($plugin);
} catch (coding_exception $e) {
// Catching coding_exceptions here means that incompatible
// question types do not cause the rest of Moodle to break.
}
}
return $qtypes;
}
/**
* Sort an array of question types according to the order the admin set up,
* and then alphabetically for the rest.
* @param array qtype->name() => qtype->local_name().
* @return array sorted array.
*/
public static function sort_qtype_array($qtypes, $config = null) {
if (is_null($config)) {
$config = self::get_config();
}
$sortorder = array();
$otherqtypes = array();
foreach ($qtypes as $name => $localname) {
$sortvar = $name . '_sortorder';
if (isset($config->$sortvar)) {
$sortorder[$config->$sortvar] = $name;
} else {
$otherqtypes[$name] = $localname;
}
}
ksort($sortorder);
core_collator::asort($otherqtypes);
$sortedqtypes = array();
foreach ($sortorder as $name) {
$sortedqtypes[$name] = $qtypes[$name];
}
foreach ($otherqtypes as $name => $notused) {
$sortedqtypes[$name] = $qtypes[$name];
}
return $sortedqtypes;
}
/**
* @return array all the question types that users are allowed to create,
* sorted into the preferred order set on the admin screen.
*/
public static function get_creatable_qtypes() {
$config = self::get_config();
$allqtypes = self::get_all_qtypes();
$qtypenames = array();
foreach ($allqtypes as $name => $qtype) {
if (self::qtype_enabled($name)) {
$qtypenames[$name] = $qtype->local_name();
}
}
$qtypenames = self::sort_qtype_array($qtypenames);
$creatableqtypes = array();
foreach ($qtypenames as $name => $notused) {
$creatableqtypes[$name] = $allqtypes[$name];
}
return $creatableqtypes;
}
/**
* Load the question definition class(es) belonging to a question type. That is,
* include_once('/question/type/' . $qtypename . '/question.php'), with a bit
* of checking.
* @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
*/
public static function load_question_definition_classes($qtypename) {
global $CFG;
if (isset(self::$loadedqdefs[$qtypename])) {
return;
}
$file = $CFG->dirroot . '/question/type/' . $qtypename . '/question.php';
if (!is_readable($file)) {
throw new coding_exception('Unknown question type (no definition) ' . $qtypename);
}
include_once($file);
self::$loadedqdefs[$qtypename] = 1;
}
/**
* This method needs to be called whenever a question is edited.
*/
public static function notify_question_edited($questionid) {
question_finder::get_instance()->uncache_question($questionid);
}
/**
* Load a question definition data from the database. The data will be
* returned as a plain stdClass object.
* @param int $questionid the id of the question to load.
* @return object question definition loaded from the database.
*/
public static function load_question_data($questionid) {
return question_finder::get_instance()->load_question_data($questionid);
}
/**
* Load a question definition from the database. The object returned
* will actually be of an appropriate {@link question_definition} subclass.
* @param int $questionid the id of the question to load.
* @param bool $allowshuffle if false, then any shuffle option on the selected
* quetsion is disabled.
* @return question_definition loaded from the database.
*/
public static function load_question($questionid, $allowshuffle = true) {
if (self::$testmode) {
// Evil, test code in production, but no way round it.
return self::return_test_question_data($questionid);
}
$questiondata = self::load_question_data($questionid);
if (!$allowshuffle) {
$questiondata->options->shuffleanswers = false;
}
return self::make_question($questiondata);
}
/**
* Convert the question information loaded with {@link get_question_options()}
* to a question_definintion object.
* @param object $questiondata raw data loaded from the database.
* @return question_definition loaded from the database.
*/
public static function make_question($questiondata) {
$definition = self::get_qtype($questiondata->qtype, false)->make_question($questiondata, false);
question_version_info::$pendingdefinitions[$definition->id] = $definition;
return $definition;
}
/**
* Get all the versions of a particular question.
*
* @param int $questionid id of the question
* @return array The array keys are version number, and the values are objects with three int fields
* version (same as array key), versionid and questionid.
*/
public static function get_all_versions_of_question(int $questionid): array {
global $DB;
$sql = "SELECT qv.id AS versionid, qv.version, qv.questionid
FROM {question_versions} qv
WHERE qv.questionbankentryid = (SELECT DISTINCT qbe.id
FROM {question_bank_entries} qbe
JOIN {question_versions} qv ON qbe.id = qv.questionbankentryid
JOIN {question} q ON qv.questionid = q.id
WHERE q.id = ?)
ORDER BY qv.version DESC";
return $DB->get_records_sql($sql, [$questionid]);
}
/**
* Get all the versions of questions.
*
* @param array $questionids Array of question ids.
* @return array two dimensional array question_bank_entries.id => version number => question.id.
* Versions in descending order.
*/
public static function get_all_versions_of_questions(array $questionids): array {
global $DB;
[$listquestionid, $params] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED);
$sql = "SELECT qv.questionid, qv.version, qv.questionbankentryid
FROM {question_versions} qv
JOIN {question_versions} qv2 ON qv.questionbankentryid = qv2.questionbankentryid
WHERE qv2.questionid $listquestionid
ORDER BY qv.questionbankentryid, qv.version DESC";
$result = [];
$rows = $DB->get_recordset_sql($sql, $params);
foreach ($rows as $row) {
$result[$row->questionbankentryid][$row->version] = $row->questionid;
}
return $result;
}
/**
* @return question_finder a question finder.
*/
public static function get_finder() {
return question_finder::get_instance();
}
/**
* Only to be called from unit tests. Allows {@link load_test_data()} to be used.
*/
public static function start_unit_test() {
self::$testmode = true;
}
/**
* Only to be called from unit tests. Allows {@link load_test_data()} to be used.
*/
public static function end_unit_test() {
self::$testmode = false;
self::$testdata = array();
}
private static function return_test_question_data($questionid) {
if (!isset(self::$testdata[$questionid])) {
throw new coding_exception('question_bank::return_test_data(' . $questionid .
') called, but no matching question has been loaded by load_test_data.');
}
return self::$testdata[$questionid];
}
/**
* To be used for unit testing only. Will throw an exception if
* {@link start_unit_test()} has not been called first.
* @param object $questiondata a question data object to put in the test data store.
*/
public static function load_test_question_data(question_definition $question) {
if (!self::$testmode) {
throw new coding_exception('question_bank::load_test_data called when ' .
'not in test mode.');
}
self::$testdata[$question->id] = $question;
}
protected static function ensure_fraction_options_initialised() {
if (!is_null(self::$fractionoptions)) {
return;
}
// define basic array of grades. This list comprises all fractions of the form:
// a. p/q for q <= 6, 0 <= p <= q
// b. p/10 for 0 <= p <= 10
// c. 1/q for 1 <= q <= 10
// d. 1/20
$rawfractions = array(
0.9000000,
0.8333333,
0.8000000,
0.7500000,
0.7000000,
0.6666667,
0.6000000,
0.5000000,
0.4000000,
0.3333333,
0.3000000,
0.2500000,
0.2000000,
0.1666667,
0.1428571,
0.1250000,
0.1111111,
0.1000000,
0.0500000,
);
// Put the None option at the top.
self::$fractionoptions = array(
'0.0' => get_string('none'),
'1.0' => '100%',
);
self::$fractionoptionsfull = array(
'0.0' => get_string('none'),
'1.0' => '100%',
);
// The the positive grades in descending order.
foreach ($rawfractions as $fraction) {
$percentage = format_float(100 * $fraction, 5, true, true) . '%';
self::$fractionoptions["{$fraction}"] = $percentage;
self::$fractionoptionsfull["{$fraction}"] = $percentage;
}
// The the negative grades in descending order.
foreach (array_reverse($rawfractions) as $fraction) {
self::$fractionoptionsfull['' . (-$fraction)] =
format_float(-100 * $fraction, 5, true, true) . '%';
}
self::$fractionoptionsfull['-1.0'] = '-100%';
}
/**
* @return array string => string The standard set of grade options (fractions)
* to use when editing questions, in the range 0 to 1 inclusive. Array keys
* are string becuase: a) we want grades to exactly 7 d.p., and b. you can't
* have float array keys in PHP.
* Initialised by {@link ensure_grade_options_initialised()}.
*/
public static function fraction_options() {
self::ensure_fraction_options_initialised();
return self::$fractionoptions;
}
/** @return array string => string The full standard set of (fractions) -1 to 1 inclusive. */
public static function fraction_options_full() {
self::ensure_fraction_options_initialised();
return self::$fractionoptionsfull;
}
/**
* Return a list of the different question types present in the given categories.
*
* @param array $categories a list of category ids
* @return array the list of question types in the categories
* @since Moodle 3.1
*/
public static function get_all_question_types_in_categories($categories) {
global $DB;
list($categorysql, $params) = $DB->get_in_or_equal($categories);
$sql = "SELECT DISTINCT q.qtype
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid $categorysql";
$qtypes = $DB->get_fieldset_sql($sql, $params);
return $qtypes;
}
}
/**
* Class for loading questions according to various criteria.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_finder implements cache_data_source {
/** @var question_finder the singleton instance of this class. */
protected static $questionfinder = null;
/**
* @return question_finder a question finder.
*/
public static function get_instance() {
if (is_null(self::$questionfinder)) {
self::$questionfinder = new question_finder();
}
return self::$questionfinder;
}
/* See cache_data_source::get_instance_for_cache. */
public static function get_instance_for_cache(cache_definition $definition) {
return self::get_instance();
}
/**
* @return cache_application the question definition cache we are using.
*/
protected function get_data_cache() {
// Do not double cache here because it may break cache resetting.
return cache::make('core', 'questiondata');
}
/**
* This method needs to be called whenever a question is edited.
*/
public function uncache_question($questionid) {
$this->get_data_cache()->delete($questionid);
}
/**
* Load a question definition data from the database. The data will be
* returned as a plain stdClass object.
* @param int $questionid the id of the question to load.
* @return object question definition loaded from the database.
*/
public function load_question_data($questionid) {
return $this->get_data_cache()->get($questionid);
}
/**
* Get the ids of all the questions in a list of categories.
* @param array $categoryids either a category id, or a comma-separated list
* of category ids, or an array of them.
* @param string $extraconditions extra conditions to AND with the rest of
* the where clause. Must use named parameters.
* @param array $extraparams any parameters used by $extraconditions.
* @return array questionid => questionid.
*/
public function get_questions_from_categories($categoryids, $extraconditions,
$extraparams = array()) {
global $DB;
list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc');
if ($extraconditions) {
$extraconditions = ' AND (' . $extraconditions . ')';
}
$qcparams['readystatus'] = question_version_status::QUESTION_STATUS_READY;
$qcparams['readystatusqv'] = question_version_status::QUESTION_STATUS_READY;
$sql = "SELECT q.id, q.id AS id2
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE qbe.questioncategoryid {$qcsql}
AND q.parent = 0
AND qv.status = :readystatus
AND qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be
ON be.id = v.questionbankentryid
WHERE be.id = qbe.id
AND v.status = :readystatusqv)
{$extraconditions}";
return $DB->get_records_sql_menu($sql, $qcparams + $extraparams);
}
/**
* Get the ids of all the questions in a list of categories, with the number
* of times they have already been used in a given set of usages.
*
* The result array is returned in order of increasing (count previous uses).
*
* @param array $categoryids an array question_category ids.
* @param qubaid_condition $qubaids which question_usages to count previous uses from.
* @param string $extraconditions extra conditions to AND with the rest of
* the where clause. Must use named parameters.
* @param array $extraparams any parameters used by $extraconditions.
* @return array questionid => count of number of previous uses.
*
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function get_questions_from_categories_with_usage_counts($categoryids,
qubaid_condition $qubaids, $extraconditions = '', $extraparams = array()) {
debugging(
'Function get_questions_from_categories_with_usage_counts() is deprecated, please do not use the function.',
DEBUG_DEVELOPER
);
return $this->get_questions_from_categories_and_tags_with_usage_counts(
$categoryids, $qubaids, $extraconditions, $extraparams);
}
/**
* Get the ids of all the questions in a list of categories that have ALL the provided tags,
* with the number of times they have already been used in a given set of usages.
*
* The result array is returned in order of increasing (count previous uses).
*
* @param array $categoryids an array of question_category ids.
* @param qubaid_condition $qubaids which question_usages to count previous uses from.
* @param string $extraconditions extra conditions to AND with the rest of
* the where clause. Must use named parameters.
* @param array $extraparams any parameters used by $extraconditions.
* @param array $tagids an array of tag ids
* @return array questionid => count of number of previous uses.
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function get_questions_from_categories_and_tags_with_usage_counts($categoryids,
qubaid_condition $qubaids, $extraconditions = '', $extraparams = array(), $tagids = array()) {
debugging(
'Function get_questions_from_categories_and_tags_with_usage_counts() is deprecated, please do not use the function.',
DEBUG_DEVELOPER
);
global $DB;
list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc');
$readystatus = question_version_status::QUESTION_STATUS_READY;
$select = "q.id, (SELECT COUNT(1)
FROM " . $qubaids->from_question_attempts('qa') . "
WHERE qa.questionid = q.id AND " . $qubaids->where() . "
) AS previous_attempts";
$from = "{question} q";
$join = "JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid";
$from = $from . " " . $join;
$where = "qbe.questioncategoryid {$qcsql}
AND q.parent = 0
AND qv.status = '$readystatus'
AND qv.version = (SELECT MAX(v.version)
FROM {question_versions} v
JOIN {question_bank_entries} be
ON be.id = v.questionbankentryid
WHERE be.id = qbe.id)";
$params = $qcparams;
if (!empty($tagids)) {
// We treat each additional tag as an AND condition rather than
// an OR condition.
//
// For example, if the user filters by the tags "foo" and "bar" then
// we reduce the question list to questions that are tagged with both
// "foo" AND "bar". Any question that does not have ALL of the specified
// tags will be omitted.
list($tagsql, $tagparams) = $DB->get_in_or_equal($tagids, SQL_PARAMS_NAMED, 'ti');
$tagparams['tagcount'] = count($tagids);
$tagparams['questionitemtype'] = 'question';
$tagparams['questioncomponent'] = 'core_question';
$where .= " AND q.id IN (SELECT ti.itemid
FROM {tag_instance} ti
WHERE ti.itemtype = :questionitemtype
AND ti.component = :questioncomponent
AND ti.tagid {$tagsql}
GROUP BY ti.itemid
HAVING COUNT(itemid) = :tagcount)";
$params += $tagparams;
}
if ($extraconditions) {
$extraconditions = ' AND (' . $extraconditions . ')';
}
return $DB->get_records_sql_menu("SELECT $select
FROM $from
WHERE $where $extraconditions
ORDER BY previous_attempts",
$qubaids->from_where_params() + $params + $extraparams);
}
/* See cache_data_source::load_for_cache. */
public function load_for_cache($questionid) {
global $DB;
$sql = 'SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat,
q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype,
q.length, q.stamp, q.timecreated, q.timemodified,
q.createdby, q.modifiedby, qbe.idnumber,
qc.contextid,
qv.status,
qv.id as versionid,
qv.version,
qv.questionbankentryid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.id = :id';
$questiondata = $DB->get_record_sql($sql, ['id' => $questionid], MUST_EXIST);
get_question_options($questiondata);
return $questiondata;
}
/* See cache_data_source::load_many_for_cache. */
public function load_many_for_cache(array $questionids) {
global $DB;
list($idcondition, $params) = $DB->get_in_or_equal($questionids);
$sql = 'SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat,
q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype,
q.length, q.stamp, q.timecreated, q.timemodified,
q.createdby, q.modifiedby, qbe.idnumber,
qc.contextid,
qv.status,
qv.id as versionid,
qv.version,
qv.questionbankentryid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.id ';
$questiondata = $DB->get_records_sql($sql . $idcondition, $params);
foreach ($questionids as $id) {
if (!array_key_exists($id, $questiondata)) {
throw new dml_missing_record_exception('question', '', ['id' => $id]);
}
get_question_options($questiondata[$id]);
}
return $questiondata;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+798
View File
@@ -0,0 +1,798 @@
<?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/>.
/**
* This file defines the question attempt step class, and a few related classes.
*
* @package moodlecore
* @subpackage questionengine
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Stores one step in a {@see question_attempt}.
*
* The most important attributes of a step are the state, which is one of the
* {@see question_state} constants, the fraction, which may be null, or a
* number bewteen the attempt's minfraction and maxfraction, and the array of submitted
* data, about which more later.
*
* A step also tracks the time it was created, and the user responsible for
* creating it.
*
* The submitted data is basically just an array of name => value pairs, with
* certain conventions about the to divide the variables into five = 2 x 2 + 1 categories.
*
* Variables may either belong to the behaviour, in which case the
* name starts with a '-', or they may belong to the question type in which case
* they name does not start with a '-'.
*
* Second, variables may either be ones that came form the original request, in
* which case the name does not start with an _, or they are cached values that
* were created during processing, in which case the name does start with an _.
*
* In addition, we can store 'metadata', typically only in the first step of a
* question attempt. These are stored with the initial characters ':_'.
*
* That is, each name will start with one of '', '_', '-', '-_' or ':_'. The remainder
* of the name was supposed to match the regex [a-z][a-z0-9]* - but this has never
* been enforced. Question types exist which break this rule. E.g. qtype_combined.
* Perhpas now, an accurate regex would be [a-z][a-z0-9_:]*.
*
* These variables can be accessed with {@see get_behaviour_var()} and {@see get_qt_var()},
* - to be clear, ->get_behaviour_var('x') gets the variable with name '-x' -
* and values whose names start with '_' can be set using {@see set_behaviour_var()}
* and {@see set_qt_var()}. There are some other methods like {@see has_behaviour_var()}
* to check wether a varaible with a particular name is set, and {@see get_behaviour_data()}
* to get all the behaviour data as an associative array. There are also
* {@see get_metadata_var()}, {@see set_metadata_var()} and {@see has_metadata_var()},
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_attempt_step {
/**
* @var integer if this attempts is stored in the question_attempts table,
* the id of that row.
*/
private $id = null;
/**
* @var question_state one of the {@see question_state} constants.
* The state after this step.
*/
private $state;
/**
* @var null|number the fraction (grade on a scale of
* minfraction .. maxfraction, normally 0..1) or null.
*/
private $fraction = null;
/** @var int the timestamp when this step was created. */
private $timecreated;
/** @var int the id of the user responsible for creating this step. */
private $userid;
/** @var array name => value pairs. The submitted data. */
private $data;
/** @var array name => array of {@see stored_file}s. Caches the contents of file areas. */
private $files = array();
/** @var stdClass|null User information. */
private $user = null;
/**
* You should not need to call this constructor in your own code. Steps are
* normally created by {@see question_attempt} methods like
* {@see question_attempt::process_action()}.
* @param array $data the submitted data that defines this step.
* @param int|null $timecreated the time to record for the action. (If not given, use now.)
* @param int|null $userid the user to attribute the aciton to. (If not given, use the current user.)
* @param int|null $existingstepid if this step is going to replace an existing step
* (for example, during a regrade) this is the id of the previous step we are replacing.
*/
public function __construct($data = [], $timecreated = null, $userid = null,
$existingstepid = null) {
global $USER;
if (!is_array($data)) {
throw new coding_exception('$data must be an array when constructing a question_attempt_step.');
}
$this->state = question_state::$unprocessed;
$this->data = $data;
if (is_null($timecreated)) {
$this->timecreated = time();
} else {
$this->timecreated = $timecreated;
}
if (is_null($userid)) {
$this->userid = $USER->id;
} else {
$this->userid = $userid;
}
if (!is_null($existingstepid)) {
$this->id = $existingstepid;
}
}
/**
* @return int|null The id of this step in the database. null if this step
* is not stored in the database.
*/
public function get_id() {
return $this->id;
}
/** @return question_state The state after this step. */
public function get_state() {
return $this->state;
}
/**
* Set the state. Normally only called by behaviours.
* @param question_state $state one of the {@see question_state} constants.
*/
public function set_state($state) {
$this->state = $state;
}
/**
* @return null|number the fraction (grade on a scale of
* minfraction .. maxfraction, normally 0..1),
* or null if this step has not been marked.
*/
public function get_fraction() {
return $this->fraction;
}
/**
* Set the fraction. Normally only called by behaviours.
* @param null|number $fraction the fraction to set.
*/
public function set_fraction($fraction) {
$this->fraction = $fraction;
}
/** @return int the id of the user resonsible for creating this step. */
public function get_user_id() {
return $this->userid;
}
/**
* Update full user information for step.
*
* @param stdClass $user Full user object.
* @throws coding_exception
*/
public function add_full_user_object(stdClass $user): void {
if ($user->id != $this->userid) {
throw new coding_exception('Wrong user passed to add_full_user_object');
}
$this->user = $user;
}
/**
* Return the full user object.
*
* @return null|stdClass Get full user object.
*/
public function get_user(): ?stdClass {
if ($this->user === null) {
debugging('Attempt to access the step user before it was initialised. ' .
'Did you forget to call question_usage_by_activity::preload_all_step_users() or similar?', DEBUG_DEVELOPER);
}
return $this->user;
}
/**
* Get full name of user who did action.
*
* @return string full name of user.
*/
public function get_user_fullname(): string {
return fullname($this->get_user());
}
/** @return int the timestamp when this step was created. */
public function get_timecreated() {
return $this->timecreated;
}
/**
* @param string $name the name of a question type variable to look for in the submitted data.
* @return bool whether a variable with this name exists in the question type data.
*/
public function has_qt_var($name) {
return array_key_exists($name, $this->data);
}
/**
* @param string $name the name of a question type variable to look for in the submitted data.
* @return string the requested variable, or null if the variable is not set.
*/
public function get_qt_var($name) {
if (!$this->has_qt_var($name)) {
return null;
}
return $this->data[$name];
}
/**
* Set a cached question type variable.
* @param string $name the name of the variable to set. Must match _[a-z][a-z0-9]*.
* @param string $value the value to set.
*/
public function set_qt_var($name, $value) {
if ($name[0] != '_') {
throw new coding_exception('Cannot set question type data ' . $name .
' on an attempt step. You can only set variables with names begining with _.');
}
$this->data[$name] = $value;
}
/**
* Get the latest set of files for a particular question type variable of
* type question_attempt::PARAM_FILES.
*
* @param string $name the name of the associated variable.
* @param int $contextid contextid of the question attempt
* @return array of {@see stored_files}.
*/
public function get_qt_files($name, $contextid) {
if (array_key_exists($name, $this->files)) {
return $this->files[$name];
}
if (!$this->has_qt_var($name)) {
$this->files[$name] = array();
return array();
}
$fs = get_file_storage();
$filearea = question_file_saver::clean_file_area_name('response_' . $name);
$this->files[$name] = $fs->get_area_files($contextid, 'question',
$filearea, $this->id, 'sortorder', false);
return $this->files[$name];
}
/**
* Prepare a draft file are for the files belonging the a response variable
* of this step.
*
* @param string $name the variable name the files belong to.
* @param int $contextid the id of the context the quba belongs to.
* @return int the draft itemid.
*/
public function prepare_response_files_draft_itemid($name, $contextid) {
list($draftid, $notused) = $this->prepare_response_files_draft_itemid_with_text(
$name, $contextid, null);
return $draftid;
}
/**
* Prepare a draft file are for the files belonging the a response variable
* of this step, while rewriting the URLs in some text.
*
* @param string $name the variable name the files belong to.
* @param int $contextid the id of the context the quba belongs to.
* @param string|null $text the text to update the URLs in.
* @return array(int, string) the draft itemid and the text with URLs rewritten.
*/
public function prepare_response_files_draft_itemid_with_text($name, $contextid, $text) {
$filearea = question_file_saver::clean_file_area_name('response_' . $name);
$draftid = 0; // Will be filled in by file_prepare_draft_area.
$newtext = file_prepare_draft_area($draftid, $contextid, 'question',
$filearea, $this->id, null, $text);
return array($draftid, $newtext);
}
/**
* Rewrite the @@PLUGINFILE@@ tokens in a response variable from this step
* that contains links to file. Normally you should probably call
* {@see question_attempt::rewrite_response_pluginfile_urls()} instead of
* calling this method directly.
*
* @param string $text the text to update the URLs in.
* @param int $contextid the id of the context the quba belongs to.
* @param string $name the variable name the files belong to.
* @param array $extras extra file path components.
* @return string the rewritten text.
*/
public function rewrite_response_pluginfile_urls($text, $contextid, $name, $extras) {
$filearea = question_file_saver::clean_file_area_name('response_' . $name);
return question_rewrite_question_urls($text, 'pluginfile.php', $contextid,
'question', $filearea, $extras, $this->id);
}
/**
* Get all the question type variables.
* @param array name => value pairs.
*/
public function get_qt_data() {
$result = array();
foreach ($this->data as $name => $value) {
if ($name[0] != '-' && $name[0] != ':') {
$result[$name] = $value;
}
}
return $result;
}
/**
* @param string $name the name of a behaviour variable to look for in the submitted data.
* @return bool whether a variable with this name exists in the question type data.
*/
public function has_behaviour_var($name) {
return array_key_exists('-' . $name, $this->data);
}
/**
* @param string $name the name of a behaviour variable to look for in the submitted data.
* @return string the requested variable, or null if the variable is not set.
*/
public function get_behaviour_var($name) {
if (!$this->has_behaviour_var($name)) {
return null;
}
return $this->data['-' . $name];
}
/**
* Set a cached behaviour variable.
* @param string $name the name of the variable to set. Must match _[a-z][a-z0-9]*.
* @param string $value the value to set.
*/
public function set_behaviour_var($name, $value) {
if ($name[0] != '_') {
throw new coding_exception('Cannot set question type data ' . $name .
' on an attempt step. You can only set variables with names begining with _.');
}
return $this->data['-' . $name] = $value;
}
/**
* Get all the behaviour variables.
*
* @return array name => value pairs. NOTE! the name has the leading - stripped off.
* (If you don't understand the note, read the comment at the top of this class :-))
*/
public function get_behaviour_data() {
$result = array();
foreach ($this->data as $name => $value) {
if ($name[0] == '-') {
$result[substr($name, 1)] = $value;
}
}
return $result;
}
/**
* Get all the submitted data, but not the cached data. behaviour
* variables have the - at the start of their name. This is only really
* intended for use by {@see question_attempt::regrade()}, it should not
* be considered part of the public API.
* @param array name => value pairs.
*/
public function get_submitted_data() {
$result = array();
foreach ($this->data as $name => $value) {
if ($name[0] == '_' || ($name[0] == '-' && $name[1] == '_')) {
continue;
}
$result[$name] = $value;
}
return $result;
}
/**
* Get all the data. behaviour variables have the - at the start of
* their name. This is only intended for internal use, for example by
* {@see question_engine_data_mapper::insert_question_attempt_step()},
* however, it can occasionally be useful in test code. It should not be
* considered part of the public API of this class.
* @param array name => value pairs.
*/
public function get_all_data() {
return $this->data;
}
/**
* Set a metadata variable.
*
* Do not call this method directly from your code. It is for internal
* use only. You should call {@see question_usage::set_question_attempt_metadata()}.
*
* @param string $name the name of the variable to set. [a-z][a-z0-9]*.
* @param string $value the value to set.
*/
public function set_metadata_var($name, $value) {
$this->data[':_' . $name] = $value;
}
/**
* Whether this step has a metadata variable.
*
* Do not call this method directly from your code. It is for internal
* use only. You should call {@see question_usage::get_question_attempt_metadata()}.
*
* @param string $name the name of the variable to set. [a-z][a-z0-9]*.
* @return bool the value to set previously, or null if this variable was never set.
*/
public function has_metadata_var($name) {
return isset($this->data[':_' . $name]);
}
/**
* Get a metadata variable.
*
* Do not call this method directly from your code. It is for internal
* use only. You should call {@see question_usage::get_question_attempt_metadata()}.
*
* @param string $name the name of the variable to set. [a-z][a-z0-9]*.
* @return string the value to set previously, or null if this variable was never set.
*/
public function get_metadata_var($name) {
if (!$this->has_metadata_var($name)) {
return null;
}
return $this->data[':_' . $name];
}
/**
* Create a question_attempt_step from records loaded from the database.
* @param Iterator $records Raw records loaded from the database.
* @param int $stepid The id of the records to extract.
* @param string $qtype The question type of which this is an attempt.
* If not given, each record must include a qtype field.
* @return question_attempt_step The newly constructed question_attempt_step.
*/
public static function load_from_records($records, $attemptstepid, $qtype = null) {
$currentrec = $records->current();
while ($currentrec->attemptstepid != $attemptstepid) {
$records->next();
if (!$records->valid()) {
throw new coding_exception('Question attempt step ' . $attemptstepid .
' not found in the database.');
}
$currentrec = $records->current();
}
$record = $currentrec;
$contextid = null;
$data = array();
while ($currentrec && $currentrec->attemptstepid == $attemptstepid) {
if (!is_null($currentrec->name)) {
$data[$currentrec->name] = $currentrec->value;
}
$records->next();
if ($records->valid()) {
$currentrec = $records->current();
} else {
$currentrec = false;
}
}
$step = new question_attempt_step_read_only($data, $record->timecreated, $record->userid);
$step->state = question_state::get($record->state);
$step->id = $record->attemptstepid;
if (!is_null($record->fraction)) {
$step->fraction = $record->fraction + 0;
}
// This next chunk of code requires getting $contextid and $qtype here.
// Somehow, we need to get that information to this point by modifying
// all the paths by which this method can be called.
// Can we only return files when it's possible? Should there be some kind of warning?
if (is_null($qtype)) {
$qtype = $record->qtype;
}
foreach (question_bank::get_qtype($qtype)->response_file_areas() as $area) {
if (empty($step->data[$area])) {
continue;
}
$step->data[$area] = new question_file_loader($step, $area, $step->data[$area], $record->contextid);
}
return $step;
}
}
/**
* A subclass of {@see question_attempt_step} used when processing a new submission.
*
* When we are processing some new submitted data, which may or may not lead to
* a new step being added to the {@see question_usage_by_activity} we create an
* instance of this class. which is then passed to the question behaviour and question
* type for processing. At the end of processing we then may, or may not, keep it.
*
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_attempt_pending_step extends question_attempt_step {
/** @var string the new response summary, if there is one. */
protected $newresponsesummary = null;
/** @var int the new variant number, if there is one. */
protected $newvariant = null;
/**
* If as a result of processing this step, the response summary for the
* question attempt should changed, you should call this method to set the
* new summary.
* @param string $responsesummary the new response summary.
*/
public function set_new_response_summary($responsesummary) {
$this->newresponsesummary = $responsesummary;
}
/**
* Get the new response summary, if there is one.
* @return string the new response summary, or null if it has not changed.
*/
public function get_new_response_summary() {
return $this->newresponsesummary;
}
/**
* Whether this processing this step has changed the response summary.
* @return bool true if there is a new response summary.
*/
public function response_summary_changed() {
return !is_null($this->newresponsesummary);
}
/**
* If as a result of processing this step, you identify that this variant of the
* question is actually identical to the another one, you may change the
* variant number recorded, in order to give better statistics. For an example
* see qbehaviour_opaque.
* @param int $variant the new variant number.
*/
public function set_new_variant_number($variant) {
$this->newvariant = $variant;
}
/**
* Get the new variant number, if there is one.
* @return int the new variant number, or null if it has not changed.
*/
public function get_new_variant_number() {
return $this->newvariant;
}
/**
* Whether this processing this step has changed the variant number.
* @return bool true if there is a new variant number.
*/
public function variant_number_changed() {
return !is_null($this->newvariant);
}
}
/**
* A subclass of {@see question_attempt_step} that cannot be modified.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_attempt_step_read_only extends question_attempt_step {
public function set_state($state) {
throw new coding_exception('Cannot modify a question_attempt_step_read_only.');
}
public function set_fraction($fraction) {
throw new coding_exception('Cannot modify a question_attempt_step_read_only.');
}
public function set_qt_var($name, $value) {
throw new coding_exception('Cannot modify a question_attempt_step_read_only.');
}
public function set_behaviour_var($name, $value) {
throw new coding_exception('Cannot modify a question_attempt_step_read_only.');
}
}
/**
* A null {@see question_attempt_step} returned from
* {@see question_attempt::get_last_step()} etc. when a an attempt has just been
* created and there is no actual step.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_null_step {
public function get_state() {
return question_state::$notstarted;
}
public function set_state($state) {
throw new coding_exception('This question has not been started.');
}
public function get_fraction() {
return null;
}
}
/**
* This is an adapter class that wraps a {@see question_attempt_step} and
* modifies the get/set_*_data methods so that they operate only on the parts
* that belong to a particular subquestion, as indicated by an extra prefix.
*
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_attempt_step_subquestion_adapter extends question_attempt_step {
/** @var question_attempt_step the step we are wrapping. */
protected $realstep;
/** @var string the exta prefix on fields we work with. */
protected $extraprefix;
/**
* Constructor.
* @param question_attempt_step $realstep the step to wrap. (Can be null if you
* just want to call add/remove.prefix.)
* @param string $extraprefix the extra prefix that is used for date fields.
*/
public function __construct($realstep, $extraprefix) {
$this->realstep = $realstep;
$this->extraprefix = $extraprefix;
}
/**
* Add the extra prefix to a field name.
* @param string $field the plain field name.
* @return string the field name with the extra bit of prefix added.
*/
public function add_prefix($field) {
if (substr($field, 0, 2) === '-_') {
return '-_' . $this->extraprefix . substr($field, 2);
} else if (substr($field, 0, 1) === '-') {
return '-' . $this->extraprefix . substr($field, 1);
} else if (substr($field, 0, 1) === '_') {
return '_' . $this->extraprefix . substr($field, 1);
} else {
return $this->extraprefix . $field;
}
}
/**
* Remove the extra prefix from a field name if it is present.
* @param string $field the extended field name.
* @return string the field name with the extra bit of prefix removed, or
* null if the extre prefix was not present.
*/
public function remove_prefix($field) {
if (preg_match('~^(-?_?)' . preg_quote($this->extraprefix, '~') . '(.*)$~', $field, $matches)) {
return $matches[1] . $matches[2];
} else {
return null;
}
}
/**
* Filter some data to keep only those entries where the key contains
* extraprefix, and remove the extra prefix from the reutrned arrary.
* @param array $data some of the data stored in this step.
* @return array the data with the keys ajusted using {@see remove_prefix()}.
*/
public function filter_array($data) {
$result = array();
foreach ($data as $fullname => $value) {
if ($name = $this->remove_prefix($fullname)) {
$result[$name] = $value;
}
}
return $result;
}
public function get_state() {
return $this->realstep->get_state();
}
public function set_state($state) {
throw new coding_exception('Cannot modify a question_attempt_step_subquestion_adapter.');
}
public function get_fraction() {
return $this->realstep->get_fraction();
}
public function set_fraction($fraction) {
throw new coding_exception('Cannot modify a question_attempt_step_subquestion_adapter.');
}
public function get_user_id() {
return $this->realstep->get_user_id();
}
public function get_timecreated() {
return $this->realstep->get_timecreated();
}
public function has_qt_var($name) {
return $this->realstep->has_qt_var($this->add_prefix($name));
}
public function get_qt_var($name) {
return $this->realstep->get_qt_var($this->add_prefix($name));
}
public function set_qt_var($name, $value) {
$this->realstep->set_qt_var($this->add_prefix($name), $value);
}
public function get_qt_data() {
return $this->filter_array($this->realstep->get_qt_data());
}
public function has_behaviour_var($name) {
return $this->realstep->has_behaviour_var($this->add_prefix($name));
}
public function get_behaviour_var($name) {
return $this->realstep->get_behaviour_var($this->add_prefix($name));
}
public function set_behaviour_var($name, $value) {
return $this->realstep->set_behaviour_var($this->add_prefix($name), $value);
}
public function get_behaviour_data() {
return $this->filter_array($this->realstep->get_behaviour_data());
}
public function get_submitted_data() {
return $this->filter_array($this->realstep->get_submitted_data());
}
public function get_all_data() {
return $this->filter_array($this->realstep->get_all_data());
}
public function get_qt_files($name, $contextid) {
throw new coding_exception('No attempt has yet been made to implement files support in ' .
'question_attempt_step_subquestion_adapter.');
}
public function prepare_response_files_draft_itemid($name, $contextid) {
throw new coding_exception('No attempt has yet been made to implement files support in ' .
'question_attempt_step_subquestion_adapter.');
}
public function prepare_response_files_draft_itemid_with_text($name, $contextid, $text) {
throw new coding_exception('No attempt has yet been made to implement files support in ' .
'question_attempt_step_subquestion_adapter.');
}
public function rewrite_response_pluginfile_urls($text, $contextid, $name, $extras) {
throw new coding_exception('No attempt has yet been made to implement files support in ' .
'question_attempt_step_subquestion_adapter.');
}
}
File diff suppressed because it is too large Load Diff
+512
View File
@@ -0,0 +1,512 @@
<?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/>.
/**
* Renderers for outputting parts of the question engine.
*
* @package moodlecore
* @subpackage questionengine
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_question\output\question_version_info;
defined('MOODLE_INTERNAL') || die();
/**
* This renderer controls the overall output of questions. It works with a
* {@link qbehaviour_renderer} and a {@link qtype_renderer} to output the
* type-specific bits. The main entry point is the {@link question()} method.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class core_question_renderer extends plugin_renderer_base {
/**
* @deprecated since Moodle 4.0
*/
public function question_preview_link() {
throw new coding_exception(__FUNCTION__ . '() has been removed.');
}
/**
* Generate the display of a question in a particular state, and with certain
* display options. Normally you do not call this method directly. Intsead
* you call {@link question_usage_by_activity::render_question()} which will
* call this method with appropriate arguments.
*
* @param question_attempt $qa the question attempt to display.
* @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
* specific parts.
* @param qtype_renderer $qtoutput the renderer to output the question type
* specific parts.
* @param question_display_options $options controls what should and should not be displayed.
* @param string|null $number The question number to display. 'i' is a special
* value that gets displayed as Information. Null means no number is displayed.
* @return string HTML representation of the question.
*/
public function question(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
qtype_renderer $qtoutput, question_display_options $options, $number) {
// If not already set, record the questionidentifier.
$options = clone($options);
if (!$options->has_question_identifier()) {
$options->questionidentifier = $this->question_number_text($number);
}
$output = '';
$output .= html_writer::start_tag('div', array(
'id' => $qa->get_outer_question_div_unique_id(),
'class' => implode(' ', array(
'que',
$qa->get_question(false)->get_type_name(),
$qa->get_behaviour_name(),
$qa->get_state_class($options->correctness && $qa->has_marks()),
))
));
$output .= html_writer::tag('div',
$this->info($qa, $behaviouroutput, $qtoutput, $options, $number),
array('class' => 'info'));
$output .= html_writer::start_tag('div', array('class' => 'content'));
$output .= html_writer::tag('div',
$this->add_part_heading($qtoutput->formulation_heading(),
$this->formulation($qa, $behaviouroutput, $qtoutput, $options)),
array('class' => 'formulation clearfix'));
$output .= html_writer::nonempty_tag('div',
$this->add_part_heading(get_string('feedback', 'question'),
$this->outcome($qa, $behaviouroutput, $qtoutput, $options)),
array('class' => 'outcome clearfix'));
$output .= html_writer::nonempty_tag('div',
$this->add_part_heading(get_string('comments', 'question'),
$this->manual_comment($qa, $behaviouroutput, $qtoutput, $options)),
array('class' => 'comment clearfix'));
$output .= html_writer::nonempty_tag('div',
$this->response_history($qa, $behaviouroutput, $qtoutput, $options),
array('class' => 'history clearfix border p-2'));
$output .= html_writer::end_tag('div');
$output .= html_writer::end_tag('div');
return $output;
}
/**
* Generate the information bit of the question display that contains the
* metadata like the question number, current state, and mark.
* @param question_attempt $qa the question attempt to display.
* @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
* specific parts.
* @param qtype_renderer $qtoutput the renderer to output the question type
* specific parts.
* @param question_display_options $options controls what should and should not be displayed.
* @param string|null $number The question number to display. 'i' is a special
* value that gets displayed as Information. Null means no number is displayed.
* @return HTML fragment.
*/
protected function info(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
qtype_renderer $qtoutput, question_display_options $options, $number) {
$output = '';
$output .= $this->number($number);
$output .= $this->status($qa, $behaviouroutput, $options);
$output .= $this->mark_summary($qa, $behaviouroutput, $options);
$output .= $this->question_flag($qa, $options->flags);
$output .= $this->edit_question_link($qa, $options);
if ($options->versioninfo) {
$output .= $this->render(new question_version_info($qa->get_question(), true));
}
return $output;
}
/**
* Generate the display of the question number.
* @param string|null $number The question number to display. 'i' is a special
* value that gets displayed as Information. Null means no number is displayed.
* @return HTML fragment.
*/
protected function number($number) {
if (trim($number ?? '') === '') {
return '';
}
if (trim($number) === 'i') {
$numbertext = get_string('information', 'question');
} else {
$numbertext = get_string('questionx', 'question',
html_writer::tag('span', s($number), array('class' => 'qno')));
}
return html_writer::tag('h3', $numbertext, array('class' => 'no'));
}
/**
* Get the question number as a string.
*
* @param string|null $number e.g. '123' or 'i'. null or '' means do not display anything number-related.
* @return string e.g. 'Question 123' or 'Information' or ''.
*/
protected function question_number_text(?string $number): string {
$number = $number ?? '';
// Trim the question number of whitespace, including &nbsp;.
$trimmed = trim(html_entity_decode($number), " \n\r\t\v\x00\xC2\xA0");
if ($trimmed === '') {
return '';
}
if (trim($number) === 'i') {
return get_string('information', 'question');
} else {
return get_string('questionx', 'question', s($number));
}
}
/**
* Add an invisible heading like 'question text', 'feebdack' at the top of
* a section's contents, but only if the section has some content.
* @param string $heading the heading to add.
* @param string $content the content of the section.
* @return string HTML fragment with the heading added.
*/
protected function add_part_heading($heading, $content) {
if ($content) {
$content = html_writer::tag('h4', $heading, array('class' => 'accesshide')) . $content;
}
return $content;
}
/**
* Generate the display of the status line that gives the current state of
* the question.
* @param question_attempt $qa the question attempt to display.
* @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
* specific parts.
* @param question_display_options $options controls what should and should not be displayed.
* @return HTML fragment.
*/
protected function status(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
question_display_options $options) {
return html_writer::tag('div', $qa->get_state_string($options->correctness),
array('class' => 'state'));
}
/**
* Generate the display of the marks for this question.
* @param question_attempt $qa the question attempt to display.
* @param qbehaviour_renderer $behaviouroutput the behaviour renderer, which can generate a custom display.
* @param question_display_options $options controls what should and should not be displayed.
* @return HTML fragment.
*/
protected function mark_summary(question_attempt $qa, qbehaviour_renderer $behaviouroutput, question_display_options $options) {
return html_writer::nonempty_tag('div',
$behaviouroutput->mark_summary($qa, $this, $options),
array('class' => 'grade'));
}
/**
* Generate the display of the marks for this question.
* @param question_attempt $qa the question attempt to display.
* @param question_display_options $options controls what should and should not be displayed.
* @return HTML fragment.
*/
public function standard_mark_summary(question_attempt $qa, qbehaviour_renderer $behaviouroutput, question_display_options $options) {
if (!$options->marks) {
return '';
} else if ($qa->get_max_mark() == 0) {
return get_string('notgraded', 'question');
} else if ($options->marks == question_display_options::MAX_ONLY ||
is_null($qa->get_fraction())) {
return $behaviouroutput->marked_out_of_max($qa, $this, $options);
} else {
return $behaviouroutput->mark_out_of_max($qa, $this, $options);
}
}
/**
* Generate the display of the available marks for this question.
* @param question_attempt $qa the question attempt to display.
* @param question_display_options $options controls what should and should not be displayed.
* @return HTML fragment.
*/
public function standard_marked_out_of_max(question_attempt $qa, question_display_options $options) {
return get_string('markedoutofmax', 'question', $qa->format_max_mark($options->markdp));
}
/**
* Generate the display of the marks for this question out of the available marks.
* @param question_attempt $qa the question attempt to display.
* @param question_display_options $options controls what should and should not be displayed.
* @return HTML fragment.
*/
public function standard_mark_out_of_max(question_attempt $qa, question_display_options $options) {
$a = new stdClass();
$a->mark = $qa->format_mark($options->markdp);
$a->max = $qa->format_max_mark($options->markdp);
return get_string('markoutofmax', 'question', $a);
}
/**
* Render the question flag, assuming $flagsoption allows it.
*
* @param question_attempt $qa the question attempt to display.
* @param int $flagsoption the option that says whether flags should be displayed.
*/
protected function question_flag(question_attempt $qa, $flagsoption) {
$divattributes = array('class' => 'questionflag');
switch ($flagsoption) {
case question_display_options::VISIBLE:
$flagcontent = $this->get_flag_html($qa->is_flagged());
break;
case question_display_options::EDITABLE:
$id = $qa->get_flag_field_name();
// The checkbox id must be different from any element name, because
// of a stupid IE bug:
// http://www.456bereastreet.com/archive/200802/beware_of_id_and_name_attribute_mixups_when_using_getelementbyid_in_internet_explorer/
$checkboxattributes = array(
'type' => 'checkbox',
'id' => $id . 'checkbox',
'name' => $id,
'value' => 1,
);
if ($qa->is_flagged()) {
$checkboxattributes['checked'] = 'checked';
}
$postdata = question_flags::get_postdata($qa);
$flagcontent = html_writer::empty_tag('input',
array('type' => 'hidden', 'name' => $id, 'value' => 0)) .
html_writer::empty_tag('input',
array('type' => 'hidden', 'value' => $postdata, 'class' => 'questionflagpostdata')) .
html_writer::empty_tag('input', $checkboxattributes) .
html_writer::tag('label', $this->get_flag_html($qa->is_flagged(), $id . 'img'),
array('id' => $id . 'label', 'for' => $id . 'checkbox')) . "\n";
$divattributes = array(
'class' => 'questionflag editable',
);
break;
default:
$flagcontent = '';
}
return html_writer::nonempty_tag('div', $flagcontent, $divattributes);
}
/**
* Work out the actual img tag needed for the flag
*
* @param bool $flagged whether the question is currently flagged.
* @param string $id an id to be added as an attribute to the img (optional).
* @return string the img tag.
*/
protected function get_flag_html($flagged, $id = '') {
if ($flagged) {
$icon = 'i/flagged';
$label = get_string('clickunflag', 'question');
} else {
$icon = 'i/unflagged';
$label = get_string('clickflag', 'question');
}
$attributes = [
'src' => $this->image_url($icon),
'alt' => '',
'class' => 'questionflagimage',
];
if ($id) {
$attributes['id'] = $id;
}
$img = html_writer::empty_tag('img', $attributes);
$img .= html_writer::span($label);
return $img;
}
/**
* Generate the display of the edit question link.
*
* @param question_attempt $qa The question attempt to display.
* @param question_display_options $options controls what should and should not be displayed.
* @return string
*/
protected function edit_question_link(question_attempt $qa, question_display_options $options) {
if (empty($options->editquestionparams)) {
return '';
}
$params = $options->editquestionparams;
if ($params['returnurl'] instanceof moodle_url) {
$params['returnurl'] = $params['returnurl']->out_as_local_url(false);
}
$params['id'] = $qa->get_question_id();
$editurl = new moodle_url('/question/bank/editquestion/question.php', $params);
return html_writer::tag('div', html_writer::link(
$editurl, $this->pix_icon('t/edit', get_string('edit'), '', array('class' => 'iconsmall')) .
get_string('editquestion', 'question')),
array('class' => 'editquestion'));
}
/**
* Generate the display of the formulation part of the question. This is the
* area that contains the quetsion text, and the controls for students to
* input their answers. Some question types also embed feedback, for
* example ticks and crosses, in this area.
*
* @param question_attempt $qa the question attempt to display.
* @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
* specific parts.
* @param qtype_renderer $qtoutput the renderer to output the question type
* specific parts.
* @param question_display_options $options controls what should and should not be displayed.
* @return HTML fragment.
*/
protected function formulation(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
qtype_renderer $qtoutput, question_display_options $options) {
$output = '';
$output .= html_writer::empty_tag('input', array(
'type' => 'hidden',
'name' => $qa->get_control_field_name('sequencecheck'),
'value' => $qa->get_sequence_check_count()));
$output .= $qtoutput->formulation_and_controls($qa, $options);
if ($options->clearwrong) {
$output .= $qtoutput->clear_wrong($qa);
}
$output .= html_writer::nonempty_tag('div',
$behaviouroutput->controls($qa, $options), array('class' => 'im-controls'));
return $output;
}
/**
* Generate the display of the outcome part of the question. This is the
* area that contains the various forms of feedback.
*
* @param question_attempt $qa the question attempt to display.
* @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
* specific parts.
* @param qtype_renderer $qtoutput the renderer to output the question type
* specific parts.
* @param question_display_options $options controls what should and should not be displayed.
* @return HTML fragment.
*/
protected function outcome(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
qtype_renderer $qtoutput, question_display_options $options) {
$output = '';
$output .= html_writer::nonempty_tag('div',
$qtoutput->feedback($qa, $options), array('class' => 'feedback'));
$output .= html_writer::nonempty_tag('div',
$behaviouroutput->feedback($qa, $options), array('class' => 'im-feedback'));
$output .= html_writer::nonempty_tag('div',
$options->extrainfocontent, array('class' => 'extra-feedback'));
return $output;
}
protected function manual_comment(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
qtype_renderer $qtoutput, question_display_options $options) {
return $qtoutput->manual_comment($qa, $options) .
$behaviouroutput->manual_comment($qa, $options);
}
/**
* Generate the display of the response history part of the question. This
* is the table showing all the steps the question has been through.
*
* @param question_attempt $qa the question attempt to display.
* @param qbehaviour_renderer $behaviouroutput the renderer to output the behaviour
* specific parts.
* @param qtype_renderer $qtoutput the renderer to output the question type
* specific parts.
* @param question_display_options $options controls what should and should not be displayed.
* @return HTML fragment.
*/
protected function response_history(question_attempt $qa, qbehaviour_renderer $behaviouroutput,
qtype_renderer $qtoutput, question_display_options $options) {
if (!$options->history) {
return '';
}
$table = new html_table();
$table->head = array (
get_string('step', 'question'),
get_string('time'),
get_string('action', 'question'),
get_string('state', 'question'),
);
if ($options->marks >= question_display_options::MARK_AND_MAX) {
$table->head[] = get_string('marks', 'question');
}
foreach ($qa->get_full_step_iterator() as $i => $step) {
$stepno = $i + 1;
$rowclass = '';
if ($stepno == $qa->get_num_steps()) {
$rowclass = 'current';
} else if (!empty($options->questionreviewlink)) {
$url = new moodle_url($options->questionreviewlink,
array('slot' => $qa->get_slot(), 'step' => $i));
$stepno = $this->output->action_link($url, $stepno,
new popup_action('click', $url, 'reviewquestion',
array('width' => 450, 'height' => 650)),
array('title' => get_string('reviewresponse', 'question')));
}
$restrictedqa = new question_attempt_with_restricted_history($qa, $i, null);
$row = [$stepno,
userdate($step->get_timecreated(), get_string('strftimedatetimeshortaccurate', 'core_langconfig')),
s($qa->summarise_action($step)) . $this->action_author($step, $options),
$restrictedqa->get_state_string($options->correctness)];
if ($options->marks >= question_display_options::MARK_AND_MAX) {
$row[] = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp);
}
$table->rowclasses[] = $rowclass;
$table->data[] = $row;
}
return html_writer::tag('h4', get_string('responsehistory', 'question'),
array('class' => 'responsehistoryheader')) .
$options->extrahistorycontent .
html_writer::tag('div', html_writer::table($table, true),
array('class' => 'responsehistoryheader'));
}
/**
* Action author's profile link.
*
* @param question_attempt_step $step The step.
* @param question_display_options $options The display options.
* @return string The link to user's profile.
*/
protected function action_author(question_attempt_step $step, question_display_options $options): string {
if ($options->userinfoinhistory && $step->get_user_id() != $options->userinfoinhistory) {
return html_writer::link(
new moodle_url('/user/view.php', ['id' => $step->get_user_id(), 'course' => $this->page->course->id]),
$step->get_user_fullname(), ['class' => 'd-table-cell']);
} else {
return '';
}
}
}
+478
View File
@@ -0,0 +1,478 @@
<?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/>.
/**
* This defines the states a question can be in.
*
* @package moodlecore
* @subpackage questionengine
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* An enumeration representing the states a question can be in after a
* {@link question_attempt_step}.
*
* There are also some useful methods for testing and manipulating states.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class question_state {
/**#@+
* Specific question_state instances.
*/
public static $notstarted;
public static $unprocessed;
public static $todo;
public static $invalid;
public static $complete;
public static $needsgrading;
public static $finished;
public static $gaveup;
public static $gradedwrong;
public static $gradedpartial;
public static $gradedright;
public static $manfinished;
public static $mangaveup;
public static $mangrwrong;
public static $mangrpartial;
public static $mangrright;
/**#@+-*/
protected function __construct() {
}
public static function init() {
$us = new ReflectionClass('question_state');
foreach ($us->getStaticProperties() as $name => $notused) {
$class = 'question_state_' . $name;
$states[$name] = new $class();
self::$$name = $states[$name];
}
}
/**
* Get all the states in an array.
*
* @return question_state[] of question_state objects.
*/
public static function get_all() {
$states = array();
$us = new ReflectionClass('question_state');
foreach ($us->getStaticProperties() as $name => $notused) {
$states[] = self::$$name;
}
return $states;
}
/**
* Get all the states in an array.
* @param string $summarystate one of the four summary states
* inprogress, needsgrading, manuallygraded or autograded.
* @return array of the corresponding states.
*/
public static function get_all_for_summary_state($summarystate) {
$states = array();
foreach (self::get_all() as $state) {
if ($state->get_summary_state() == $summarystate) {
$states[] = $state;
}
}
if (empty($states)) {
throw new coding_exception('unknown summary state ' . $summarystate);
}
return $states;
}
/**
* @return string convert this state to a string.
*/
public function __toString() {
return substr(get_class($this), 15);
}
/**
* Get the instance of this class for a given state name.
*
* @param string $name a state name.
* @return question_state|null the state with that name. (Null only in an exceptional case.)
*/
public static function get(string $name): ?question_state {
// In the past, there was a bug where null states got stored
// in the database as an empty string, which was wrong because
// the state column should be NOT NULL.
// That is no longer possible, but we need to avoid exceptions
// for people with old bad data in their database.
if ($name === '') {
debugging('Attempt to create a state from an empty string. ' .
'This is probably a sign of bad data in your database. See MDL-80127.');
return null;
}
return self::$$name;
}
/**
* Is this state one of the ones that mean the question attempt is in progress?
* That is, started, but no finished.
* @return bool
*/
public function is_active() {
return false;
}
/**
* Is this state one of the ones that mean the question attempt is finished?
* That is, no further interaction possible, apart from manual grading.
* @return bool
*/
public function is_finished() {
return true;
}
/**
* Is this state one of the ones that mean the question attempt has been graded?
* @return bool
*/
public function is_graded() {
return false;
}
/**
* Is this state one of the ones that mean the question attempt has been graded?
* @return bool
*/
public function is_correct() {
return false;
}
/**
* Is this state one of the ones that mean the question attempt has been graded?
* @return bool
*/
public function is_partially_correct() {
return false;
}
/**
* Is this state one of the ones that mean the question attempt has been graded?
* @return bool
*/
public function is_incorrect() {
return false;
}
/**
* Is this state one of the ones that mean the question attempt has been graded?
* @return bool
*/
public function is_gave_up() {
return false;
}
/**
* Is this state one of the ones that mean the question attempt has had a manual comment added?
* @return bool
*/
public function is_commented() {
return false;
}
/**
* Each state can be categorised into one of four categories:
* inprogress, needsgrading, manuallygraded or autograded.
* @return string which category this state falls into.
*/
public function get_summary_state() {
if (!$this->is_finished()) {
return 'inprogress';
} else if ($this == self::$needsgrading) {
return 'needsgrading';
} else if ($this->is_commented()) {
return 'manuallygraded';
} else {
return 'autograded';
}
}
/**
* Return the appropriate graded state based on a fraction. That is 0 or less
* is $graded_incorrect, 1 is $graded_correct, otherwise it is $graded_partcorrect.
* Appropriate allowance is made for rounding float values.
*
* @param number $fraction the grade, on the fraction scale.
* @return question_state one of the state constants.
*/
public static function graded_state_for_fraction($fraction) {
if ($fraction < 0.000001) {
return self::$gradedwrong;
} else if ($fraction > 0.999999) {
return self::$gradedright;
} else {
return self::$gradedpartial;
}
}
/**
* Return the appropriate manually graded state based on a fraction. That is 0 or less
* is $manually_graded_incorrect, 1 is $manually_graded_correct, otherwise it is
* $manually_graded_partcorrect. Appropriate allowance is made for rounding float values.
*
* @param number $fraction the grade, on the fraction scale.
* @return int one of the state constants.
*/
public static function manually_graded_state_for_fraction($fraction) {
if (is_null($fraction)) {
return self::$needsgrading;
} else if ($fraction < 0.000001) {
return self::$mangrwrong;
} else if ($fraction > 0.999999) {
return self::$mangrright;
} else {
return self::$mangrpartial;
}
}
/**
* Compute an appropriate state to move to after a manual comment has been
* added to this state.
* @param number $fraction the manual grade (if any) on the fraction scale.
* @return int the new state.
*/
public function corresponding_commented_state($fraction) {
throw new coding_exception('Unexpected question state.');
}
/**
* Return an appropriate CSS class name ''/'correct'/'partiallycorrect'/'incorrect',
* for a state.
* @return string
*/
public function get_feedback_class() {
return '';
}
/**
* Return the name of an appropriate string to look up in the question
* language pack for a state. This is used, for example, by
* {@link question_behaviour::get_state_string()}. However, behaviours
* sometimes change this default string for soemthing more specific.
*
* @param bool $showcorrectness Whether right/partial/wrong states should
* be distinguised, or just treated as 'complete'.
* @return string the name of a string that can be looked up in the 'question'
* lang pack, or used as a CSS class name, etc.
*/
abstract public function get_state_class($showcorrectness);
/**
* The result of doing get_string on the result of {@link get_state_class()}.
*
* @param bool $showcorrectness Whether right/partial/wrong states should
* be distinguised.
* @return string a string from the lang pack that can be used in the UI.
*/
public function default_string($showcorrectness) {
return get_string($this->get_state_class($showcorrectness), 'question');
}
}
/**#@+
* Specific question_state subclasses.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_state_notstarted extends question_state {
public function is_finished() {
return false;
}
public function get_state_class($showcorrectness) {
throw new coding_exception('Unexpected question state.');
}
}
class question_state_unprocessed extends question_state {
public function is_finished() {
return false;
}
public function get_state_class($showcorrectness) {
throw new coding_exception('Unexpected question state.');
}
}
class question_state_todo extends question_state {
public function is_active() {
return true;
}
public function is_finished() {
return false;
}
public function get_state_class($showcorrectness) {
return 'notyetanswered';
}
}
class question_state_invalid extends question_state {
public function is_active() {
return true;
}
public function is_finished() {
return false;
}
public function get_state_class($showcorrectness) {
return 'invalidanswer';
}
}
class question_state_complete extends question_state {
public function is_active() {
return true;
}
public function is_finished() {
return false;
}
public function get_state_class($showcorrectness) {
return 'answersaved';
}
}
class question_state_needsgrading extends question_state {
public function get_state_class($showcorrectness) {
if ($showcorrectness) {
return 'requiresgrading';
} else {
return 'complete';
}
}
public function corresponding_commented_state($fraction) {
return self::manually_graded_state_for_fraction($fraction);
}
}
class question_state_finished extends question_state {
public function get_state_class($showcorrectness) {
return 'complete';
}
public function corresponding_commented_state($fraction) {
return self::$manfinished;
}
}
class question_state_gaveup extends question_state {
public function is_gave_up() {
return true;
}
public function get_feedback_class() {
return 'incorrect';
}
public function get_state_class($showcorrectness) {
return 'notanswered';
}
public function corresponding_commented_state($fraction) {
if (is_null($fraction)) {
return self::$mangaveup;
} else {
return self::manually_graded_state_for_fraction($fraction);
}
}
}
abstract class question_state_graded extends question_state {
public function is_graded() {
return true;
}
public function get_state_class($showcorrectness) {
if ($showcorrectness) {
return $this->get_feedback_class();
} else {
return 'complete';
}
}
public function corresponding_commented_state($fraction) {
return self::manually_graded_state_for_fraction($fraction);
}
}
class question_state_gradedwrong extends question_state_graded {
public function is_incorrect() {
return true;
}
public function get_feedback_class() {
return 'incorrect';
}
}
class question_state_gradedpartial extends question_state_graded {
public function is_graded() {
return true;
}
public function is_partially_correct() {
return true;
}
public function get_feedback_class() {
return 'partiallycorrect';
}
}
class question_state_gradedright extends question_state_graded {
public function is_graded() {
return true;
}
public function is_correct() {
return true;
}
public function get_feedback_class() {
return 'correct';
}
}
class question_state_manfinished extends question_state_finished {
public function is_commented() {
return true;
}
}
class question_state_mangaveup extends question_state_gaveup {
public function is_commented() {
return true;
}
}
abstract class question_state_manuallygraded extends question_state_graded {
public function is_commented() {
return true;
}
}
class question_state_mangrwrong extends question_state_manuallygraded {
public function is_incorrect() {
return false;
}
public function get_feedback_class() {
return 'incorrect';
}
}
class question_state_mangrpartial extends question_state_manuallygraded {
public function is_partially_correct() {
return true;
}
public function get_feedback_class() {
return 'partiallycorrect';
}
}
class question_state_mangrright extends question_state_manuallygraded {
public function is_correct() {
return true;
}
public function get_feedback_class() {
return 'correct';
}
}
/**#@-*/
question_state::init();
+38
View File
@@ -0,0 +1,38 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
defined('MOODLE_INTERNAL') || die();
/**
* Coverage information for the question_engine.
*
* @copyright 2022 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
return new class extends phpunit_coverage_info {
/** @var array The list of files relative to the plugin root to include in coverage generation. */
protected $includelistfiles = [
'bank.php',
'datalib.php',
'lib.php',
'phpunit.xml',
'questionattempt.php',
'questionattemptstep.php',
'questionusage.php',
'renderer.php',
];
};
@@ -0,0 +1,382 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use quiz_statistics\tests\statistics_helper;
use mod_quiz\quiz_attempt;
use mod_quiz\quiz_settings;
use qubaid_list;
use question_bank;
use question_engine;
use question_engine_data_mapper;
use question_state;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for the parts of {@link question_engine_data_mapper} related to reporting.
*
* @package core_question
* @category test
* @copyright 2013 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class datalib_reporting_queries_test extends \qbehaviour_walkthrough_test_base {
/** @var question_engine_data_mapper */
protected $dm;
/** @var qtype_shortanswer_question */
protected $sa;
/** @var qtype_essay_question */
protected $essay;
/** @var array */
protected $usageids = array();
/** @var qubaid_condition */
protected $bothusages;
/** @var array */
protected $allslots = array();
/**
* Test the various methods that load data for reporting.
*
* Since these methods need an expensive set-up, and then only do read-only
* operations on the data, we use a single method to do the set-up, which
* calls diffents methods to test each query.
*/
public function test_reporting_queries(): void {
// We create two usages, each with two questions, a short-answer marked
// out of 5, and and essay marked out of 10.
//
// In the first usage, the student answers the short-answer
// question correctly, and enters something in the essay.
//
// In the second useage, the student answers the short-answer question
// wrongly, and leaves the essay blank.
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$this->sa = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
$this->essay = $generator->create_question('essay', null,
array('category' => $cat->id));
$this->usageids = array();
// Create the first usage.
$q = question_bank::load_question($this->sa->id);
$this->start_attempt_at_question($q, 'interactive', 5);
$this->allslots[] = $this->slot;
$this->process_submission(array('answer' => 'cat'));
$this->process_submission(array('answer' => 'frog', '-submit' => 1));
$q = question_bank::load_question($this->essay->id);
$this->start_attempt_at_question($q, 'interactive', 10);
$this->allslots[] = $this->slot;
$this->process_submission(array('answer' => '<p>The cat sat on the mat.</p>', 'answerformat' => FORMAT_HTML));
$this->finish();
$this->save_quba();
$this->usageids[] = $this->quba->get_id();
// Create the second usage.
$this->quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
$q = question_bank::load_question($this->sa->id);
$this->start_attempt_at_question($q, 'interactive', 5);
$this->process_submission(array('answer' => 'fish'));
$q = question_bank::load_question($this->essay->id);
$this->start_attempt_at_question($q, 'interactive', 10);
$this->finish();
$this->save_quba();
$this->usageids[] = $this->quba->get_id();
// Set up some things the tests will need.
$this->dm = new question_engine_data_mapper();
$this->bothusages = new qubaid_list($this->usageids);
// Now test the various queries.
$this->dotest_load_questions_usages_latest_steps($this->allslots);
$this->dotest_load_questions_usages_latest_steps(null);
$this->dotest_load_questions_usages_question_state_summary($this->allslots);
$this->dotest_load_questions_usages_question_state_summary(null);
$this->dotest_load_questions_usages_where_question_in_state();
$this->dotest_load_average_marks($this->allslots);
$this->dotest_load_average_marks(null);
$this->dotest_sum_usage_marks_subquery();
$this->dotest_question_attempt_latest_state_view();
}
/**
* This test is executed by {@link test_reporting_queries()}.
*
* @param array|null $slots list of slots to use in the call.
*/
protected function dotest_load_questions_usages_latest_steps($slots) {
$rawstates = $this->dm->load_questions_usages_latest_steps($this->bothusages, $slots,
'qa.id AS questionattemptid, qa.questionusageid, qa.slot, ' .
'qa.questionid, qa.maxmark, qas.sequencenumber, qas.state');
$states = array();
foreach ($rawstates as $state) {
$states[$state->questionusageid][$state->slot] = $state;
unset($state->questionattemptid);
unset($state->questionusageid);
unset($state->slot);
}
$state = $states[$this->usageids[0]][$this->allslots[0]];
$this->assertEquals((object) array(
'questionid' => $this->sa->id,
'maxmark' => 5.0,
'sequencenumber' => 2,
'state' => (string) question_state::$gradedright,
), $state);
$state = $states[$this->usageids[0]][$this->allslots[1]];
$this->assertEquals((object) array(
'questionid' => $this->essay->id,
'maxmark' => 10.0,
'sequencenumber' => 2,
'state' => (string) question_state::$needsgrading,
), $state);
$state = $states[$this->usageids[1]][$this->allslots[0]];
$this->assertEquals((object) array(
'questionid' => $this->sa->id,
'maxmark' => 5.0,
'sequencenumber' => 2,
'state' => (string) question_state::$gradedwrong,
), $state);
$state = $states[$this->usageids[1]][$this->allslots[1]];
$this->assertEquals((object) array(
'questionid' => $this->essay->id,
'maxmark' => 10.0,
'sequencenumber' => 1,
'state' => (string) question_state::$gaveup,
), $state);
}
/**
* This test is executed by {@link test_reporting_queries()}.
*
* @param array|null $slots list of slots to use in the call.
*/
protected function dotest_load_questions_usages_question_state_summary($slots) {
$summary = $this->dm->load_questions_usages_question_state_summary(
$this->bothusages, $slots);
$this->assertEquals($summary[$this->allslots[0] . ',' . $this->sa->id],
(object) array(
'slot' => $this->allslots[0],
'questionid' => $this->sa->id,
'name' => $this->sa->name,
'inprogress' => 0,
'needsgrading' => 0,
'autograded' => 2,
'manuallygraded' => 0,
'all' => 2,
));
$this->assertEquals($summary[$this->allslots[1] . ',' . $this->essay->id],
(object) array(
'slot' => $this->allslots[1],
'questionid' => $this->essay->id,
'name' => $this->essay->name,
'inprogress' => 0,
'needsgrading' => 1,
'autograded' => 1,
'manuallygraded' => 0,
'all' => 2,
));
}
/**
* This test is executed by {@link test_reporting_queries()}.
*/
protected function dotest_load_questions_usages_where_question_in_state() {
$this->assertEquals(
array(array($this->usageids[0], $this->usageids[1]), 2),
$this->dm->load_questions_usages_where_question_in_state($this->bothusages,
'all', $this->allslots[1], null, 'questionusageid'));
$this->assertEquals(
array(array($this->usageids[0], $this->usageids[1]), 2),
$this->dm->load_questions_usages_where_question_in_state($this->bothusages,
'autograded', $this->allslots[0], null, 'questionusageid'));
$this->assertEquals(
array(array($this->usageids[0]), 1),
$this->dm->load_questions_usages_where_question_in_state($this->bothusages,
'needsgrading', $this->allslots[1], null, 'questionusageid'));
}
/**
* This test is executed by {@link test_reporting_queries()}.
*
* @param array|null $slots list of slots to use in the call.
*/
protected function dotest_load_average_marks($slots) {
$averages = $this->dm->load_average_marks($this->bothusages, $slots);
$this->assertEquals(array(
$this->allslots[0] => (object) array(
'slot' => $this->allslots[0],
'averagefraction' => 0.5,
'numaveraged' => 2,
),
$this->allslots[1] => (object) array(
'slot' => $this->allslots[1],
'averagefraction' => 0,
'numaveraged' => 1,
),
), $averages);
}
/**
* This test is executed by {@link test_reporting_queries()}.
*/
protected function dotest_sum_usage_marks_subquery() {
global $DB;
$totals = $DB->get_records_sql_menu("SELECT qu.id, ({$this->dm->sum_usage_marks_subquery('qu.id')}) AS totalmark
FROM {question_usages} qu
WHERE qu.id IN ({$this->usageids[0]}, {$this->usageids[1]})");
$this->assertNull($totals[$this->usageids[0]]); // Since a question requires grading.
$this->assertNotNull($totals[$this->usageids[1]]); // Grrr! PHP null == 0 makes this hard.
$this->assertEquals(0, $totals[$this->usageids[1]]);
}
/**
* This test is executed by {@link test_reporting_queries()}.
*/
protected function dotest_question_attempt_latest_state_view() {
global $DB;
list($inlineview, $viewparams) = $this->dm->question_attempt_latest_state_view(
'lateststate', $this->bothusages);
$rawstates = $DB->get_records_sql("
SELECT lateststate.questionattemptid,
qu.id AS questionusageid,
lateststate.slot,
lateststate.questionid,
lateststate.maxmark,
lateststate.sequencenumber,
lateststate.state
FROM {question_usages} qu
LEFT JOIN $inlineview ON lateststate.questionusageid = qu.id
WHERE qu.id IN ({$this->usageids[0]}, {$this->usageids[1]})", $viewparams);
$states = array();
foreach ($rawstates as $state) {
$states[$state->questionusageid][$state->slot] = $state;
unset($state->questionattemptid);
unset($state->questionusageid);
unset($state->slot);
}
$state = $states[$this->usageids[0]][$this->allslots[0]];
$this->assertEquals((object) array(
'questionid' => $this->sa->id,
'maxmark' => 5.0,
'sequencenumber' => 2,
'state' => (string) question_state::$gradedright,
), $state);
$state = $states[$this->usageids[0]][$this->allslots[1]];
$this->assertEquals((object) array(
'questionid' => $this->essay->id,
'maxmark' => 10.0,
'sequencenumber' => 2,
'state' => (string) question_state::$needsgrading,
), $state);
$state = $states[$this->usageids[1]][$this->allslots[0]];
$this->assertEquals((object) array(
'questionid' => $this->sa->id,
'maxmark' => 5.0,
'sequencenumber' => 2,
'state' => (string) question_state::$gradedwrong,
), $state);
$state = $states[$this->usageids[1]][$this->allslots[1]];
$this->assertEquals((object) array(
'questionid' => $this->essay->id,
'maxmark' => 10.0,
'sequencenumber' => 1,
'state' => (string) question_state::$gaveup,
), $state);
}
/**
* Test that a Quiz with only description questions wont break \quiz_statistics\task\recalculate.
*
* @covers \quiz_statistics\task\recalculate::execute
*/
public function test_quiz_with_description_questions_recalculate_statistics(): void {
$this->resetAfterTest();
// Create course with quiz module.
$course = $this->getDataGenerator()->create_course();
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$layout = '1';
$quiz = $quizgenerator->create_instance([
'course' => $course->id,
'grade' => 0.0, 'sumgrades' => 1,
'layout' => $layout
]);
// Add question of type description to quiz.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$question = $questiongenerator->create_question('description', null, ['category' => $cat->id]);
quiz_add_quiz_question($question->id, $quiz);
// Create attempt.
$user = $this->getDataGenerator()->create_user();
$quizobj = quiz_settings::create($quiz->id, $user->id);
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
$timenow = time();
$attempt = quiz_create_attempt($quizobj, 1, null, $timenow, false, $user->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
quiz_attempt_save_started($quizobj, $quba, $attempt);
// Submit attempt.
$attemptobj = quiz_attempt::create($attempt->id);
$attemptobj->process_submitted_actions($timenow, false);
$attemptobj->process_finish($timenow, false);
// Calculate the statistics.
$this->expectOutputRegex('~.*Calculations completed.*~');
statistics_helper::run_pending_recalculation_tasks();
}
}
+308
View File
@@ -0,0 +1,308 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use qubaid_join;
use qubaid_list;
use question_bank;
use question_engine;
use question_engine_data_mapper;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for parts of {@link question_engine_data_mapper}.
*
* Note that many of the methods used when attempting questions, like
* load_questions_usage_by_activity, insert_question_*, delete_steps are
* tested elsewhere, e.g. by {@link question_usage_autosave_test}. We do not
* re-test them here.
*
* @package core_question
* @category test
* @copyright 2014 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \question_engine_data_mapper
*/
class datalib_test extends \qbehaviour_walkthrough_test_base {
/**
* We create two usages, each with two questions, a short-answer marked
* out of 5, and and essay marked out of 10. We just start these attempts.
*
* Then we change the max mark for the short-answer question in one of the
* usages to 20, using a qubaid_list, and verify.
*
* Then we change the max mark for the essay question in the other
* usage to 2, using a qubaid_join, and verify.
*/
public function test_set_max_mark_in_attempts(): void {
// Set up some things the tests will need.
$this->resetAfterTest();
$dm = new question_engine_data_mapper();
// Create the questions.
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$sa = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
$essay = $generator->create_question('essay', null,
array('category' => $cat->id));
// Create the first usage.
$q = question_bank::load_question($sa->id);
$this->start_attempt_at_question($q, 'interactive', 5);
$q = question_bank::load_question($essay->id);
$this->start_attempt_at_question($q, 'interactive', 10);
$this->finish();
$this->save_quba();
$usage1id = $this->quba->get_id();
// Create the second usage.
$this->quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
$q = question_bank::load_question($sa->id);
$this->start_attempt_at_question($q, 'interactive', 5);
$this->process_submission(array('answer' => 'fish'));
$q = question_bank::load_question($essay->id);
$this->start_attempt_at_question($q, 'interactive', 10);
$this->finish();
$this->save_quba();
$usage2id = $this->quba->get_id();
// Test set_max_mark_in_attempts with a qubaid_list.
$usagestoupdate = new qubaid_list(array($usage1id));
$dm->set_max_mark_in_attempts($usagestoupdate, 1, 20.0);
$quba1 = question_engine::load_questions_usage_by_activity($usage1id);
$quba2 = question_engine::load_questions_usage_by_activity($usage2id);
$this->assertEquals(20, $quba1->get_question_max_mark(1));
$this->assertEquals(10, $quba1->get_question_max_mark(2));
$this->assertEquals( 5, $quba2->get_question_max_mark(1));
$this->assertEquals(10, $quba2->get_question_max_mark(2));
// Test set_max_mark_in_attempts with a qubaid_join.
$usagestoupdate = new qubaid_join('{question_usages} qu', 'qu.id',
'qu.id = :usageid', array('usageid' => $usage2id));
$dm->set_max_mark_in_attempts($usagestoupdate, 2, 2.0);
$quba1 = question_engine::load_questions_usage_by_activity($usage1id);
$quba2 = question_engine::load_questions_usage_by_activity($usage2id);
$this->assertEquals(20, $quba1->get_question_max_mark(1));
$this->assertEquals(10, $quba1->get_question_max_mark(2));
$this->assertEquals( 5, $quba2->get_question_max_mark(1));
$this->assertEquals( 2, $quba2->get_question_max_mark(2));
// Test the nothing to do case.
$usagestoupdate = new qubaid_join('{question_usages} qu', 'qu.id',
'qu.id = :usageid', array('usageid' => -1));
$dm->set_max_mark_in_attempts($usagestoupdate, 2, 2.0);
$quba1 = question_engine::load_questions_usage_by_activity($usage1id);
$quba2 = question_engine::load_questions_usage_by_activity($usage2id);
$this->assertEquals(20, $quba1->get_question_max_mark(1));
$this->assertEquals(10, $quba1->get_question_max_mark(2));
$this->assertEquals( 5, $quba2->get_question_max_mark(1));
$this->assertEquals( 2, $quba2->get_question_max_mark(2));
}
public function test_load_used_variants(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$questiondata1 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
$questiondata2 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
$questiondata3 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
$quba = question_engine::make_questions_usage_by_activity('test', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$question1 = question_bank::load_question($questiondata1->id);
$question3 = question_bank::load_question($questiondata3->id);
$quba->add_question($question1);
$quba->add_question($question1);
$quba->add_question($question3);
$quba->start_all_questions();
question_engine::save_questions_usage_by_activity($quba);
$this->assertEquals(array(
$questiondata1->id => array(1 => 2),
$questiondata2->id => array(),
$questiondata3->id => array(1 => 1),
), question_engine::load_used_variants(
array($questiondata1->id, $questiondata2->id, $questiondata3->id),
new qubaid_list(array($quba->get_id()))));
}
public function test_repeated_usage_saving_new_usage(): void {
global $DB;
$this->resetAfterTest();
$initialqurows = $DB->count_records('question_usages');
$initialqarows = $DB->count_records('question_attempts');
$initialqasrows = $DB->count_records('question_attempt_steps');
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$questiondata1 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
$quba = question_engine::make_questions_usage_by_activity('test', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$quba->add_question(question_bank::load_question($questiondata1->id));
$quba->start_all_questions();
question_engine::save_questions_usage_by_activity($quba);
// Check one usage, question_attempts and step added.
$firstid = $quba->get_id();
$this->assertEquals(1, $DB->count_records('question_usages') - $initialqurows);
$this->assertEquals(1, $DB->count_records('question_attempts') - $initialqarows);
$this->assertEquals(1, $DB->count_records('question_attempt_steps') - $initialqasrows);
$quba->finish_all_questions();
question_engine::save_questions_usage_by_activity($quba);
// Check usage id not changed.
$this->assertEquals($firstid, $quba->get_id());
// Check still one usage, question_attempts, but now two steps.
$this->assertEquals(1, $DB->count_records('question_usages') - $initialqurows);
$this->assertEquals(1, $DB->count_records('question_attempts') - $initialqarows);
$this->assertEquals(2, $DB->count_records('question_attempt_steps') - $initialqasrows);
}
public function test_repeated_usage_saving_existing_usage(): void {
global $DB;
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$questiondata1 = $generator->create_question('shortanswer', null, array('category' => $cat->id));
$initquba = question_engine::make_questions_usage_by_activity('test', \context_system::instance());
$initquba->set_preferred_behaviour('deferredfeedback');
$slot = $initquba->add_question(question_bank::load_question($questiondata1->id));
$initquba->start_all_questions();
question_engine::save_questions_usage_by_activity($initquba);
$quba = question_engine::load_questions_usage_by_activity($initquba->get_id());
$initialqurows = $DB->count_records('question_usages');
$initialqarows = $DB->count_records('question_attempts');
$initialqasrows = $DB->count_records('question_attempt_steps');
$quba->process_all_actions(time(), $quba->prepare_simulated_post_data(
[$slot => ['answer' => 'Frog']]));
question_engine::save_questions_usage_by_activity($quba);
// Check one usage, question_attempts and step added.
$this->assertEquals(0, $DB->count_records('question_usages') - $initialqurows);
$this->assertEquals(0, $DB->count_records('question_attempts') - $initialqarows);
$this->assertEquals(1, $DB->count_records('question_attempt_steps') - $initialqasrows);
$quba->finish_all_questions();
question_engine::save_questions_usage_by_activity($quba);
// Check still one usage, question_attempts, but now two steps.
$this->assertEquals(0, $DB->count_records('question_usages') - $initialqurows);
$this->assertEquals(0, $DB->count_records('question_attempts') - $initialqarows);
$this->assertEquals(2, $DB->count_records('question_attempt_steps') - $initialqasrows);
}
/**
* Test that database operations on an empty usage work without errors.
*/
public function test_save_and_load_an_empty_usage(): void {
$this->resetAfterTest();
// Create a new usage.
$quba = question_engine::make_questions_usage_by_activity('test', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
// Save it.
question_engine::save_questions_usage_by_activity($quba);
// Reload it.
$reloadedquba = question_engine::load_questions_usage_by_activity($quba->get_id());
$this->assertCount(0, $quba->get_slots());
// Delete it.
question_engine::delete_questions_usage_by_activity($quba->get_id());
}
public function test_cannot_save_a_step_with_a_missing_state(): void {
global $DB;
$this->resetAfterTest();
// Create a question.
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$questiondata = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
// Create a usage.
$quba = question_engine::make_questions_usage_by_activity('test', \context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$slot = $quba->add_question(question_bank::load_question($questiondata->id));
$quba->start_all_questions();
// Add a step with a bad state.
$newstep = new \question_attempt_step();
$newstep->set_state(null);
$addstepmethod = new \ReflectionMethod('question_attempt', 'add_step');
$addstepmethod->invoke($quba->get_question_attempt($slot), $newstep);
// Verify that trying to save this throws an exception.
$this->expectException(\dml_write_exception::class);
question_engine::save_questions_usage_by_activity($quba);
}
/**
* Test cases for {@see test_get_file_area_name()}.
*
* @return array test cases
*/
public function get_file_area_name_cases(): array {
return [
'simple variable' => ['response_attachments', 'response_attachments'],
'behaviour variable' => ['response_5:answer', 'response_5answer'],
'variable with special character' => ['response_5:answer', 'response_5answer'],
'multiple underscores in different places' => ['response_weird____variable__name', 'response_weird_variable_name'],
];
}
/**
* Test get_file_area_name.
*
* @covers \question_file_saver::clean_file_area_name
* @dataProvider get_file_area_name_cases
*
* @param string $uncleanedfilearea
* @param string $expectedfilearea
*/
public function test_clean_file_area_name(string $uncleanedfilearea, string $expectedfilearea): void {
$this->assertEquals($expectedfilearea, \question_file_saver::clean_file_area_name($uncleanedfilearea));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use qubaid_condition;
use qubaid_join;
use qubaid_list;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
/**
* Unit tests for qubaid_condition and subclasses.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class qubaid_condition_test extends \advanced_testcase {
protected function normalize_sql($sql, $params) {
$newparams = array();
preg_match_all('/(?<!:):([a-z][a-z0-9_]*)/', $sql, $named_matches);
foreach($named_matches[1] as $param) {
if (array_key_exists($param, $params)) {
$newparams[] = $params[$param];
}
}
$newsql = preg_replace('/(?<!:):[a-z][a-z0-9_]*/', '?', $sql);
return array($newsql, $newparams);
}
protected function check_typical_question_attempts_query(
qubaid_condition $qubaids, $expectedsql, $expectedparams) {
$sql = "SELECT qa.id, qa.maxmark
FROM {$qubaids->from_question_attempts('qa')}
WHERE {$qubaids->where()} AND qa.slot = :slot";
$params = $qubaids->from_where_params();
$params['slot'] = 1;
// NOTE: parameter names may change thanks to $DB->inorequaluniqueindex, normal comparison is very wrong!!
list($sql, $params) = $this->normalize_sql($sql, $params);
list($expectedsql, $expectedparams) = $this->normalize_sql($expectedsql, $expectedparams);
$this->assertEquals($expectedsql, $sql);
$this->assertEquals($expectedparams, $params);
}
protected function check_typical_in_query(qubaid_condition $qubaids,
$expectedsql, $expectedparams) {
$sql = "SELECT qa.id, qa.maxmark
FROM {question_attempts} qa
WHERE qa.questionusageid {$qubaids->usage_id_in()}";
// NOTE: parameter names may change thanks to $DB->inorequaluniqueindex, normal comparison is very wrong!!
list($sql, $params) = $this->normalize_sql($sql, $qubaids->usage_id_in_params());
list($expectedsql, $expectedparams) = $this->normalize_sql($expectedsql, $expectedparams);
$this->assertEquals($expectedsql, $sql);
$this->assertEquals($expectedparams, $params);
}
public function test_qubaid_list_one_join(): void {
$qubaids = new qubaid_list(array(1));
$this->check_typical_question_attempts_query($qubaids,
"SELECT qa.id, qa.maxmark
FROM {question_attempts} qa
WHERE qa.questionusageid = :qubaid1 AND qa.slot = :slot",
array('qubaid1' => 1, 'slot' => 1));
}
public function test_qubaid_list_several_join(): void {
$qubaids = new qubaid_list(array(1, 3, 7));
$this->check_typical_question_attempts_query($qubaids,
"SELECT qa.id, qa.maxmark
FROM {question_attempts} qa
WHERE qa.questionusageid IN (:qubaid2,:qubaid3,:qubaid4) AND qa.slot = :slot",
array('qubaid2' => 1, 'qubaid3' => 3, 'qubaid4' => 7, 'slot' => 1));
}
public function test_qubaid_join(): void {
$qubaids = new qubaid_join("{other_table} ot", 'ot.usageid', 'ot.id = 1');
$this->check_typical_question_attempts_query($qubaids,
"SELECT qa.id, qa.maxmark
FROM {other_table} ot
JOIN {question_attempts} qa ON qa.questionusageid = ot.usageid
WHERE ot.id = 1 AND qa.slot = :slot", array('slot' => 1));
}
public function test_qubaid_join_no_where_join(): void {
$qubaids = new qubaid_join("{other_table} ot", 'ot.usageid');
$this->check_typical_question_attempts_query($qubaids,
"SELECT qa.id, qa.maxmark
FROM {other_table} ot
JOIN {question_attempts} qa ON qa.questionusageid = ot.usageid
WHERE 1 = 1 AND qa.slot = :slot", array('slot' => 1));
}
public function test_qubaid_list_one_in(): void {
global $CFG;
$qubaids = new qubaid_list(array(1));
$this->check_typical_in_query($qubaids,
"SELECT qa.id, qa.maxmark
FROM {question_attempts} qa
WHERE qa.questionusageid = :qubaid5", array('qubaid5' => 1));
}
public function test_qubaid_list_several_in(): void {
global $CFG;
$qubaids = new qubaid_list(array(1, 2, 3));
$this->check_typical_in_query($qubaids,
"SELECT qa.id, qa.maxmark
FROM {question_attempts} qa
WHERE qa.questionusageid IN (:qubaid6,:qubaid7,:qubaid8)",
array('qubaid6' => 1, 'qubaid7' => 2, 'qubaid8' => 3));
}
public function test_qubaid_join_in(): void {
global $CFG;
$qubaids = new qubaid_join("{other_table} ot", 'ot.usageid', 'ot.id = 1');
$this->check_typical_in_query($qubaids,
"SELECT qa.id, qa.maxmark
FROM {question_attempts} qa
WHERE qa.questionusageid IN (SELECT ot.usageid FROM {other_table} ot WHERE ot.id = 1)",
array());
}
public function test_qubaid_join_no_where_in(): void {
global $CFG;
$qubaids = new qubaid_join("{other_table} ot", 'ot.usageid');
$this->check_typical_in_query($qubaids,
"SELECT qa.id, qa.maxmark
FROM {question_attempts} qa
WHERE qa.questionusageid IN (SELECT ot.usageid FROM {other_table} ot WHERE 1 = 1)",
array());
}
}
@@ -0,0 +1,97 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
/**
* Unit tests for {@see \question_display_options}.
*
* @coversDefaultClass \question_display_options
* @package core_question
* @category test
* @copyright 2023 Jun Pataleta
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_display_options_test extends \advanced_testcase {
/**
* Data provider for {@see self::test_has_question_identifier()}
*
* @return array[]
*/
public function has_question_identifier_provider(): array {
return [
'Empty string' => ['', false],
'Empty space' => [' ', false],
'Null' => [null, false],
'Non-empty string' => ["Hello!", true],
];
}
/**
* Tests for {@see \question_display_options::has_question_identifier}
*
* @covers ::has_question_identifier
* @dataProvider has_question_identifier_provider
* @param string|null $identifier The question identifier
* @param bool $expected The expected return value
* @return void
*/
public function test_has_question_identifier(?string $identifier, bool $expected): void {
$options = new \question_display_options();
$options->questionidentifier = $identifier;
$this->assertEquals($expected, $options->has_question_identifier());
}
/**
* Data provider for {@see self::test_add_question_identifier_to_label()
*
* @return array[]
*/
public function add_question_identifier_to_label_provider(): array {
return [
'Empty string identifier' => ['Hello', '', false, false, "Hello"],
'Null identifier' => ['Hello', null, false, false, "Hello"],
'With identifier' => ['Hello', 'World', false, false, "Hello World"],
'With identifier, sr-only' => ['Hello', 'World', true, false, 'Hello <span class="sr-only">World</span>'],
'With identifier, prepend' => ['Hello', 'World', false, true, "World Hello"],
];
}
/**
* Tests for {@see \question_display_options::add_question_identifier_to_label()}
*
* @covers ::add_question_identifier_to_label
* @dataProvider add_question_identifier_to_label_provider
* @param string $label The label string.
* @param string|null $identifier The question identifier.
* @param bool $sronly Whether to render the question identifier in a sr-only container
* @param bool $addbefore Whether to render the question identifier before the label.
* @param string $expected The expected return value.
* @return void
*/
public function test_add_question_identifier_to_label(
string $label,
?string $identifier,
bool $sronly,
bool $addbefore,
string $expected
): void {
$options = new \question_display_options();
$options->questionidentifier = $identifier;
$this->assertEquals($expected, $options->add_question_identifier_to_label($label, $sronly, $addbefore));
}
}
@@ -0,0 +1,339 @@
<?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/>.
/**
* This file contains tests for the question_engine class.
*
* @package moodlecore
* @subpackage questionengine
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question;
use advanced_testcase;
use moodle_exception;
use question_engine;
/**
* Unit tests for the question_engine class.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \question_engine
*/
class question_engine_test extends advanced_testcase {
/**
* Load required libraries.
*/
public static function setUpBeforeClass(): void {
global $CFG;
require_once("{$CFG->dirroot}/question/engine/lib.php");
}
/**
* Tests for load_behaviour_class.
*
* @covers \question_engine::load_behaviour_class
*/
public function test_load_behaviour_class(): void {
// Exercise SUT.
question_engine::load_behaviour_class('deferredfeedback');
// Verify.
$this->assertTrue(class_exists('qbehaviour_deferredfeedback'));
}
/**
* Tests for load_behaviour_class when a class is missing.
*
* @covers \question_engine::load_behaviour_class
*/
public function test_load_behaviour_class_missing(): void {
// Exercise SUT.
$this->expectException(moodle_exception::class);
question_engine::load_behaviour_class('nonexistantbehaviour');
}
/**
* Test the get_behaviour_unused_display_options with various options.
*
* @covers \question_engine::get_behaviour_unused_display_options
* @dataProvider get_behaviour_unused_display_options_provider
* @param string $behaviour
* @param array $expected
*/
public function test_get_behaviour_unused_display_options(string $behaviour, array $expected): void {
$this->assertEquals($expected, question_engine::get_behaviour_unused_display_options($behaviour));
}
/**
* Data provider for get_behaviour_unused_display_options.
*
* @return array
*/
public function get_behaviour_unused_display_options_provider(): array {
return [
'interactive' => [
'interactive',
[],
],
'deferredfeedback' => [
'deferredfeedback',
['correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'],
],
'deferredcbm' => [
'deferredcbm',
['correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'],
],
'manualgraded' => [
'manualgraded',
['correctness', 'marks', 'specificfeedback', 'generalfeedback', 'rightanswer'],
],
];
}
/**
* Tests for can_questions_finish_during_the_attempt.
*
* @covers \question_engine::can_questions_finish_during_the_attempt
* @dataProvider can_questions_finish_during_the_attempt_provider
* @param string $behaviour
* @param bool $expected
*/
public function test_can_questions_finish_during_the_attempt(string $behaviour, bool $expected): void {
$this->assertEquals($expected, question_engine::can_questions_finish_during_the_attempt($behaviour));
}
/**
* Data provider for can_questions_finish_during_the_attempt_provider.
*
* @return array
*/
public function can_questions_finish_during_the_attempt_provider(): array {
return [
['deferredfeedback', false],
['interactive', true],
];
}
/**
* Tests for sort_behaviours
*
* @covers \question_engine::sort_behaviours
* @dataProvider sort_behaviours_provider
* @param array $input The params passed to sort_behaviours
* @param array $expected
*/
public function test_sort_behaviours(array $input, array $expected): void {
$this->assertSame($expected, question_engine::sort_behaviours(...$input));
}
/**
* Data provider for sort_behaviours.
*
* @return array
*/
public function sort_behaviours_provider(): array {
$in = [
'b1' => 'Behave 1',
'b2' => 'Behave 2',
'b3' => 'Behave 3',
'b4' => 'Behave 4',
'b5' => 'Behave 5',
'b6' => 'Behave 6',
];
return [
[
[$in, '', '', ''],
$in,
],
[
[$in, '', 'b4', 'b4'],
$in,
],
[
[$in, '', 'b1,b2,b3,b4', 'b4'],
['b4' => 'Behave 4', 'b5' => 'Behave 5', 'b6' => 'Behave 6'],
],
[
[$in, 'b6,b1,b4', 'b2,b3,b4,b5', 'b4'],
['b6' => 'Behave 6', 'b1' => 'Behave 1', 'b4' => 'Behave 4'],
],
[
[$in, 'b6,b5,b4', 'b1,b2,b3', 'b4'],
['b6' => 'Behave 6', 'b5' => 'Behave 5', 'b4' => 'Behave 4'],
],
[
[$in, 'b1,b6,b5', 'b1,b2,b3,b4', 'b4'],
['b6' => 'Behave 6', 'b5' => 'Behave 5', 'b4' => 'Behave 4'],
],
[
[$in, 'b2,b4,b6', 'b1,b3,b5', 'b2'],
['b2' => 'Behave 2', 'b4' => 'Behave 4', 'b6' => 'Behave 6'],
],
// Ignore unknown input in the order argument.
[
[$in, 'unknown', '', ''],
$in,
],
// Ignore unknown input in the disabled argument.
[
[$in, '', 'unknown', ''],
$in,
],
];
}
/**
* Tests for is_manual_grade_in_range.
*
* @dataProvider is_manual_grade_in_range_provider
* @covers \question_engine::is_manual_grade_in_range
* @param array $post The values to add to $_POST
* @param array $params The params to pass to is_manual_grade_in_range
* @param bool $expected
*/
public function test_is_manual_grade_in_range(array $post, array $params, bool $expected): void {
$_POST[] = $post;
$this->assertEquals($expected, question_engine::is_manual_grade_in_range(...$params));
}
/**
* Data provider for is_manual_grade_in_range tests.
*
* @return array
*/
public function is_manual_grade_in_range_provider(): array {
return [
'In range' => [
'post' => [
'q1:2_-mark' => 0.5,
'q1:2_-maxmark' => 1.0,
'q1:2_:minfraction' => 0,
'q1:2_:maxfraction' => 1,
],
'range' => [1, 2],
'expected' => true,
],
'Bottom end' => [
'post' => [
'q1:2_-mark' => -1.0,
'q1:2_-maxmark' => 2.0,
'q1:2_:minfraction' => -0.5,
'q1:2_:maxfraction' => 1,
],
'range' => [1, 2],
'expected' => true,
],
'Too low' => [
'post' => [
'q1:2_-mark' => -1.1,
'q1:2_-maxmark' => 2.0,
'q1:2_:minfraction' => -0.5,
'q1:2_:maxfraction' => 1,
],
'range' => [1, 2],
'expected' => true,
],
'Top end' => [
'post' => [
'q1:2_-mark' => 3.0,
'q1:2_-maxmark' => 1.0,
'q1:2_:minfraction' => -6.0,
'q1:2_:maxfraction' => 3.0,
],
'range' => [1, 2],
'expected' => true,
],
'Too high' => [
'post' => [
'q1:2_-mark' => 3.1,
'q1:2_-maxmark' => 1.0,
'q1:2_:minfraction' => -6.0,
'q1:2_:maxfraction' => 3.0,
],
'range' => [1, 2],
'expected' => true,
],
];
}
/**
* Tests for is_manual_grade_in_range.
*
* @covers \question_engine::is_manual_grade_in_range
*/
public function test_is_manual_grade_in_range_ungraded(): void {
$this->assertTrue(question_engine::is_manual_grade_in_range(1, 2));
}
/**
* Ensure that the number renderer performs as expected.
*
* @covers \core_question_renderer::number
* @dataProvider render_question_number_provider
* @param mixed $value
* @param string $expected
*/
public function test_render_question_number($value, string $expected): void {
global $PAGE;
$renderer = new \core_question_renderer($PAGE, 'core_question');
$rc = new \ReflectionClass($renderer);
$rcm = $rc->getMethod('number');
$this->assertEquals($expected, $rcm->invoke($renderer, $value));
}
/**
* Data provider for test_render_question_number.
*
* @return array
*/
public function render_question_number_provider(): array {
return [
'Test with number is i character' => [
'i',
'<h3 class="no">Information</h3>',
],
'Test with number is empty string' => [
'',
'',
],
'Test with null' => [
null,
'',
],
'Test with number is 0' => [
0,
'<h3 class="no">Question <span class="qno">0</span></h3>',
],
'Test with number is numeric' => [
1,
'<h3 class="no">Question <span class="qno">1</span></h3>',
],
'Test with number is string' => [
'1 of 2',
'<h3 class="no">Question <span class="qno">1 of 2</span></h3>',
],
];
}
}
@@ -0,0 +1,123 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use advanced_testcase;
use context_system;
use core_question\local\bank\question_version_status;
use core_question_generator;
/**
* Unit tests for the {@see question_reference_manager} class.
*
* @package core_question
* @category test
* @copyright 2011 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core_question\question_reference_manager
*/
class question_reference_manager_test extends advanced_testcase {
public function test_questions_with_references(): void {
global $DB;
$this->resetAfterTest();
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$systemcontext = context_system::instance();
// Create three questions, each with three versions.
// In each case, the third version is draft.
$cat = $questiongenerator->create_question_category();
$q1v1 = $questiongenerator->create_question('truefalse', null, ['name' => 'Q1V1', 'category' => $cat->id]);
$q1v2 = $questiongenerator->update_question($q1v1, null, ['name' => 'Q1V2']);
$q1v3 = $questiongenerator->update_question($q1v2, null,
['name' => 'Q1V3', 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
$q2v1 = $questiongenerator->create_question('truefalse', null, ['name' => 'Q2V1', 'category' => $cat->id]);
$q2v2 = $questiongenerator->update_question($q2v1, null, ['name' => 'Q2V2']);
$q2v3 = $questiongenerator->update_question($q2v2, null,
['name' => 'Q2V3', 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
$q3v1 = $questiongenerator->create_question('truefalse', null, ['name' => 'Q3V1', 'category' => $cat->id]);
$q3v2 = $questiongenerator->update_question($q3v1, null, ['name' => 'Q3V2']);
$q3v3 = $questiongenerator->update_question($q3v2, null,
['name' => 'Q3V3', 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
// Create specific references to Q2V1 and Q2V3.
$DB->insert_record('question_references', ['usingcontextid' => $systemcontext->id,
'component' => 'core_question', 'questionarea' => 'test', 'itemid' => 0,
'questionbankentryid' => $q2v1->questionbankentryid, 'version' => 1]);
$DB->insert_record('question_references', ['usingcontextid' => $systemcontext->id,
'component' => 'core_question', 'questionarea' => 'test', 'itemid' => 1,
'questionbankentryid' => $q2v1->questionbankentryid, 'version' => 3]);
// Create an always-latest reference to Q3.
$DB->insert_record('question_references', ['usingcontextid' => $systemcontext->id,
'component' => 'core_question', 'questionarea' => 'test', 'itemid' => 2,
'questionbankentryid' => $q3v1->questionbankentryid, 'version' => null]);
// Verify which versions of Q1 are used.
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([$q1v1->id]));
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([$q1v2->id]));
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([$q1v3->id]));
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([$q1v1->id, $q1v2->id, $q1v3->id]));
// Verify which versions of Q2 are used.
$this->assertEqualsCanonicalizing([$q2v1->id],
question_reference_manager::questions_with_references([$q2v1->id]));
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([$q2v2->id]));
$this->assertEqualsCanonicalizing([$q2v3->id],
question_reference_manager::questions_with_references([$q2v3->id]));
$this->assertEqualsCanonicalizing([$q2v1->id, $q2v3->id],
question_reference_manager::questions_with_references([$q2v1->id, $q2v2->id, $q2v3->id]));
// Verify which versions of Q1 are used.
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([$q3v1->id]));
$this->assertEqualsCanonicalizing([$q3v2->id],
question_reference_manager::questions_with_references([$q3v2->id]));
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([$q3v3->id]));
$this->assertEqualsCanonicalizing([$q3v2->id],
question_reference_manager::questions_with_references([$q3v1->id, $q3v2->id, $q3v3->id]));
// Do some combined queries.
$this->assertEqualsCanonicalizing([$q2v1->id, $q2v3->id, $q3v2->id],
question_reference_manager::questions_with_references([
$q1v1->id, $q1v2->id, $q1v3->id,
$q2v1->id, $q2v2->id, $q2v3->id,
$q3v1->id, $q3v2->id, $q3v3->id]));
$this->assertEqualsCanonicalizing([$q2v1->id, $q2v3->id, $q3v2->id],
question_reference_manager::questions_with_references([$q2v1->id, $q2v3->id, $q3v2->id]));
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([
$q1v1->id, $q1v2->id, $q1v3->id,
$q2v2->id,
$q3v1->id, $q3v3->id]));
// Test some edge cases.
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([]));
$this->assertEqualsCanonicalizing([],
question_reference_manager::questions_with_references([-1]));
}
}
@@ -0,0 +1,256 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_attempt;
use question_bank;
use question_engine;
use question_state;
use question_test_recordset;
use question_usage_null_observer;
use testable_question_engine_unit_of_work;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for loading data into the {@link question_attempt} class.
*
* Action methods like start, process_action and finish are assumed to be
* tested by walkthrough tests in the various behaviours.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionattempt_db_test extends \data_loading_method_test_base {
public function test_load(): void {
$records = new question_test_recordset(array(
array('questionattemptid', 'contextid', 'questionusageid', 'slot',
'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged',
'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
'attemptstepid', 'sequencenumber', 'state', 'fraction',
'timecreated', 'userid', 'name', 'value'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', null, 1256233705, 1, 'answer', '1'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 1, '', '', '', 1256233790, 3, 2, 'complete', null, 1256233710, 1, 'answer', '0'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 4, 3, 'complete', null, 1256233715, 1, 'answer', '1'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 4, 'gradedright', 1.0000000, 1256233720, 1, '-finish', '1'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-comment', 'Not good enough!'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-mark', '1'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 6, 5, 'mangrpartial', 0.5000000, 1256233790, 1, '-maxmark', '2'),
));
$question = \test_question_maker::make_question('truefalse', 'true');
$question->id = -1;
question_bank::start_unit_test();
question_bank::load_test_question_data($question);
$qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback');
question_bank::end_unit_test();
$this->assertEquals($question->questiontext, $qa->get_question(false)->questiontext);
$this->assertEquals(6, $qa->get_num_steps());
$step = $qa->get_step(0);
$this->assertEquals(question_state::$todo, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233700, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array(), $step->get_all_data());
$step = $qa->get_step(1);
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233705, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('answer' => '1'), $step->get_all_data());
$step = $qa->get_step(2);
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233710, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('answer' => '0'), $step->get_all_data());
$step = $qa->get_step(3);
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233715, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('answer' => '1'), $step->get_all_data());
$step = $qa->get_step(4);
$this->assertEquals(question_state::$gradedright, $step->get_state());
$this->assertEquals(1, $step->get_fraction());
$this->assertEquals(1256233720, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('-finish' => '1'), $step->get_all_data());
$step = $qa->get_step(5);
$this->assertEquals(question_state::$mangrpartial, $step->get_state());
$this->assertEquals(0.5, $step->get_fraction());
$this->assertEquals(1256233790, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('-comment' => 'Not good enough!', '-mark' => '1', '-maxmark' => '2'),
$step->get_all_data());
}
public function test_load_missing_question(): void {
$records = new question_test_recordset(array(
array('questionattemptid', 'contextid', 'questionusageid', 'slot',
'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged',
'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
'attemptstepid', 'sequencenumber', 'state', 'fraction',
'timecreated', 'userid', 'name', 'value'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null),
));
question_bank::start_unit_test();
$qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback');
question_bank::end_unit_test();
$missingq = question_bank::get_qtype('missingtype')->make_deleted_instance(-1, 2);
$this->assertEquals($missingq, $qa->get_question(false));
$this->assertEquals(1, $qa->get_num_steps());
$step = $qa->get_step(0);
$this->assertEquals(question_state::$todo, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233700, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array(), $step->get_all_data());
}
public function test_load_with_autosaved_data(): void {
$records = new question_test_recordset(array(
array('questionattemptid', 'contextid', 'questionusageid', 'slot',
'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged',
'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
'attemptstepid', 'sequencenumber', 'state', 'fraction',
'timecreated', 'userid', 'name', 'value'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 4, -3, 'complete', null, 1256233715, 1, 'answer', '1'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', null, 1256233705, 1, 'answer', '1'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 1, '', '', '', 1256233790, 3, 2, 'complete', null, 1256233710, 1, 'answer', '0'),
));
$question = \test_question_maker::make_question('truefalse', 'true');
$question->id = -1;
question_bank::start_unit_test();
question_bank::load_test_question_data($question);
$qa = question_attempt::load_from_records($records, 1, new question_usage_null_observer(), 'deferredfeedback');
question_bank::end_unit_test();
$this->assertEquals($question->questiontext, $qa->get_question(false)->questiontext);
$this->assertEquals(4, $qa->get_num_steps());
$this->assertTrue($qa->has_autosaved_step());
$step = $qa->get_step(0);
$this->assertEquals(question_state::$todo, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233700, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array(), $step->get_all_data());
$step = $qa->get_step(1);
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233705, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('answer' => '1'), $step->get_all_data());
$step = $qa->get_step(2);
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233710, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('answer' => '0'), $step->get_all_data());
$step = $qa->get_step(3);
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233715, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('answer' => '1'), $step->get_all_data());
}
public function test_load_with_unnecessary_autosaved_data(): void {
// The point here is that the somehow (probably due to two things
// happening concurrently, we have autosaved data in the database that
// has already been superceded by real data, so it should be ignored.
// There is also a second lot of redundant data to delete.
$records = new question_test_recordset(array(
array('questionattemptid', 'contextid', 'questionusageid', 'slot',
'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged',
'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
'attemptstepid', 'sequencenumber', 'state', 'fraction',
'timecreated', 'userid', 'name', 'value'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, -2, 'complete', null, 1256233715, 1, 'answer', '0'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 4, -1, 'complete', null, 1256233715, 1, 'answer', '0'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', null, 1256233705, 1, 'answer', '1'),
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 1.0000000, 1, '', '', '', 1256233790, 3, 2, 'complete', null, 1256233710, 1, 'answer', '0'),
));
$question = \test_question_maker::make_question('truefalse', 'true');
$question->id = -1;
question_bank::start_unit_test();
question_bank::load_test_question_data($question);
$observer = new testable_question_engine_unit_of_work(
question_engine::make_questions_usage_by_activity('unit_test', \context_system::instance()));
$qa = question_attempt::load_from_records($records, 1, $observer, 'deferredfeedback');
question_bank::end_unit_test();
$this->assertEquals($question->questiontext, $qa->get_question(false)->questiontext);
$this->assertEquals(3, $qa->get_num_steps());
$this->assertFalse($qa->has_autosaved_step());
$step = $qa->get_step(0);
$this->assertEquals(question_state::$todo, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233700, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array(), $step->get_all_data());
$step = $qa->get_step(1);
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233705, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('answer' => '1'), $step->get_all_data());
$step = $qa->get_step(2);
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233710, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('answer' => '0'), $step->get_all_data());
$this->assertEquals(2, count($observer->get_steps_deleted()));
}
}
@@ -0,0 +1,114 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_attempt;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for the {@link question_attempt} class.
*
* Action methods like start, process_action and finish are assumed to be
* tested by walkthrough tests in the various behaviours.
*
* These are the tests that don't require any steps.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionattempt_test extends \advanced_testcase {
/** @var question_definition a question that can be used in the tests. */
private $question;
/** @var int fake question_usage id used in some tests. */
private $usageid;
/** @var question_attempt a question attempt that can be used in the tests. */
private $qa;
protected function setUp(): void {
$this->question = \test_question_maker::make_question('description');
$this->question->defaultmark = 3;
$this->usageid = 13;
$this->qa = new question_attempt($this->question, $this->usageid);
}
public function test_constructor_sets_maxmark(): void {
$qa = new question_attempt($this->question, $this->usageid);
$this->assertSame($this->question, $qa->get_question(false));
$this->assertEquals(3, $qa->get_max_mark());
}
public function test_maxmark_beats_default_mark(): void {
$qa = new question_attempt($this->question, $this->usageid, null, 2);
$this->assertEquals(2, $qa->get_max_mark());
}
public function test_get_set_slot(): void {
$this->qa->set_slot(7);
$this->assertEquals(7, $this->qa->get_slot());
}
public function test_fagged_initially_false(): void {
$this->assertEquals(false, $this->qa->is_flagged());
}
public function test_set_is_flagged(): void {
$this->qa->set_flagged(true);
$this->assertEquals(true, $this->qa->is_flagged());
}
public function test_get_qt_field_name(): void {
$name = $this->qa->get_qt_field_name('test');
$this->assertMatchesRegularExpression('/^' . preg_quote($this->qa->get_field_prefix(), '/') . '/', $name);
$this->assertMatchesRegularExpression('/_test$/', $name);
}
public function test_get_behaviour_field_name(): void {
$name = $this->qa->get_behaviour_field_name('test');
$this->assertMatchesRegularExpression('/^' . preg_quote($this->qa->get_field_prefix(), '/') . '/', $name);
$this->assertMatchesRegularExpression('/_-test$/', $name);
}
public function test_get_field_prefix(): void {
$this->qa->set_slot(7);
$name = $this->qa->get_field_prefix();
$this->assertMatchesRegularExpression('/' . preg_quote($this->usageid, '/') . '/', $name);
$this->assertMatchesRegularExpression('/' . preg_quote($this->qa->get_slot(), '/') . '/', $name);
}
public function test_get_submitted_var_not_present_var_returns_null(): void {
$this->assertNull($this->qa->get_submitted_var(
'reallyunlikelyvariablename', PARAM_BOOL));
}
public function test_get_all_submitted_qt_vars(): void {
$this->qa->set_usage_id('MDOgzdhS4W');
$this->qa->set_slot(1);
$this->assertEquals(array('omval_response1' => 1, 'omval_response2' => 666, 'omact_gen_14' => 'Check'),
$this->qa->get_all_submitted_qt_vars(array(
'qMDOgzdhS4W:1_omval_response1' => 1,
'qMDOgzdhS4W:1_omval_response2' => 666,
'qMDOgzdhS4W:1_omact_gen_14' => 'Check',
)));
}
}
@@ -0,0 +1,208 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_attempt;
use question_attempt_step;
use question_state;
use testable_question_attempt;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* These tests use a standard fixture of a {@link question_attempt} with three steps.
*
* Action methods like start, process_action and finish are assumed to be
* tested by walkthrough tests in the various behaviours.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionattempt_with_steps_test extends \advanced_testcase {
private $question;
private $qa;
protected function setUp(): void {
$this->question = \test_question_maker::make_question('description');
$this->qa = new testable_question_attempt($this->question, 0, null, 2);
for ($i = 0; $i < 3; $i++) {
$step = new question_attempt_step(array('i' => $i));
$this->qa->add_step($step);
}
}
protected function tearDown(): void {
$this->qa = null;
}
public function test_get_step_before_start(): void {
$this->expectException(\moodle_exception::class);
$step = $this->qa->get_step(-1);
}
public function test_get_step_at_start(): void {
$step = $this->qa->get_step(0);
$this->assertEquals(0, $step->get_qt_var('i'));
}
public function test_get_step_at_end(): void {
$step = $this->qa->get_step(2);
$this->assertEquals(2, $step->get_qt_var('i'));
}
public function test_get_step_past_end(): void {
$this->expectException(\moodle_exception::class);
$step = $this->qa->get_step(3);
}
public function test_get_num_steps(): void {
$this->assertEquals(3, $this->qa->get_num_steps());
}
public function test_get_last_step(): void {
$step = $this->qa->get_last_step();
$this->assertEquals(2, $step->get_qt_var('i'));
}
public function test_get_last_qt_var_there1(): void {
$this->assertEquals(2, $this->qa->get_last_qt_var('i'));
}
public function test_get_last_qt_var_there2(): void {
$this->qa->get_step(0)->set_qt_var('_x', 'a value');
$this->assertEquals('a value', $this->qa->get_last_qt_var('_x'));
}
public function test_get_last_qt_var_missing(): void {
$this->assertNull($this->qa->get_last_qt_var('notthere'));
}
public function test_get_last_qt_var_missing_default(): void {
$this->assertEquals('default', $this->qa->get_last_qt_var('notthere', 'default'));
}
public function test_get_last_behaviour_var_missing(): void {
$this->assertNull($this->qa->get_last_qt_var('notthere'));
}
public function test_get_last_behaviour_var_there(): void {
$this->qa->get_step(1)->set_behaviour_var('_x', 'a value');
$this->assertEquals('a value', '' . $this->qa->get_last_behaviour_var('_x'));
}
public function test_get_state_gets_state_of_last(): void {
$this->qa->get_step(2)->set_state(question_state::$gradedright);
$this->qa->get_step(1)->set_state(question_state::$gradedwrong);
$this->assertEquals(question_state::$gradedright, $this->qa->get_state());
}
public function test_get_mark_gets_mark_of_last(): void {
$this->assertEquals(2, $this->qa->get_max_mark());
$this->qa->get_step(2)->set_fraction(0.5);
$this->qa->get_step(1)->set_fraction(0.1);
$this->assertEquals(1, $this->qa->get_mark());
}
public function test_get_fraction_gets_fraction_of_last(): void {
$this->qa->get_step(2)->set_fraction(0.5);
$this->qa->get_step(1)->set_fraction(0.1);
$this->assertEquals(0.5, $this->qa->get_fraction());
}
public function test_get_fraction_returns_null_if_none(): void {
$this->assertNull($this->qa->get_fraction());
}
public function test_format_mark(): void {
$this->qa->get_step(2)->set_fraction(0.5);
$this->assertEquals('1.00', $this->qa->format_mark(2));
}
public function test_format_max_mark(): void {
$this->assertEquals('2.0000000', $this->qa->format_max_mark(7));
}
public function test_get_min_fraction(): void {
$this->qa->set_min_fraction(-1);
$this->assertEquals(-1, $this->qa->get_min_fraction());
}
public function test_cannot_get_min_fraction_before_start(): void {
$qa = new question_attempt($this->question, 0);
$this->expectException('moodle_exception');
$qa->get_min_fraction();
}
public function test_get_max_fraction(): void {
$this->qa->set_max_fraction(2);
$this->assertEquals(2, $this->qa->get_max_fraction());
}
public function test_cannot_get_max_fraction_before_start(): void {
$qa = new question_attempt($this->question, 0);
$this->expectException('moodle_exception');
$qa->get_max_fraction();
}
/**
* Test cases for {@see test_validate_manual_mark()}.
*
* @return array test cases
*/
public function validate_manual_mark_cases(): array {
// Recall, the DB schema stores question grade information to 7 decimal places.
return [
[0, 1, 2, null, ''],
[0, 1, 2, '', ''],
[0, 1, 2, '0', ''],
[0, 1, 2, '0.0', ''],
[0, 1, 2, '2,0', ''],
[0, 1, 2, 'frog', get_string('manualgradeinvalidformat', 'question')],
[0, 1, 2, '2.1', get_string('manualgradeoutofrange', 'question')],
[0, 1, 2, '-0,01', get_string('manualgradeoutofrange', 'question')],
[-0.3333333, 1, 0.75, '0.75', ''],
[-0.3333333, 1, 0.75, '0.7500001', get_string('manualgradeoutofrange', 'question')],
[-0.3333333, 1, 0.75, '-0.25', ''],
[-0.3333333, 1, 0.75, '-0.2500001', get_string('manualgradeoutofrange', 'question')],
];
}
/**
* Test validate_manual_mark.
*
* @dataProvider validate_manual_mark_cases
*
* @param float $minfraction minimum fraction for the question being attempted.
* @param float $maxfraction maximum fraction for the question being attempted.
* @param float $maxmark marks for the question attempt.
* @param string|null $currentmark submitted mark.
* @param string $expectederror expected error, if any.
*/
public function test_validate_manual_mark(float $minfraction, float $maxfraction,
float $maxmark, ?string $currentmark, string $expectederror): void {
$this->qa->set_min_fraction($minfraction);
$this->qa->set_max_fraction($maxfraction);
$this->qa->set_max_mark($maxmark);
$this->assertSame($expectederror, $this->qa->validate_manual_mark($currentmark));
}
}
@@ -0,0 +1,112 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_engine;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* This file contains tests for the {@link question_attempt_iterator} class.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionattemptiterator_test extends \advanced_testcase {
private $quba;
private $qas = array();
private $iterator;
protected function setUp(): void {
$this->quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
$this->quba->set_preferred_behaviour('deferredfeedback');
$slot = $this->quba->add_question(\test_question_maker::make_question('description'));
$this->qas[$slot] = $this->quba->get_question_attempt($slot);
$slot = $this->quba->add_question(\test_question_maker::make_question('description'));
$this->qas[$slot] = $this->quba->get_question_attempt($slot);
$this->iterator = $this->quba->get_attempt_iterator();
}
protected function tearDown(): void {
$this->quba = null;
$this->iterator = null;
}
public function test_foreach_loop(): void {
$i = 1;
foreach ($this->iterator as $key => $qa) {
$this->assertEquals($i, $key);
$this->assertSame($this->qas[$i], $qa);
$i++;
}
$this->assertEquals(3, $i);
}
public function test_offsetExists_before_start(): void {
$this->assertFalse(isset($this->iterator[0]));
}
public function test_offsetExists_at_start(): void {
$this->assertTrue(isset($this->iterator[1]));
}
public function test_offsetExists_at_endt(): void {
$this->assertTrue(isset($this->iterator[2]));
}
public function test_offsetExists_past_end(): void {
$this->assertFalse(isset($this->iterator[3]));
}
public function test_offsetGet_before_start(): void {
$this->expectException(\moodle_exception::class);
$step = $this->iterator[0];
}
public function test_offsetGet_at_start(): void {
$this->assertSame($this->qas[1], $this->iterator[1]);
}
public function test_offsetGet_at_end(): void {
$this->assertSame($this->qas[2], $this->iterator[2]);
}
public function test_offsetGet_past_end(): void {
$this->expectException(\moodle_exception::class);
$step = $this->iterator[3];
}
public function test_cannot_set(): void {
$this->expectException(\moodle_exception::class);
$this->iterator[0] = null;
}
public function test_cannot_unset(): void {
$this->expectException(\moodle_exception::class);
unset($this->iterator[2]);
}
}
@@ -0,0 +1,81 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_attempt_step;
use question_state;
use question_test_recordset;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for the loading data into the {@link question_attempt_step} class.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionattemptstep_db_test extends \data_loading_method_test_base {
public function test_load_with_data(): void {
$records = new question_test_recordset(array(
array('attemptstepid', 'questionattemptid', 'sequencenumber', 'state', 'fraction', 'timecreated', 'userid', 'name', 'value', 'qtype', 'contextid'),
array( 1, 1, 0, 'todo', null, 1256228502, 13, null, null, 'description', 1),
array( 2, 1, 1, 'complete', null, 1256228505, 13, 'x', 'a', 'description', 1),
array( 2, 1, 1, 'complete', null, 1256228505, 13, '_y', '_b', 'description', 1),
array( 2, 1, 1, 'complete', null, 1256228505, 13, '-z', '!c', 'description', 1),
array( 2, 1, 1, 'complete', null, 1256228505, 13, '-_t', '!_d', 'description', 1),
array( 3, 1, 2, 'gradedright', 1.0, 1256228515, 13, '-finish', '1', 'description', 1),
));
$step = question_attempt_step::load_from_records($records, 2);
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256228505, $step->get_timecreated());
$this->assertEquals(13, $step->get_user_id());
$this->assertEquals(array('x' => 'a', '_y' => '_b', '-z' => '!c', '-_t' => '!_d'), $step->get_all_data());
}
public function test_load_without_data(): void {
$records = new question_test_recordset(array(
array('attemptstepid', 'questionattemptid', 'sequencenumber', 'state', 'fraction', 'timecreated', 'userid', 'name', 'value', 'contextid'),
array( 2, 1, 1, 'complete', null, 1256228505, 13, null, null, 1),
));
$step = question_attempt_step::load_from_records($records, 2, 'description');
$this->assertEquals(question_state::$complete, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256228505, $step->get_timecreated());
$this->assertEquals(13, $step->get_user_id());
$this->assertEquals(array(), $step->get_all_data());
}
public function test_load_dont_be_too_greedy(): void {
$records = new question_test_recordset(array(
array('attemptstepid', 'questionattemptid', 'sequencenumber', 'state', 'fraction', 'timecreated', 'userid', 'name', 'value', 'contextid'),
array( 1, 1, 0, 'todo', null, 1256228502, 13, 'x', 'right', 1),
array( 2, 2, 0, 'complete', null, 1256228505, 13, 'x', 'wrong', 1),
));
$step = question_attempt_step::load_from_records($records, 1, 'description');
$this->assertEquals(array('x' => 'right'), $step->get_all_data());
}
}
@@ -0,0 +1,173 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_attempt_step;
use question_state;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for the {@link question_attempt_step} class.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionattemptstep_test extends \advanced_testcase {
public function test_initial_state_unprocessed(): void {
$step = new question_attempt_step();
$this->assertEquals(question_state::$unprocessed, $step->get_state());
}
public function test_get_set_state(): void {
$step = new question_attempt_step();
$step->set_state(question_state::$gradedright);
$this->assertEquals(question_state::$gradedright, $step->get_state());
}
public function test_initial_fraction_null(): void {
$step = new question_attempt_step();
$this->assertNull($step->get_fraction());
}
public function test_get_set_fraction(): void {
$step = new question_attempt_step();
$step->set_fraction(0.5);
$this->assertEquals(0.5, $step->get_fraction());
}
public function test_has_var(): void {
$step = new question_attempt_step(array('x' => 1, '-y' => 'frog'));
$this->assertTrue($step->has_qt_var('x'));
$this->assertTrue($step->has_behaviour_var('y'));
$this->assertFalse($step->has_qt_var('y'));
$this->assertFalse($step->has_behaviour_var('x'));
}
public function test_get_var(): void {
$step = new question_attempt_step(array('x' => 1, '-y' => 'frog'));
$this->assertEquals('1', $step->get_qt_var('x'));
$this->assertEquals('frog', $step->get_behaviour_var('y'));
$this->assertNull($step->get_qt_var('y'));
}
public function test_set_var(): void {
$step = new question_attempt_step();
$step->set_qt_var('_x', 1);
$step->set_behaviour_var('_x', 2);
$this->assertEquals('1', $step->get_qt_var('_x'));
$this->assertEquals('2', $step->get_behaviour_var('_x'));
}
public function test_cannot_set_qt_var_without_underscore(): void {
$step = new question_attempt_step();
$this->expectException('moodle_exception');
$step->set_qt_var('x', 1);
}
public function test_cannot_set_behaviour_var_without_underscore(): void {
$step = new question_attempt_step();
$this->expectException('moodle_exception');
$step->set_behaviour_var('x', 1);
}
public function test_get_data(): void {
$step = new question_attempt_step(array('x' => 1, '-y' => 'frog', ':flagged' => 1));
$this->assertEquals(array('x' => '1'), $step->get_qt_data());
$this->assertEquals(array('y' => 'frog'), $step->get_behaviour_data());
$this->assertEquals(array('x' => 1, '-y' => 'frog', ':flagged' => 1), $step->get_all_data());
}
public function test_get_submitted_data(): void {
$step = new question_attempt_step(array('x' => 1, '-y' => 'frog'));
$step->set_qt_var('_x', 1);
$step->set_behaviour_var('_x', 2);
$this->assertEquals(array('x' => 1, '-y' => 'frog'), $step->get_submitted_data());
}
public function test_constructor_default_params(): void {
global $USER;
$step = new question_attempt_step();
$this->assertEquals(time(), $step->get_timecreated(), 5);
$this->assertEquals($USER->id, $step->get_user_id());
$this->assertEquals(array(), $step->get_qt_data());
$this->assertEquals(array(), $step->get_behaviour_data());
}
public function test_constructor_given_params(): void {
global $USER;
$step = new question_attempt_step(array(), 123, 5);
$this->assertEquals(123, $step->get_timecreated());
$this->assertEquals(5, $step->get_user_id());
$this->assertEquals(array(), $step->get_qt_data());
$this->assertEquals(array(), $step->get_behaviour_data());
}
/**
* Test get_user function.
*/
public function test_get_user(): void {
$this->resetAfterTest(true);
$student = $this->getDataGenerator()->create_user();
$step = new question_attempt_step(array(), 123, $student->id);
$step->add_full_user_object($student);
$this->assertEquals($student, $step->get_user());
}
/**
* Test get_user_fullname function.
*/
public function test_get_user_fullname(): void {
$this->resetAfterTest(true);
$student = $this->getDataGenerator()->create_user();
$step = new question_attempt_step(array(), 123, $student->id);
$step->add_full_user_object($student);
$this->assertEquals(fullname($student), $step->get_user_fullname());
}
/**
* Test add_full_user_object function.
*/
public function test_add_full_user_object(): void {
$this->resetAfterTest(true);
$student1 = $this->getDataGenerator()->create_user();
$student2 = $this->getDataGenerator()->create_user();
$step = new question_attempt_step(array(), 123, $student1->id);
// Add full user with the valid user.
$step->add_full_user_object($student1);
$this->assertEquals($student1, $step->get_user());
// Throw exception with the invalid user.
$this->expectException('coding_exception');
$this->expectExceptionMessage('Wrong user passed to add_full_user_object');
$step->add_full_user_object($student2);
}
}
@@ -0,0 +1,132 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_attempt_step;
use testable_question_attempt;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for the {@link question_attempt_step_iterator} class.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionattemptstepiterator_test extends \advanced_testcase {
private $qa;
private $iterator;
protected function setUp(): void {
$question = \test_question_maker::make_question('description');
$this->qa = new testable_question_attempt($question, 0);
for ($i = 0; $i < 3; $i++) {
$step = new question_attempt_step(array('i' => $i));
$this->qa->add_step($step);
}
$this->iterator = $this->qa->get_step_iterator();
}
protected function tearDown(): void {
$this->qa = null;
$this->iterator = null;
}
public function test_foreach_loop(): void {
$i = 0;
foreach ($this->iterator as $key => $step) {
$this->assertEquals($i, $key);
$this->assertEquals($i, $step->get_qt_var('i'));
$i++;
}
}
public function test_foreach_loop_add_step_during(): void {
$i = 0;
foreach ($this->iterator as $key => $step) {
$this->assertEquals($i, $key);
$this->assertEquals($i, $step->get_qt_var('i'));
$i++;
if ($i == 2) {
$step = new question_attempt_step(array('i' => 3));
$this->qa->add_step($step);
}
}
$this->assertEquals(4, $i);
}
public function test_reverse_foreach_loop(): void {
$i = 2;
foreach ($this->qa->get_reverse_step_iterator() as $key => $step) {
$this->assertEquals($i, $key);
$this->assertEquals($i, $step->get_qt_var('i'));
$i--;
}
}
public function test_offsetExists_before_start(): void {
$this->assertFalse(isset($this->iterator[-1]));
}
public function test_offsetExists_at_start(): void {
$this->assertTrue(isset($this->iterator[0]));
}
public function test_offsetExists_at_endt(): void {
$this->assertTrue(isset($this->iterator[2]));
}
public function test_offsetExists_past_end(): void {
$this->assertFalse(isset($this->iterator[3]));
}
public function test_offsetGet_before_start(): void {
$this->expectException(\moodle_exception::class);
$step = $this->iterator[-1];
}
public function test_offsetGet_at_start(): void {
$step = $this->iterator[0];
$this->assertEquals(0, $step->get_qt_var('i'));
}
public function test_offsetGet_at_end(): void {
$step = $this->iterator[2];
$this->assertEquals(2, $step->get_qt_var('i'));
}
public function test_offsetGet_past_end(): void {
$this->expectException(\moodle_exception::class);
$step = $this->iterator[3];
}
public function test_cannot_set(): void {
$this->expectException(\moodle_exception::class);
$this->iterator[0] = null;
}
public function test_cannot_unset(): void {
$this->expectException(\moodle_exception::class);
unset($this->iterator[2]);
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use core_question\local\bank\question_version_status;
use qubaid_list;
use question_bank;
use question_engine;
use question_finder;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
/**
* Unit tests for the {@see question_bank} class.
*
* @package core_question
* @category test
* @copyright 2011 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionbank_test extends \advanced_testcase {
public function test_sort_qtype_array(): void {
$config = new \stdClass();
$config->multichoice_sortorder = '1';
$config->calculated_sortorder = '2';
$qtypes = array(
'frog' => 'toad',
'calculated' => 'newt',
'multichoice' => 'eft',
);
$this->assertEquals(question_bank::sort_qtype_array($qtypes, $config), array(
'multichoice' => 'eft',
'calculated' => 'newt',
'frog' => 'toad',
));
}
public function test_fraction_options(): void {
$fractions = question_bank::fraction_options();
$this->assertSame(get_string('none'), reset($fractions));
$this->assertSame('0.0', key($fractions));
$this->assertSame('5%', end($fractions));
$this->assertSame('0.05', key($fractions));
array_shift($fractions);
array_pop($fractions);
array_pop($fractions);
$this->assertSame('100%', reset($fractions));
$this->assertSame('1.0', key($fractions));
$this->assertSame('11.11111%', end($fractions));
$this->assertSame('0.1111111', key($fractions));
}
public function test_fraction_options_full(): void {
$fractions = question_bank::fraction_options_full();
$this->assertSame(get_string('none'), reset($fractions));
$this->assertSame('0.0', key($fractions));
$this->assertSame('-100%', end($fractions));
$this->assertSame('-1.0', key($fractions));
array_shift($fractions);
array_pop($fractions);
array_pop($fractions);
$this->assertSame('100%', reset($fractions));
$this->assertSame('1.0', key($fractions));
$this->assertSame('-83.33333%', end($fractions));
$this->assertSame('-0.8333333', key($fractions));
}
public function test_load_many_for_cache(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$q1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]);
$qs = question_finder::get_instance()->load_many_for_cache([$q1->id]);
$this->assertArrayHasKey($q1->id, $qs);
}
public function test_load_many_for_cache_missing_id(): void {
// Try to load a non-existent question.
$this->expectException(\dml_missing_record_exception::class);
question_finder::get_instance()->load_many_for_cache([-1]);
}
/**
* Test get_questions_from_categories.
*
* @covers \question_finder::get_questions_from_categories
*
* @return void
*/
public function test_get_questions_from_categories(): void {
$this->resetAfterTest();
/** @var core_question_generator $questiongenerator */
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create three questions in a question bank category, each with three versions.
// The first question has all three versions in status ready.
$cat = $questiongenerator->create_question_category();
$q1v1 = $questiongenerator->create_question('truefalse', null, ['name' => 'Q1V1', 'category' => $cat->id]);
$q1v2 = $questiongenerator->update_question($q1v1, null, ['name' => 'Q1V2']);
$q1v3 = $questiongenerator->update_question($q1v2, null, ['name' => 'Q1V3']);
// The second question has the first version in status draft, the second version in status ready,
// and third version in status draft.
$q2v1 = $questiongenerator->create_question('numerical', null, ['name' => 'Q2V2', 'category' => $cat->id,
'status' => question_version_status::QUESTION_STATUS_DRAFT, ]);
$q2v2 = $questiongenerator->update_question($q2v1, null, ['name' => 'Q2V2',
'status' => question_version_status::QUESTION_STATUS_READY, ]);
$q2v3 = $questiongenerator->update_question($q2v2, null,
['name' => 'Q2V3', 'status' => question_version_status::QUESTION_STATUS_DRAFT]);
// The third question has all three version in status draft.
$q3v1 = $questiongenerator->create_question('shortanswer', null, ['name' => 'Q3V1', 'category' => $cat->id,
'status' => question_version_status::QUESTION_STATUS_DRAFT, ]);
$q3v2 = $questiongenerator->update_question($q3v1, null, ['name' => 'Q3V2',
'status' => question_version_status::QUESTION_STATUS_DRAFT, ]);
$q3v3 = $questiongenerator->update_question($q3v2, null, ['name' => 'Q3V3',
'status' => question_version_status::QUESTION_STATUS_DRAFT]);
// Test the returned array of questions in that category is the desired one with version three of the first
// question, version two of the second question, and the third question omitted completely since there are
// only draft versions.
$this->assertEquals([$q1v3->id => $q1v3->id, $q2v2->id => $q2v2->id],
question_bank::get_finder()->get_questions_from_categories([$cat->id], ""));
}
}
@@ -0,0 +1,168 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_state;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once($CFG->libdir . '/questionlib.php');
/**
* Unit tests for the {@link question_state} class and subclasses.
*
* @package core_question
* @category test
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \question_state
*/
class questionstate_test extends \advanced_testcase {
public function test_is_active(): void {
$this->assertFalse(question_state::$notstarted->is_active());
$this->assertFalse(question_state::$unprocessed->is_active());
$this->assertTrue(question_state::$todo->is_active());
$this->assertTrue(question_state::$invalid->is_active());
$this->assertTrue(question_state::$complete->is_active());
$this->assertFalse(question_state::$needsgrading->is_active());
$this->assertFalse(question_state::$finished->is_active());
$this->assertFalse(question_state::$gaveup->is_active());
$this->assertFalse(question_state::$gradedwrong->is_active());
$this->assertFalse(question_state::$gradedpartial->is_active());
$this->assertFalse(question_state::$gradedright->is_active());
$this->assertFalse(question_state::$manfinished->is_active());
$this->assertFalse(question_state::$mangaveup->is_active());
$this->assertFalse(question_state::$mangrwrong->is_active());
$this->assertFalse(question_state::$mangrpartial->is_active());
$this->assertFalse(question_state::$mangrright->is_active());
}
public function test_is_finished(): void {
$this->assertFalse(question_state::$notstarted->is_finished());
$this->assertFalse(question_state::$unprocessed->is_finished());
$this->assertFalse(question_state::$todo->is_finished());
$this->assertFalse(question_state::$invalid->is_finished());
$this->assertFalse(question_state::$complete->is_finished());
$this->assertTrue(question_state::$needsgrading->is_finished());
$this->assertTrue(question_state::$finished->is_finished());
$this->assertTrue(question_state::$gaveup->is_finished());
$this->assertTrue(question_state::$gradedwrong->is_finished());
$this->assertTrue(question_state::$gradedpartial->is_finished());
$this->assertTrue(question_state::$gradedright->is_finished());
$this->assertTrue(question_state::$manfinished->is_finished());
$this->assertTrue(question_state::$mangaveup->is_finished());
$this->assertTrue(question_state::$mangrwrong->is_finished());
$this->assertTrue(question_state::$mangrpartial->is_finished());
$this->assertTrue(question_state::$mangrright->is_finished());
}
public function test_is_graded(): void {
$this->assertFalse(question_state::$notstarted->is_graded());
$this->assertFalse(question_state::$unprocessed->is_graded());
$this->assertFalse(question_state::$todo->is_graded());
$this->assertFalse(question_state::$invalid->is_graded());
$this->assertFalse(question_state::$complete->is_graded());
$this->assertFalse(question_state::$needsgrading->is_graded());
$this->assertFalse(question_state::$finished->is_graded());
$this->assertFalse(question_state::$gaveup->is_graded());
$this->assertTrue(question_state::$gradedwrong->is_graded());
$this->assertTrue(question_state::$gradedpartial->is_graded());
$this->assertTrue(question_state::$gradedright->is_graded());
$this->assertFalse(question_state::$manfinished->is_graded());
$this->assertFalse(question_state::$mangaveup->is_graded());
$this->assertTrue(question_state::$mangrwrong->is_graded());
$this->assertTrue(question_state::$mangrpartial->is_graded());
$this->assertTrue(question_state::$mangrright->is_graded());
}
public function test_is_commented(): void {
$this->assertFalse(question_state::$notstarted->is_commented());
$this->assertFalse(question_state::$unprocessed->is_commented());
$this->assertFalse(question_state::$todo->is_commented());
$this->assertFalse(question_state::$invalid->is_commented());
$this->assertFalse(question_state::$complete->is_commented());
$this->assertFalse(question_state::$needsgrading->is_commented());
$this->assertFalse(question_state::$finished->is_commented());
$this->assertFalse(question_state::$gaveup->is_commented());
$this->assertFalse(question_state::$gradedwrong->is_commented());
$this->assertFalse(question_state::$gradedpartial->is_commented());
$this->assertFalse(question_state::$gradedright->is_commented());
$this->assertTrue(question_state::$manfinished->is_commented());
$this->assertTrue(question_state::$mangaveup->is_commented());
$this->assertTrue(question_state::$mangrwrong->is_commented());
$this->assertTrue(question_state::$mangrpartial->is_commented());
$this->assertTrue(question_state::$mangrright->is_commented());
}
public function test_graded_state_for_fraction(): void {
$this->assertEquals(question_state::$gradedwrong, question_state::graded_state_for_fraction(-1));
$this->assertEquals(question_state::$gradedwrong, question_state::graded_state_for_fraction(0));
$this->assertEquals(question_state::$gradedpartial, question_state::graded_state_for_fraction(0.000001));
$this->assertEquals(question_state::$gradedpartial, question_state::graded_state_for_fraction(0.999999));
$this->assertEquals(question_state::$gradedright, question_state::graded_state_for_fraction(1));
}
public function test_manually_graded_state_for_other_state(): void {
$this->assertEquals(question_state::$manfinished,
question_state::$finished->corresponding_commented_state(null));
$this->assertEquals(question_state::$mangaveup,
question_state::$gaveup->corresponding_commented_state(null));
$this->assertEquals(question_state::$manfinished,
question_state::$manfinished->corresponding_commented_state(null));
$this->assertEquals(question_state::$mangaveup,
question_state::$mangaveup->corresponding_commented_state(null));
$this->assertEquals(question_state::$needsgrading,
question_state::$mangrright->corresponding_commented_state(null));
$this->assertEquals(question_state::$needsgrading,
question_state::$mangrright->corresponding_commented_state(null));
$this->assertEquals(question_state::$mangrwrong,
question_state::$gaveup->corresponding_commented_state(0));
$this->assertEquals(question_state::$mangrwrong,
question_state::$needsgrading->corresponding_commented_state(0));
$this->assertEquals(question_state::$mangrwrong,
question_state::$gradedwrong->corresponding_commented_state(0));
$this->assertEquals(question_state::$mangrwrong,
question_state::$gradedpartial->corresponding_commented_state(0));
$this->assertEquals(question_state::$mangrwrong,
question_state::$gradedright->corresponding_commented_state(0));
$this->assertEquals(question_state::$mangrwrong,
question_state::$mangrright->corresponding_commented_state(0));
$this->assertEquals(question_state::$mangrwrong,
question_state::$mangrpartial->corresponding_commented_state(0));
$this->assertEquals(question_state::$mangrwrong,
question_state::$mangrright->corresponding_commented_state(0));
$this->assertEquals(question_state::$mangrpartial,
question_state::$gradedpartial->corresponding_commented_state(0.5));
$this->assertEquals(question_state::$mangrright,
question_state::$gradedpartial->corresponding_commented_state(1));
}
public function test_get(): void {
$this->assertEquals(question_state::$todo, question_state::get('todo'));
}
public function test_get_bad_data(): void {
question_state::get('');
$this->assertDebuggingCalled('Attempt to create a state from an empty string. ' .
'This is probably a sign of bad data in your database. See MDL-80127.');
}
}
@@ -0,0 +1,751 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_bank;
use question_state;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for the autosave parts of the {@link question_usage} class.
*
* @package core_question
* @category test
* @copyright 2013 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionusage_autosave_test extends \qbehaviour_walkthrough_test_base {
public function test_autosave_then_display(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Process a response and check the expected result.
$this->process_submission(array('answer' => 'first response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process an autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'second response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$this->delete_quba();
}
public function test_autosave_then_autosave_different_data(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Process a response and check the expected result.
$this->process_submission(array('answer' => 'first response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process an autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'second response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process a second autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'third response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'third response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$this->delete_quba();
}
public function test_autosave_then_autosave_same_data(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Process a response and check the expected result.
$this->process_submission(array('answer' => 'first response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process an autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'second response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$stepid = $this->quba->get_question_attempt($this->slot)->get_last_step()->get_id();
// Process a second autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'second response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Try to check it is really the same step
$newstepid = $this->quba->get_question_attempt($this->slot)->get_last_step()->get_id();
$this->assertEquals($stepid, $newstepid);
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$this->delete_quba();
}
public function test_autosave_then_autosave_original_data(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Process a response and check the expected result.
$this->process_submission(array('answer' => 'first response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process an autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'second response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process a second autosave saving the original response.
// This should remove the autosave step.
$this->load_quba();
$this->process_autosave(array('answer' => 'first response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$this->delete_quba();
}
public function test_autosave_then_real_save(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Process a response and check the expected result.
$this->process_submission(array('answer' => 'first response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process an autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'second response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Now save for real a third response.
$this->process_submission(array('answer' => 'third response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'third response');
$this->check_output_contains_hidden_input(':sequencecheck', 3);
}
public function test_autosave_then_real_save_same(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Process a response and check the expected result.
$this->process_submission(array('answer' => 'first response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process an autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'second response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Now save for real of the same response.
$this->process_submission(array('answer' => 'second response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 3);
}
public function test_autosave_then_submit(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Process a response and check the expected result.
$this->process_submission(array('answer' => 'first response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process an autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'second response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Now submit a third response.
$this->process_submission(array('answer' => 'third response'));
$this->quba->finish_all_questions();
$this->check_current_state(question_state::$gradedwrong);
$this->check_current_mark(0);
$this->check_step_count(4);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'third response', false);
$this->check_output_contains_hidden_input(':sequencecheck', 4);
}
public function test_autosave_and_save_concurrently(): void {
// This test simulates the following scenario:
// 1. Student looking at a page of the quiz, and edits a field then waits.
// 2. Autosave starts.
// 3. Student immediately clicks Next, which submits the current page.
// In this situation, the real submit should beat the autosave, even
// thought they happen concurrently. We simulate this by opening a
// second db connections.
global $DB;
// Open second connection
$cfg = $DB->export_dbconfig();
if (!isset($cfg->dboptions)) {
$cfg->dboptions = array();
}
$DB2 = \moodle_database::get_driver_instance($cfg->dbtype, $cfg->dblibrary);
$DB2->connect($cfg->dbhost, $cfg->dbuser, $cfg->dbpass, $cfg->dbname, $cfg->prefix, $cfg->dboptions);
// Since we need to commit our transactions in a given order, close the
// standard unit test transaction.
$this->preventResetByRollback();
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->save_quba();
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Start to process an autosave on $DB.
$transaction = $DB->start_delegated_transaction();
$this->load_quba($DB);
$this->process_autosave(array('answer' => 'autosaved response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba($DB); // Don't commit the transaction yet.
// Now process a real submit on $DB2 (using a different response).
$transaction2 = $DB2->start_delegated_transaction();
$this->load_quba($DB2);
$this->process_submission(array('answer' => 'real response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
// Now commit the first transaction.
$transaction->allow_commit();
// Now commit the other transaction.
$this->save_quba($DB2);
$transaction2->allow_commit();
// Now re-load and check how that is re-displayed.
$this->load_quba();
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->render();
$this->check_output_contains_text_input('answer', 'real response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$DB2->dispose();
}
public function test_concurrent_autosaves(): void {
// This test simulates the following scenario:
// 1. Student opens a page of the quiz in two separate browser.
// 2. Autosave starts in both at the same time.
// In this situation, one autosave will work, and the other one will
// get a unique key violation error. This is OK.
global $DB;
// Open second connection
$cfg = $DB->export_dbconfig();
if (!isset($cfg->dboptions)) {
$cfg->dboptions = array();
}
$DB2 = \moodle_database::get_driver_instance($cfg->dbtype, $cfg->dblibrary);
$DB2->connect($cfg->dbhost, $cfg->dbuser, $cfg->dbpass, $cfg->dbname, $cfg->prefix, $cfg->dboptions);
// Since we need to commit our transactions in a given order, close the
// standard unit test transaction.
$this->preventResetByRollback();
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->save_quba();
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Start to process an autosave on $DB.
$transaction = $DB->start_delegated_transaction();
$this->load_quba($DB);
$this->process_autosave(array('answer' => 'autosaved response 1'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba($DB); // Don't commit the transaction yet.
// Now process a real submit on $DB2 (using a different response).
$transaction2 = $DB2->start_delegated_transaction();
$this->load_quba($DB2);
$this->process_autosave(array('answer' => 'autosaved response 2'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
// Now commit the first transaction.
$transaction->allow_commit();
// Now commit the other transaction.
$this->expectException('dml_write_exception');
$this->save_quba($DB2);
$transaction2->allow_commit();
// Now re-load and check how that is re-displayed.
$this->load_quba();
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->render();
$this->check_output_contains_text_input('answer', 'autosaved response 1');
$this->check_output_contains_hidden_input(':sequencecheck', 1);
$DB2->dispose();
}
public function test_autosave_with_wrong_seq_number_ignored(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Process a response and check the expected result.
$this->process_submission(array('answer' => 'first response'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process an autosave with a sequence number 1 too small (so from the past).
$this->load_quba();
$postdata = $this->response_data_to_post(array('answer' => 'obsolete response'));
$postdata[$this->quba->get_field_prefix($this->slot) . ':sequencecheck'] = $this->get_question_attempt()->get_sequence_check_count() - 1;
$this->quba->process_all_autosaves(null, $postdata);
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$this->delete_quba();
}
public function test_finish_with_unhandled_autosave_data(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// Process a response and check the expected result.
$this->process_submission(array('answer' => 'cat'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(2);
$this->save_quba();
// Now check how that is re-displayed.
$this->render();
$this->check_output_contains_text_input('answer', 'cat');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Process an autosave.
$this->load_quba();
$this->process_autosave(array('answer' => 'frog'));
$this->check_current_state(question_state::$complete);
$this->check_current_mark(null);
$this->check_step_count(3);
$this->save_quba();
// Now check how that is re-displayed.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'frog');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
// Now finishe the attempt, without having done anything since the autosave.
$this->finish();
$this->save_quba();
// Now check how that has been graded and is re-displayed.
$this->load_quba();
$this->check_current_state(question_state::$gradedright);
$this->check_current_mark(1);
$this->render();
$this->check_output_contains_text_input('answer', 'frog', false);
$this->check_output_contains_hidden_input(':sequencecheck', 4);
$this->delete_quba();
}
/**
* Test that regrading doesn't convert autosave steps to finished steps.
* This can result in students loosing data (due to question_out_of_sequence_exception) if a teacher
* regrades an attempt while it is in progress.
*/
public function test_autosave_and_regrade_then_display(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$question = $generator->create_question('shortanswer', null,
array('category' => $cat->id));
// Start attempt at a shortanswer question.
$q = question_bank::load_question($question->id);
$this->start_attempt_at_question($q, 'deferredfeedback', 1);
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
// First see if the starting sequence is right.
$this->render();
$this->check_output_contains_hidden_input(':sequencecheck', 1);
// Add a submission.
$this->process_submission(array('answer' => 'first response'));
$this->save_quba();
// Check the submission and that the sequence went up.
$this->render();
$this->check_output_contains_text_input('answer', 'first response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$this->assertFalse($this->get_question_attempt()->has_autosaved_step());
// Add a autosave response.
$this->load_quba();
$this->process_autosave(array('answer' => 'second response'));
$this->save_quba();
// Confirm that the autosave value shows up, but that the sequence hasn't increased.
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$this->assertTrue($this->get_question_attempt()->has_autosaved_step());
// Call regrade.
$this->load_quba();
$this->quba->regrade_all_questions();
$this->save_quba();
// Check and see if the autosave response is still there, that the sequence didn't increase,
// and that there is an autosave step.
$this->load_quba();
$this->render();
$this->check_output_contains_text_input('answer', 'second response');
$this->check_output_contains_hidden_input(':sequencecheck', 2);
$this->assertTrue($this->get_question_attempt()->has_autosaved_step());
$this->delete_quba();
}
protected function tearDown(): void {
// This test relies on the destructor for the second DB connection being called before running the next test.
// Without this change - there will be unit test failures on "some" DBs (MySQL).
gc_collect_cycles();
}
}
@@ -0,0 +1,151 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_bank;
use question_state;
use question_test_recordset;
use question_usage_by_activity;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for loading data into the {@link question_usage_by_activity} class.
*
* @package core_question
* @copyright 2012 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionusagebyactivity_data_test extends \data_loading_method_test_base {
public function test_load(): void {
$scid = \context_system::instance()->id;
$records = new question_test_recordset(array(
array('qubaid', 'contextid', 'component', 'preferredbehaviour',
'questionattemptid', 'questionusageid', 'slot',
'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged',
'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
'attemptstepid', 'sequencenumber', 'state', 'fraction',
'timecreated', 'userid', 'name', 'value'),
array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null),
array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'todo', null, 1256233705, 1, 'answer', '1'),
array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', -1, 1, 2.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 2, 'gradedright', 1.0000000, 1256233720, 1, '-finish', '1'),
));
$question = \test_question_maker::make_question('truefalse', 'true');
$question->id = -1;
question_bank::start_unit_test();
question_bank::load_test_question_data($question);
$quba = question_usage_by_activity::load_from_records($records, 1);
question_bank::end_unit_test();
$this->assertEquals('unit_test', $quba->get_owning_component());
$this->assertEquals(1, $quba->get_id());
$this->assertInstanceOf('question_engine_unit_of_work', $quba->get_observer());
$this->assertEquals('interactive', $quba->get_preferred_behaviour());
$qa = $quba->get_question_attempt(1);
$this->assertEquals($question->questiontext, $qa->get_question(false)->questiontext);
$this->assertEquals(3, $qa->get_num_steps());
$step = $qa->get_step(0);
$this->assertEquals(question_state::$todo, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233700, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array(), $step->get_all_data());
$step = $qa->get_step(1);
$this->assertEquals(question_state::$todo, $step->get_state());
$this->assertNull($step->get_fraction());
$this->assertEquals(1256233705, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('answer' => '1'), $step->get_all_data());
$step = $qa->get_step(2);
$this->assertEquals(question_state::$gradedright, $step->get_state());
$this->assertEquals(1, $step->get_fraction());
$this->assertEquals(1256233720, $step->get_timecreated());
$this->assertEquals(1, $step->get_user_id());
$this->assertEquals(array('-finish' => '1'), $step->get_all_data());
}
public function test_load_data_no_steps(): void {
// The code had a bug where if one question_attempt had no steps,
// load_from_records got stuck in an infinite loop. This test is to
// verify that no longer happens.
$scid = \context_system::instance()->id;
$records = new question_test_recordset(array(
array('qubaid', 'contextid', 'component', 'preferredbehaviour',
'questionattemptid', 'questionusageid', 'slot',
'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged',
'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
'attemptstepid', 'sequencenumber', 'state', 'fraction',
'timecreated', 'userid', 'name', 'value'),
array(1, $scid, 'unit_test', 'interactive', 1, 1, 1, 'interactive', 0, 1, 1.0000000, 0.0000000, 1.0000000, 0, 'This question is missing. Unable to display anything.', '', '', 0, null, null, null, null, null, null, null, null),
array(1, $scid, 'unit_test', 'interactive', 2, 1, 2, 'interactive', 0, 1, 1.0000000, 0.0000000, 1.0000000, 0, 'This question is missing. Unable to display anything.', '', '', 0, null, null, null, null, null, null, null, null),
array(1, $scid, 'unit_test', 'interactive', 3, 1, 3, 'interactive', 0, 1, 1.0000000, 0.0000000, 1.0000000, 0, 'This question is missing. Unable to display anything.', '', '', 0, null, null, null, null, null, null, null, null),
));
question_bank::start_unit_test();
$quba = question_usage_by_activity::load_from_records($records, 1);
question_bank::end_unit_test();
$this->assertEquals('unit_test', $quba->get_owning_component());
$this->assertEquals(1, $quba->get_id());
$this->assertInstanceOf('question_engine_unit_of_work', $quba->get_observer());
$this->assertEquals('interactive', $quba->get_preferred_behaviour());
$this->assertEquals(array(1, 2, 3), $quba->get_slots());
$qa = $quba->get_question_attempt(1);
$this->assertEquals(0, $qa->get_num_steps());
}
public function test_load_data_no_qas(): void {
// The code had a bug where if a question_usage had no question_attempts,
// load_from_records got stuck in an infinite loop. This test is to
// verify that no longer happens.
$scid = \context_system::instance()->id;
$records = new question_test_recordset(array(
array('qubaid', 'contextid', 'component', 'preferredbehaviour',
'questionattemptid', 'questionusageid', 'slot',
'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged',
'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
'attemptstepid', 'sequencenumber', 'state', 'fraction',
'timecreated', 'userid', 'name', 'value'),
array(1, $scid, 'unit_test', 'interactive', null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null),
));
question_bank::start_unit_test();
$quba = question_usage_by_activity::load_from_records($records, 1);
question_bank::end_unit_test();
$this->assertEquals('unit_test', $quba->get_owning_component());
$this->assertEquals(1, $quba->get_id());
$this->assertInstanceOf('question_engine_unit_of_work', $quba->get_observer());
$this->assertEquals('interactive', $quba->get_preferred_behaviour());
$this->assertEquals(array(), $quba->get_slots());
}
}
@@ -0,0 +1,190 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_bank;
use question_engine;
use question_state;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for the question_usage_by_activity class.
*
* @package core_question
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionusagebyactivity_test extends \advanced_testcase {
public function test_set_get_preferred_model(): void {
// Set up
$quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
// Exercise SUT and verify.
$quba->set_preferred_behaviour('deferredfeedback');
$this->assertEquals('deferredfeedback', $quba->get_preferred_behaviour());
}
public function test_set_get_id(): void {
// Set up
$quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
// Exercise SUT and verify
$quba->set_id_from_database(123);
$this->assertEquals(123, $quba->get_id());
}
public function test_fake_id(): void {
// Set up
$quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
// Exercise SUT and verify
$this->assertNotEmpty($quba->get_id());
}
public function test_create_usage_and_add_question(): void {
// Exercise SUT
$context = \context_system::instance();
$quba = question_engine::make_questions_usage_by_activity('unit_test', $context);
$quba->set_preferred_behaviour('deferredfeedback');
$tf = \test_question_maker::make_question('truefalse', 'true');
$slot = $quba->add_question($tf);
// Verify.
$this->assertEquals($slot, 1);
$this->assertEquals('unit_test', $quba->get_owning_component());
$this->assertSame($context, $quba->get_owning_context());
$this->assertEquals($quba->question_count(), 1);
$this->assertEquals($quba->get_question_state($slot), question_state::$notstarted);
}
public function test_get_question(): void {
// Set up.
$quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
$quba->set_preferred_behaviour('deferredfeedback');
$tf = \test_question_maker::make_question('truefalse', 'true');
$slot = $quba->add_question($tf);
// Exercise SUT and verify.
$this->assertSame($tf, $quba->get_question($slot, false));
$this->expectException('moodle_exception');
$quba->get_question($slot + 1, false);
}
public function test_extract_responses(): void {
// Start a deferred feedback attempt with CBM and add the question to it.
$tf = \test_question_maker::make_question('truefalse', 'true');
$quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
$quba->set_preferred_behaviour('deferredcbm');
$slot = $quba->add_question($tf);
$quba->start_all_questions();
// Prepare data to be submitted
$prefix = $quba->get_field_prefix($slot);
$answername = $prefix . 'answer';
$certaintyname = $prefix . '-certainty';
$getdata = array(
$answername => 1,
$certaintyname => 3,
'irrelevant' => 'should be ignored',
);
// Exercise SUT
$submitteddata = $quba->extract_responses($slot, $getdata);
// Verify.
$this->assertEquals(array('answer' => 1, '-certainty' => 3), $submitteddata);
}
public function test_access_out_of_sequence_throws_exception(): void {
// Start a deferred feedback attempt with CBM and add the question to it.
$tf = \test_question_maker::make_question('truefalse', 'true');
$quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
$quba->set_preferred_behaviour('deferredcbm');
$slot = $quba->add_question($tf);
$quba->start_all_questions();
// Prepare data to be submitted
$prefix = $quba->get_field_prefix($slot);
$answername = $prefix . 'answer';
$certaintyname = $prefix . '-certainty';
$postdata = array(
$answername => 1,
$certaintyname => 3,
$prefix . ':sequencecheck' => 1,
'irrelevant' => 'should be ignored',
);
// Exercise SUT - no exception yet.
$quba->process_all_actions($slot, $postdata);
$postdata = array(
$answername => 1,
$certaintyname => 3,
$prefix . ':sequencecheck' => 3,
'irrelevant' => 'should be ignored',
);
// Exercise SUT - now it should fail.
$this->expectException('question_out_of_sequence_exception');
$quba->process_all_actions($slot, $postdata);
}
/**
* Test function preload all step users.
*/
public function test_preload_all_step_users(): void {
$this->resetAfterTest();
$this->setAdminUser();
// Set up.
$quba = question_engine::make_questions_usage_by_activity('unit_test',
\context_system::instance());
// Create an essay question in the DB.
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $generator->create_question_category();
$essay = $generator->create_question('essay', 'editorfilepicker', ['category' => $cat->id]);
// Start attempt at the question.
$q = question_bank::load_question($essay->id);
$quba->set_preferred_behaviour('deferredfeedback');
$slot = $quba->add_question($q, 10);
$quba->start_question($slot, 1);
// Finish the attempt.
$quba->finish_all_questions();
question_engine::save_questions_usage_by_activity($quba);
// The user information of question attempt step should be loaded.
$quba->preload_all_step_users();
$qa = $quba->get_attempt_iterator()->current();
$steps = $qa->get_full_step_iterator();
$this->assertEquals('Admin User', $steps[0]->get_user_fullname());
}
}
@@ -0,0 +1,229 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_utils;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
/**
* Unit tests for the {@link question_utils} class.
*
* @package core_question
* @category test
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class questionutils_test extends \advanced_testcase {
public function test_arrays_have_same_keys_and_values(): void {
$this->assertTrue(question_utils::arrays_have_same_keys_and_values(
array(),
array()));
$this->assertTrue(question_utils::arrays_have_same_keys_and_values(
array('key' => 1),
array('key' => '1')));
$this->assertFalse(question_utils::arrays_have_same_keys_and_values(
array(),
array('key' => 1)));
$this->assertFalse(question_utils::arrays_have_same_keys_and_values(
array('key' => 2),
array('key' => 1)));
$this->assertFalse(question_utils::arrays_have_same_keys_and_values(
array('key' => 1),
array('otherkey' => 1)));
$this->assertFalse(question_utils::arrays_have_same_keys_and_values(
array('sub0' => '2', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'),
array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1')));
}
public function test_arrays_same_at_key(): void {
$this->assertTrue(question_utils::arrays_same_at_key(
array(),
array(),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key(
array(),
array('key' => 1),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key(
array('key' => 1),
array(),
'key'));
$this->assertTrue(question_utils::arrays_same_at_key(
array('key' => 1),
array('key' => 1),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key(
array('key' => 1),
array('key' => 2),
'key'));
$this->assertTrue(question_utils::arrays_same_at_key(
array('key' => 1),
array('key' => '1'),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key(
array('key' => 0),
array('key' => ''),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key(
array(),
array('key' => ''),
'key'));
}
public function test_arrays_same_at_key_missing_is_blank(): void {
$this->assertTrue(question_utils::arrays_same_at_key_missing_is_blank(
array(),
array(),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key_missing_is_blank(
array(),
array('key' => 1),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key_missing_is_blank(
array('key' => 1),
array(),
'key'));
$this->assertTrue(question_utils::arrays_same_at_key_missing_is_blank(
array('key' => 1),
array('key' => 1),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key_missing_is_blank(
array('key' => 1),
array('key' => 2),
'key'));
$this->assertTrue(question_utils::arrays_same_at_key_missing_is_blank(
array('key' => 1),
array('key' => '1'),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key_missing_is_blank(
array('key' => '0'),
array('key' => ''),
'key'));
$this->assertTrue(question_utils::arrays_same_at_key_missing_is_blank(
array(),
array('key' => ''),
'key'));
}
public function test_arrays_same_at_key_integer(): void {
$this->assertTrue(question_utils::arrays_same_at_key_integer(
array(),
array(),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key_integer(
array(),
array('key' => 1),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key_integer(
array('key' => 1),
array(),
'key'));
$this->assertTrue(question_utils::arrays_same_at_key_integer(
array('key' => 1),
array('key' => 1),
'key'));
$this->assertFalse(question_utils::arrays_same_at_key_integer(
array('key' => 1),
array('key' => 2),
'key'));
$this->assertTrue(question_utils::arrays_same_at_key_integer(
array('key' => 1),
array('key' => '1'),
'key'));
$this->assertTrue(question_utils::arrays_same_at_key_integer(
array('key' => '0'),
array('key' => ''),
'key'));
$this->assertTrue(question_utils::arrays_same_at_key_integer(
array(),
array('key' => 0),
'key'));
}
public function test_int_to_roman(): void {
$this->assertSame('i', question_utils::int_to_roman(1));
$this->assertSame('iv', question_utils::int_to_roman(4));
$this->assertSame('v', question_utils::int_to_roman(5));
$this->assertSame('vi', question_utils::int_to_roman(6));
$this->assertSame('ix', question_utils::int_to_roman(9));
$this->assertSame('xi', question_utils::int_to_roman(11));
$this->assertSame('xlviii', question_utils::int_to_roman(48));
$this->assertSame('lxxxvii', question_utils::int_to_roman(87));
$this->assertSame('c', question_utils::int_to_roman(100));
$this->assertSame('mccxxxiv', question_utils::int_to_roman(1234));
$this->assertSame('mmmcmxcix', question_utils::int_to_roman(3999));
}
public function test_int_to_letter(): void {
$this->assertEquals('A', question_utils::int_to_letter(1));
$this->assertEquals('B', question_utils::int_to_letter(2));
$this->assertEquals('C', question_utils::int_to_letter(3));
$this->assertEquals('D', question_utils::int_to_letter(4));
$this->assertEquals('E', question_utils::int_to_letter(5));
$this->assertEquals('F', question_utils::int_to_letter(6));
$this->assertEquals('G', question_utils::int_to_letter(7));
$this->assertEquals('H', question_utils::int_to_letter(8));
$this->assertEquals('I', question_utils::int_to_letter(9));
$this->assertEquals('J', question_utils::int_to_letter(10));
$this->assertEquals('K', question_utils::int_to_letter(11));
$this->assertEquals('L', question_utils::int_to_letter(12));
$this->assertEquals('M', question_utils::int_to_letter(13));
$this->assertEquals('N', question_utils::int_to_letter(14));
$this->assertEquals('O', question_utils::int_to_letter(15));
$this->assertEquals('P', question_utils::int_to_letter(16));
$this->assertEquals('Q', question_utils::int_to_letter(17));
$this->assertEquals('R', question_utils::int_to_letter(18));
$this->assertEquals('S', question_utils::int_to_letter(19));
$this->assertEquals('T', question_utils::int_to_letter(20));
$this->assertEquals('U', question_utils::int_to_letter(21));
$this->assertEquals('V', question_utils::int_to_letter(22));
$this->assertEquals('W', question_utils::int_to_letter(23));
$this->assertEquals('X', question_utils::int_to_letter(24));
$this->assertEquals('Y', question_utils::int_to_letter(25));
$this->assertEquals('Z', question_utils::int_to_letter(26));
}
public function test_int_to_roman_too_small(): void {
$this->expectException(\moodle_exception::class);
question_utils::int_to_roman(0);
}
public function test_int_to_roman_too_big(): void {
$this->expectException(\moodle_exception::class);
question_utils::int_to_roman(4000);
}
public function test_int_to_roman_not_int(): void {
$this->expectException(\moodle_exception::class);
question_utils::int_to_roman(1.5);
}
public function test_clean_param_mark(): void {
$this->assertNull(question_utils::clean_param_mark(null));
$this->assertNull(question_utils::clean_param_mark('frog'));
$this->assertSame('', question_utils::clean_param_mark(''));
$this->assertSame(0.0, question_utils::clean_param_mark('0'));
$this->assertSame(1.5, question_utils::clean_param_mark('1.5'));
$this->assertSame(1.5, question_utils::clean_param_mark('1,5'));
$this->assertSame(-1.5, question_utils::clean_param_mark('-1.5'));
$this->assertSame(-1.5, question_utils::clean_param_mark('-1,5'));
}
}
+531
View File
@@ -0,0 +1,531 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_bank;
use question_hint;
use question_test_recordset;
use question_usage_by_activity;
use testable_question_engine_unit_of_work;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* Unit tests for the {@link question_engine_unit_of_work} class.
*
* @package core_question
* @category test
* @copyright 2012 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class unitofwork_test extends \data_loading_method_test_base {
/** @var question_usage_by_activity the test question usage. */
protected $quba;
/** @var int the slot number of the one qa in the test usage.*/
protected $slot;
/** @var testable_question_engine_unit_of_work the unit of work we are testing. */
protected $observer;
protected function setUp(): void {
// Create a usage in an initial state, with one shortanswer question added,
// and attempted in interactive mode submitted responses 'toad' then 'frog'.
// Then set it to use a new unit of work for any subsequent changes.
// Create a short answer question.
$question = \test_question_maker::make_question('shortanswer');
$question->hints = array(
new question_hint(0, 'This is the first hint.', FORMAT_HTML),
new question_hint(0, 'This is the second hint.', FORMAT_HTML),
);
$question->id = -1;
question_bank::start_unit_test();
question_bank::load_test_question_data($question);
$this->setup_initial_test_state($this->get_test_data());
}
public function tearDown(): void {
question_bank::end_unit_test();
}
protected function setup_initial_test_state($testdata) {
$records = new question_test_recordset($testdata);
$this->quba = question_usage_by_activity::load_from_records($records, 1);
$this->slot = 1;
$this->observer = new testable_question_engine_unit_of_work($this->quba);
$this->quba->set_observer($this->observer);
}
protected function get_test_data() {
return array(
array('qubaid', 'contextid', 'component', 'preferredbehaviour',
'questionattemptid', 'contextid', 'questionusageid', 'slot',
'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', 'maxfraction', 'flagged',
'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
'attemptstepid', 'sequencenumber', 'state', 'fraction',
'timecreated', 'userid', 'name', 'value'),
array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, '-_triesleft', 3),
array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'todo', null, 1256233720, 1, 'answer', 'toad'),
array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'todo', null, 1256233720, 1, '-submit', 1),
array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 2, 1, 'todo', null, 1256233720, 1, '-_triesleft', 1),
array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 3, 2, 'todo', null, 1256233740, 1, '-tryagain', 1),
array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 3, 'gradedright', 0.6666667, 1256233790, 1, 'answer', 'frog'),
array(1, 1, 'unit_test', 'interactive', 1, 123, 1, 1, 'interactive', -1, 1, 1.0000000, 0.0000000, 1.0000000, 0, '', '', '', 1256233790, 5, 3, 'gradedright', 0.6666667, 1256233790, 1, '-submit', 1),
);
}
public function test_initial_state(): void {
$this->assertFalse($this->observer->get_modified());
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$this->assertEquals(0, count($this->observer->get_attempts_modified()));
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_update_usage(): void {
$this->quba->set_preferred_behaviour('deferredfeedback');
$this->assertTrue($this->observer->get_modified());
}
public function test_add_question(): void {
$slot = $this->quba->add_question(\test_question_maker::make_question('truefalse'));
$newattempts = $this->observer->get_attempts_added();
$this->assertEquals(1, count($newattempts));
$this->assertTrue($this->quba->get_question_attempt($slot) === reset($newattempts));
$this->assertSame($slot, key($newattempts));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_add_and_start_question(): void {
$slot = $this->quba->add_question(\test_question_maker::make_question('truefalse'));
$this->quba->start_question($slot);
// The point here is that, although we have added a step, it is not listed
// separately becuase it is part of a newly added attempt, and all steps
// for a newly added attempt are automatically added to the DB, so it does
// not need to be tracked separately.
$newattempts = $this->observer->get_attempts_added();
$this->assertEquals(1, count($newattempts));
$this->assertTrue($this->quba->get_question_attempt($slot) === reset($newattempts));
$this->assertSame($slot, key($newattempts));
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_process_action(): void {
$this->quba->manual_grade($this->slot, 'Actually, that is not quite right', 0.5, FORMAT_HTML);
// Here, however, were we are adding a step to an existing qa, we do need to track that.
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$updatedattempts = $this->observer->get_attempts_modified();
$this->assertEquals(1, count($updatedattempts));
$updatedattempt = reset($updatedattempts);
$this->assertTrue($this->quba->get_question_attempt($this->slot) === $updatedattempt);
$this->assertSame($this->slot, key($updatedattempts));
$newsteps = $this->observer->get_steps_added();
$this->assertEquals(1, count($newsteps));
list($newstep, $qaid, $seq) = reset($newsteps);
$this->assertSame($this->quba->get_question_attempt($this->slot)->get_last_step(), $newstep);
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_regrade_same_steps(): void {
// Change the question in a minor way and regrade.
$this->quba->get_question($this->slot, false)->answers[14]->fraction = 0.5;
$this->quba->regrade_all_questions();
// Here, the qa, and all the steps, should be marked as updated.
// Here, however, were we are adding a step to an existing qa, we do need to track that.
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$updatedattempts = $this->observer->get_attempts_modified();
$this->assertEquals(1, count($updatedattempts));
$updatedattempt = reset($updatedattempts);
$this->assertTrue($this->quba->get_question_attempt($this->slot) === $updatedattempt);
$updatedsteps = $this->observer->get_steps_modified();
$this->assertEquals($updatedattempt->get_num_steps(), count($updatedsteps));
foreach ($updatedattempt->get_step_iterator() as $seq => $step) {
$this->assertSame(array($step, $updatedattempt->get_database_id(), $seq),
$updatedsteps[$seq]);
}
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_regrade_losing_steps(): void {
// Change the question so that 'toad' is also right, and regrade. This
// will mean that the try again, and second try states are no longer
// needed, so they should be dropped.
$this->quba->get_question($this->slot, false)->answers[14]->fraction = 1;
$this->quba->regrade_all_questions();
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$this->assertEquals(0, count($this->observer->get_steps_added()));
$updatedattempts = $this->observer->get_attempts_modified();
$this->assertEquals(1, count($updatedattempts));
$updatedattempt = reset($updatedattempts);
$this->assertTrue($this->quba->get_question_attempt($this->slot) === $updatedattempt);
$updatedsteps = $this->observer->get_steps_modified();
$this->assertEquals($updatedattempt->get_num_steps(), count($updatedsteps));
foreach ($updatedattempt->get_step_iterator() as $seq => $step) {
$this->assertSame(array($step, $updatedattempt->get_database_id(), $seq),
$updatedsteps[$seq]);
}
$deletedsteps = $this->observer->get_steps_deleted();
$this->assertEquals(2, count($deletedsteps));
$firstdeletedstep = reset($deletedsteps);
$this->assertEquals(array('-tryagain' => 1), $firstdeletedstep->get_all_data());
$seconddeletedstep = end($deletedsteps);
$this->assertEquals(array('answer' => 'frog', '-submit' => 1),
$seconddeletedstep->get_all_data());
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_tricky_regrade(): void {
// The tricky thing here is that we take a half-complete question-attempt,
// and then as one transaction, we submit some more responses, and then
// change the question attempt as in test_regrade_losing_steps, and regrade
// before the steps are even written to the database the first time.
$somedata = $this->get_test_data();
$somedata = array_slice($somedata, 0, 5);
$this->setup_initial_test_state($somedata);
$this->quba->process_action($this->slot, array('-tryagain' => 1));
$this->quba->process_action($this->slot, array('answer' => 'frog', '-submit' => 1));
$this->quba->finish_all_questions();
$this->quba->get_question($this->slot, false)->answers[14]->fraction = 1;
$this->quba->regrade_all_questions();
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$updatedattempts = $this->observer->get_attempts_modified();
$this->assertEquals(1, count($updatedattempts));
$updatedattempt = reset($updatedattempts);
$this->assertTrue($this->quba->get_question_attempt($this->slot) === $updatedattempt);
$this->assertEquals(0, count($this->observer->get_steps_added()));
$updatedsteps = $this->observer->get_steps_modified();
$this->assertEquals($updatedattempt->get_num_steps(), count($updatedsteps));
foreach ($updatedattempt->get_step_iterator() as $seq => $step) {
$this->assertSame(array($step, $updatedattempt->get_database_id(), $seq),
$updatedsteps[$seq]);
}
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_move_question(): void {
$q = \test_question_maker::make_question('truefalse');
$newslot = $this->quba->add_question_in_place_of_other($this->slot, $q);
$this->quba->start_question($this->slot);
$addedattempts = $this->observer->get_attempts_added();
$this->assertEquals(1, count($addedattempts));
$addedattempt = reset($addedattempts);
$this->assertSame($this->quba->get_question_attempt($this->slot), $addedattempt);
$updatedattempts = $this->observer->get_attempts_modified();
$this->assertEquals(1, count($updatedattempts));
$updatedattempt = reset($updatedattempts);
$this->assertSame($this->quba->get_question_attempt($newslot), $updatedattempt);
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_move_question_then_modify(): void {
$q = \test_question_maker::make_question('truefalse');
$newslot = $this->quba->add_question_in_place_of_other($this->slot, $q);
$this->quba->start_question($this->slot);
$this->quba->process_action($this->slot, array('answer' => 'frog', '-submit' => 1));
$this->quba->manual_grade($newslot, 'Test', 0.5, FORMAT_HTML);
$addedattempts = $this->observer->get_attempts_added();
$this->assertEquals(1, count($addedattempts));
$addedattempt = reset($addedattempts);
$this->assertSame($this->quba->get_question_attempt($this->slot), $addedattempt);
$updatedattempts = $this->observer->get_attempts_modified();
$this->assertEquals(1, count($updatedattempts));
$updatedattempt = reset($updatedattempts);
$this->assertSame($this->quba->get_question_attempt($newslot), $updatedattempt);
$newsteps = $this->observer->get_steps_added();
$this->assertEquals(1, count($newsteps));
list($newstep, $qaid, $seq) = reset($newsteps);
$this->assertSame($this->quba->get_question_attempt($newslot)->get_last_step(), $newstep);
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_move_question_then_move_again(): void {
$originalqa = $this->quba->get_question_attempt($this->slot);
$q1 = \test_question_maker::make_question('truefalse');
$newslot = $this->quba->add_question_in_place_of_other($this->slot, $q1);
$this->quba->start_question($this->slot);
$q2 = \test_question_maker::make_question('truefalse');
$newslot2 = $this->quba->add_question_in_place_of_other($newslot, $q2);
$this->quba->start_question($newslot);
$addedattempts = $this->observer->get_attempts_added();
$this->assertEquals(2, count($addedattempts));
$updatedattempts = $this->observer->get_attempts_modified();
$this->assertEquals(1, count($updatedattempts));
$updatedattempt = reset($updatedattempts);
$this->assertSame($originalqa, $updatedattempt);
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_set_max_mark(): void {
$this->quba->set_max_mark($this->slot, 6.0);
$this->assertEqualsWithDelta(4.0, $this->quba->get_total_mark(), 0.0000005);
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$updatedattempts = $this->observer->get_attempts_modified();
$this->assertEquals(1, count($updatedattempts));
$updatedattempt = reset($updatedattempts);
$this->assertSame($this->quba->get_question_attempt($this->slot), $updatedattempt);
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_set_question_attempt_metadata(): void {
$this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
$this->assertEquals('a value', $this->quba->get_question_attempt_metadata($this->slot, 'metathingy'));
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$this->assertEquals(0, count($this->observer->get_attempts_modified()));
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(array($this->slot => array('metathingy' => $this->quba->get_question_attempt($this->slot))),
$this->observer->get_metadata_added());
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_set_question_attempt_metadata_then_change(): void {
$this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
$this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'different value');
$this->assertEquals('different value', $this->quba->get_question_attempt_metadata($this->slot, 'metathingy'));
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$this->assertEquals(0, count($this->observer->get_attempts_modified()));
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(array($this->slot => array('metathingy' => $this->quba->get_question_attempt($this->slot))),
$this->observer->get_metadata_added());
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_set_metadata_previously_set_but_dont_actually_change(): void {
$this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
$this->observer = new testable_question_engine_unit_of_work($this->quba);
$this->quba->set_observer($this->observer);
$this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
$this->assertEquals('a value', $this->quba->get_question_attempt_metadata($this->slot, 'metathingy'));
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$this->assertEquals(0, count($this->observer->get_attempts_modified()));
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_set_metadata_previously_set(): void {
$this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
$this->observer = new testable_question_engine_unit_of_work($this->quba);
$this->quba->set_observer($this->observer);
$this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'different value');
$this->assertEquals('different value', $this->quba->get_question_attempt_metadata($this->slot, 'metathingy'));
$this->assertEquals(0, count($this->observer->get_attempts_added()));
$this->assertEquals(0, count($this->observer->get_attempts_modified()));
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(array($this->slot => array('metathingy' => $this->quba->get_question_attempt($this->slot))),
$this->observer->get_metadata_modified());
}
public function test_set_metadata_in_new_question(): void {
$newslot = $this->quba->add_question(\test_question_maker::make_question('truefalse'));
$this->quba->start_question($newslot);
$this->quba->set_question_attempt_metadata($newslot, 'metathingy', 'a value');
$this->assertEquals('a value', $this->quba->get_question_attempt_metadata($newslot, 'metathingy'));
$this->assertEquals(array($newslot => $this->quba->get_question_attempt($newslot)),
$this->observer->get_attempts_added());
$this->assertEquals(0, count($this->observer->get_attempts_modified()));
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(0, count($this->observer->get_metadata_added()));
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_set_metadata_then_move(): void {
$this->quba->set_question_attempt_metadata($this->slot, 'metathingy', 'a value');
$q = \test_question_maker::make_question('truefalse');
$newslot = $this->quba->add_question_in_place_of_other($this->slot, $q);
$this->quba->start_question($this->slot);
$this->assertEquals('a value', $this->quba->get_question_attempt_metadata($newslot, 'metathingy'));
$this->assertEquals(array($this->slot => $this->quba->get_question_attempt($this->slot)),
$this->observer->get_attempts_added());
$this->assertEquals(array($newslot => $this->quba->get_question_attempt($newslot)),
$this->observer->get_attempts_modified());
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(array($newslot => array('metathingy' => $this->quba->get_question_attempt($newslot))),
$this->observer->get_metadata_added());
$this->assertEquals(0, count($this->observer->get_metadata_modified()));
}
public function test_move_then_set_metadata(): void {
$q = \test_question_maker::make_question('truefalse');
$newslot = $this->quba->add_question_in_place_of_other($this->slot, $q);
$this->quba->start_question($this->slot);
$this->quba->set_question_attempt_metadata($newslot, 'metathingy', 'a value');
$this->assertEquals('a value', $this->quba->get_question_attempt_metadata($newslot, 'metathingy'));
$this->assertEquals(array($this->slot => $this->quba->get_question_attempt($this->slot)),
$this->observer->get_attempts_added());
$this->assertEquals(array($newslot => $this->quba->get_question_attempt($newslot)),
$this->observer->get_attempts_modified());
$this->assertEquals(0, count($this->observer->get_steps_added()));
$this->assertEquals(0, count($this->observer->get_steps_modified()));
$this->assertEquals(0, count($this->observer->get_steps_deleted()));
$this->assertEquals(array($newslot => array('metathingy' => $this->quba->get_question_attempt($newslot))),
$this->observer->get_metadata_added());
}
/**
* Test add_question_in_place_of_other function.
*
* @covers ::add_question_in_place_of_other
*/
public function test_replace_old_attempt(): void {
// Create a new question.
$q = \test_question_maker::make_question('truefalse');
$currentquestion = $this->quba->get_question_attempt($this->slot)->get_question();
// Replace the current question in the slot with a new one.
$slot = $this->quba->add_question_in_place_of_other($this->slot, $q, null, false);
$newquestion = $this->quba->get_question_attempt($slot)->get_question();
$this->assertEquals($this->slot, $slot);
$this->assertEquals($q->name, $newquestion->name);
$this->assertCount(4, $this->observer->get_steps_deleted());
$this->assertCount(1, $this->observer->get_attempts_modified());
$this->assertCount(0, $this->observer->get_attempts_added());
$this->assertNotEquals($currentquestion->id, $newquestion->id);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace core_question;
use question_bank;
use question_display_options;
use question_state;
use test_question_maker;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/../lib.php');
require_once(__DIR__ . '/helpers.php');
/**
* End-to-end tests of attempting a question.
*
* @package core_question
* @copyright 2017 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class walkthrough_test extends \qbehaviour_walkthrough_test_base {
public function test_regrade_does_not_lose_flag(): void {
// Create a true-false question with correct answer true.
$tf = test_question_maker::make_question('truefalse', 'true');
$this->start_attempt_at_question($tf, 'deferredfeedback', 2);
// Process a true answer.
$this->process_submission(array('answer' => 1));
// Finish the attempt.
$this->quba->finish_all_questions();
// Flag the question.
$this->get_question_attempt()->set_flagged(true);
// Now change the correct answer to the question, and regrade.
$tf->rightanswer = false;
$this->quba->regrade_all_questions();
// Verify the flag has not been lost.
$this->assertTrue($this->get_question_attempt()->is_flagged());
}
/**
* Test action_author function.
*/
public function test_action_author_with_display_options_testcase(): void {
$this->resetAfterTest(true);
$generator = $this->getDataGenerator()->get_plugin_generator('core_question');
$teacher = $this->getDataGenerator()->create_user();
$student = $this->getDataGenerator()->create_user();
// Create an essay question in the DB.
$cat = $generator->create_question_category();
$essay = $generator->create_question('essay', 'editorfilepicker', ['category' => $cat->id]);
// Start attempt at the question.
$q = question_bank::load_question($essay->id);
// Student attempt the question.
$this->setUser($student);
$this->start_attempt_at_question($q, 'deferredfeedback', 10, 1);
// Simulate some data submitted by the student.
$this->process_submission(['answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML]);
$this->finish();
// Process a manual comment.
$this->setUser($teacher);
$this->manual_grade('Not good enough!', 10, FORMAT_HTML);
$this->render();
$this->save_quba();
// Set display option userinfoinhistory to HIDDEN.
$displayoptions = new question_display_options();
$displayoptions->history = question_display_options::VISIBLE;
$displayoptions->userinfoinhistory = question_display_options::HIDDEN;
$this->load_quba();
$result = $this->quba->render_question($this->slot, $displayoptions);
// The profile user link should not display.
preg_match("/<a ?.*>(.*)<\/a>/", $result, $matches);
$this->assertEquals(false, isset($matches[0]));
// Set display option userinfoinhistory to SHOW_ALL.
$displayoptions = new question_display_options();
$displayoptions->history = question_display_options::VISIBLE;
$displayoptions->userinfoinhistory = question_display_options::SHOW_ALL;
$this->load_quba();
$this->quba->preload_all_step_users();
$result = $this->quba->render_question($this->slot, $displayoptions);
$numsteps = $this->quba->get_question_attempt($this->slot)->get_num_steps();
// All steps in the result should contain user profile link.
preg_match_all("/<a ?.*>(.*)<\/a>/", $result, $matches);
$this->assertEquals($numsteps, count($matches[0]));
// Set the userinfoinhistory to student id.
$displayoptions = new question_display_options();
$displayoptions->history = question_display_options::VISIBLE;
$displayoptions->userinfoinhistory = $student->id;
$this->load_quba();
$result = $this->quba->render_question($this->slot, $displayoptions);
$message = 'Attempt to access the step user before it was initialised.';
$message .= ' Did you forget to call question_usage_by_activity::preload_all_step_users() or similar?';
$this->assertDebuggingCalled($message, DEBUG_DEVELOPER);
$this->resetDebugging();
$this->quba->preload_all_step_users();
$result = $this->quba->render_question($this->slot, $displayoptions);
$this->assertDebuggingNotCalled();
// The step just show the user profile link if the step's userid is different with student id.
preg_match_all("/<a ?.*>(.*)<\/a>/", $result, $matches);
$this->assertEquals(1, count($matches[0]));
}
/**
* @covers \question_usage_by_activity::regrade_question
* @covers \question_attempt::regrade
* @covers \question_attempt::get_attempt_state_data_to_regrade_with_version
*/
public function test_regrading_an_interactive_attempt_while_in_progress(): void {
// Start an attempt at a matching question.
$q = test_question_maker::make_question('match');
$this->start_attempt_at_question($q, 'interactive', 1);
$this->save_quba();
// Verify.
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
$this->check_current_output($this->get_tries_remaining_expectation(1));
// Regrade the attempt.
// Duplicating the question here essential to triggering the bug we are trying to reproduce.
$reloadedquestion = clone($q);
$this->quba->regrade_question($this->slot, false, null, $reloadedquestion);
// Verify.
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
$this->check_current_output($this->get_tries_remaining_expectation(1));
}
/**
* @covers \question_usage_by_activity::regrade_question
* @covers \question_attempt::regrade
* @covers \question_attempt::get_attempt_state_data_to_regrade_with_version
*/
public function test_regrading_does_not_lose_metadata(): void {
// Start an attempt at a matching question.
$q = test_question_maker::make_question('match');
$this->start_attempt_at_question($q, 'interactive', 1);
// Like in process_redo_question in mod_quiz.
$this->quba->set_question_attempt_metadata($this->slot, 'originalslot', 42);
$this->save_quba();
// Verify.
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
$this->check_current_output($this->get_tries_remaining_expectation(1));
// Regrade the attempt.
$reloadedquestion = clone($q);
$this->quba->regrade_question($this->slot, false, null, $reloadedquestion);
// Verify.
$this->check_current_state(question_state::$todo);
$this->check_current_mark(null);
$this->check_step_count(1);
$this->check_current_output($this->get_tries_remaining_expectation(1));
$this->assertEquals(42, $this->quba->get_question_attempt_metadata($this->slot, 'originalslot'));
}
}
+128
View File
@@ -0,0 +1,128 @@
This files describes API changes for the core question engine.
=== 4.4 ===
* The method question_usage_by_activity::add_question_in_place_of_other has been made more flexible.
There is a new argument $keepoldquestionattempt. That defaults to true, which behaves the same as
the old API, but if you pass false, then the newly added question_attempt completely replaces the
existing one in-place.
=== 4.2 ===
* A `$questionidentifier` property has been added to `\question_display_options` to enable question type plugins to associate the
question number to the question that is being rendered. This can be used to improve the accessibility of rendered
questions and can be especially be helpful for screen reader users as adding the question number on the answer field(s) labels
will allow them to distinguish between answer fields as they navigate through a quiz.
* Question type plugins can use \question_display_options::add_question_identifier_to_label() to add the question number to the
label of the answer field(s) of the question that is being rendered. The question number may be redundant when displayed, so the
function allows for it to be enclosed within an sr-only container.
=== 4.0 ===
1) A new optional parameter $extraselect has been added as a part of load_questions_usages_where_question_in_state()
method in question/engine/datalib.php, anything passed here will be added to the SELECT list, use this to return extra data.
=== 3.9 ===
1) In the past, whenever a question_usage_by_activity was loaded from the database,
the apply_attempt_state was immediately called on every question, whether the
results of doing that were ever used, or not.
Now we have changed the code flow, so that apply_attempt_state is only called
when some data or processing is requested (e.g. analysing a response or rendering
the question) which requires the question to be fully initialised. This is MDL-67183.
This change should be completely invisible with everything handled by the question
engine. If you don't change your code, it should continue to work.
However, to get the full advantage of this change, you should review your code,
and look at every call to get_question or get_behaviour (on a question_attempt or
question_usage_by_activity). The problem with these methods is that the question engine
cannot know what you are planning to do with the question once you have got it.
Therefore, they have to assume that apply_attempt_state must be called - which can be expensive.
If you know that you don't need that (because, for example, you are just going to
look at ->id or ->questiontext or something simple) then you should pass
false to these functions, to get the possible performance benefit.
In addition, there is a new method $qa->get_question_id() to handle that case more simply.
Note that you don't have worry about this in places like the renderer for your question
type, because by the time you are in the renderer, the question will already have been
initialised.
=== 3.7 ===
1) When a question is rendered, the outer div of the question has an id="q123"
added. Unfortunately, this id was not actually unique, leading to bugs like
MDL-52572. Therefore, we have had to change it. The id used now is what
is returned by the new method $qa->get_outer_question_div_unique_id().
The old code that you need to search for and replace with a call to this
method is "'q' . $qa->get_slot()"
Note, the new method has also been added to Moodle 3.5.6 and 3.6.4, but
returning the old id. This is to help question types that want to support
multiple Moodle versions.
=== 3.1, 3.0.3, 2.9.5 ===
1) The field question_display_options::$extrainfocontent is now displayed in the
outcomes (yellow) div by default. It used to be in the info div. If you have
overriden the question renderer, you may need to make a corresponding change.
=== 3.0, 2.9.2, 2.8.8 ===
1) The extra internal PARAM constant question_attempt::PARAM_MARK should no
longer be used. (It should not have been used outside the core of the
question system). See MDL-51090 if you want more explanation.
=== 2.9 ===
1) Some new methods on the question_usage class (and corresponding methods on
question_attempt, question_attempt_step, question_usage_observer, ... requried
to implement them, but almost certainly you should only be calling the
question_usage methods from your code.
* question_usage::add_question_in_place_of_other($slot, $question, $maxmark = null)
This creates a new questoin_attempt in place of an existing one, moving the
existing question_attempt to the end of the usage, in a new slot number.
The new slot number is returned. The goal is to replace the old attempt, but
not lose the old data.
* question_usage::set_question_max_mark($slot, $maxmark)
Sets the max mark for one question in this usage. Previously, you could
only change this using the bulk operation question_usage::set_max_mark_in_attempts;
* question_usage::set_question_attempt_metadata($slot, $name, $value);
question_usage::get_question_attempt_metadata($slot, $name);
You can now record metadata, that is, values stored by name, against
question_attempts. The question engine ignores this data (other than storing
and loading it) but you may find it useful in your code.
To see examples of where these are used, look at the chagnes from MDL-40992.
2) New fields in question_display_options, ->extrainfocontent and ->extrahistorycontent.
These default to blank, but can be used to inject extra content into those parts
of the question display. If you have overridden the methods in
core_question_renderer that use these fields, you may need to update your renderer.
=== 2.6 ===
1) The method question_behaviour::is_manual_grade_in_range and move and become
question_engine::is_manual_grade_in_range.
2) The arguments to core_question_renderer::mark_summary changed from
($qa, $options) to ($qa, $behaviouroutput, $options). If you have overridden
that method you will need to update your code.
3) Heading level for number(), add_part_heading() and respond_history()
has been lowered by one level. These changes are part of improving the page
accessibility and making heading to have proper nesting. (MDL-41615)
=== Earlier changes ===
* Were not documented in this way. Sorry.
@@ -0,0 +1,565 @@
<?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/>.
/**
* This file contains classes for handling the different question behaviours
* during upgrade.
*
* @package moodlecore
* @subpackage questionengine
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Base class for managing the upgrade of a question using a particular behaviour.
*
* This class takes as input:
* 1. Various backgroud data like $quiz, $attempt and $question.
* 2. The data about the question session to upgrade $qsession and $qstates.
* Working through that data, it builds up
* 3. The equivalent new data $qa. This has roughly the same data as a
* question_attempt object belonging to the new question engine would have, but
* $this->qa is built up from stdClass objects.
*
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class question_behaviour_attempt_updater {
/** @var question_qtype_attempt_updater */
protected $qtypeupdater;
/** @var question_engine_assumption_logger */
protected $logger;
/** @var question_engine_attempt_upgrader */
protected $qeupdater;
/**
* @var object this is the data for the upgraded questions attempt that
* we are building.
*/
public $qa;
/** @var object the quiz settings. */
protected $quiz;
/** @var object the quiz attempt data. */
protected $attempt;
/** @var object the question definition data. */
protected $question;
/** @var object the question session to be upgraded. */
protected $qsession;
/** @var array the question states for the session to be upgraded. */
protected $qstates;
/** @var stdClass */
protected $startstate;
/**
* @var int counts the question_steps as they are converted to
* question_attempt_steps.
*/
protected $sequencenumber;
/** @var object pointer to the state that has already finished this attempt. */
protected $finishstate;
public function __construct($quiz, $attempt, $question, $qsession, $qstates, $logger, $qeupdater) {
$this->quiz = $quiz;
$this->attempt = $attempt;
$this->question = $question;
$this->qsession = $qsession;
$this->qstates = $qstates;
$this->logger = $logger;
$this->qeupdater = $qeupdater;
}
public function discard() {
// Help the garbage collector, which seems to be struggling.
$this->quiz = null;
$this->attempt = null;
$this->question = null;
$this->qsession = null;
$this->qstates = null;
$this->qa = null;
$this->qtypeupdater->discard();
$this->qtypeupdater = null;
$this->logger = null;
$this->qeupdater = null;
}
abstract protected function behaviour_name();
public function get_converted_qa() {
$this->initialise_qa();
$this->convert_steps();
return $this->qa;
}
protected function create_missing_first_step() {
$step = new stdClass();
$step->state = 'todo';
$step->data = array();
$step->fraction = null;
$step->timecreated = $this->attempt->timestart ? $this->attempt->timestart : time();
$step->userid = $this->attempt->userid;
$this->qtypeupdater->supply_missing_first_step_data($step->data);
return $step;
}
public function supply_missing_qa() {
$this->initialise_qa();
$this->qa->timemodified = $this->attempt->timestart;
$this->sequencenumber = 0;
$this->add_step($this->create_missing_first_step());
return $this->qa;
}
protected function initialise_qa() {
$this->qtypeupdater = $this->make_qtype_updater();
$qa = new stdClass();
$qa->questionid = $this->question->id;
$qa->variant = 1;
$qa->behaviour = $this->behaviour_name();
$qa->questionsummary = $this->qtypeupdater->question_summary($this->question);
$qa->rightanswer = $this->qtypeupdater->right_answer($this->question);
$qa->maxmark = $this->question->maxmark;
$qa->minfraction = 0;
$qa->maxfraction = 1;
$qa->flagged = 0;
$qa->responsesummary = '';
$qa->timemodified = 0;
$qa->steps = array();
$this->qa = $qa;
}
protected function convert_steps() {
$this->finishstate = null;
$this->startstate = null;
$this->sequencenumber = 0;
foreach ($this->qstates as $state) {
$this->process_state($state);
}
$this->finish_up();
}
protected function process_state($state) {
$step = $this->make_step($state);
$method = 'process' . $state->event;
$this->$method($step, $state);
}
protected function finish_up() {
}
protected function add_step($step) {
$step->sequencenumber = $this->sequencenumber;
$this->qa->steps[] = $step;
$this->sequencenumber++;
}
protected function discard_last_state() {
array_pop($this->qa->steps);
$this->sequencenumber--;
}
protected function unexpected_event($state) {
throw new coding_exception("Unexpected event {$state->event} in state {$state->id} in question session {$this->qsession->id}.");
}
protected function process0($step, $state) {
if ($this->startstate) {
if ($state->answer == reset($this->qstates)->answer) {
return;
} else if ($this->quiz->attemptonlast && $this->sequencenumber == 1) {
// There was a bug in attemptonlast in the past, which meant that
// it created two inconsistent open states, with the second taking
// priority. Simulate that be discarding the first open state, then
// continuing.
$this->logger->log_assumption("Ignoring bogus state in attempt at question {$state->question}");
$this->sequencenumber = 0;
$this->qa->steps = array();
} else if ($this->qtypeupdater->is_blank_answer($state)) {
$this->logger->log_assumption("Ignoring second start state with blank answer in attempt at question {$state->question}");
return;
} else {
throw new coding_exception("Two inconsistent open states for question session {$this->qsession->id}.");
}
}
$step->state = 'todo';
$this->startstate = $state;
$this->add_step($step);
}
protected function process1($step, $state) {
$this->unexpected_event($state);
}
protected function process2($step, $state) {
if ($this->qtypeupdater->was_answered($state)) {
$step->state = 'complete';
} else {
$step->state = 'todo';
}
$this->add_step($step);
}
protected function process3($step, $state) {
return $this->process6($step, $state);
}
protected function process4($step, $state) {
$this->unexpected_event($state);
}
protected function process5($step, $state) {
$this->unexpected_event($state);
}
abstract protected function process6($step, $state);
abstract protected function process7($step, $state);
protected function process8($step, $state) {
return $this->process6($step, $state);
}
protected function process9($step, $state) {
if (!$this->finishstate) {
$submitstate = clone($state);
$submitstate->event = 8;
$submitstate->grade = 0;
$this->process_state($submitstate);
}
$step->data['-comment'] = $this->qsession->manualcomment;
if ($this->question->maxmark > 0) {
$step->fraction = $state->grade / $this->question->maxmark;
$step->state = $this->manual_graded_state_for_fraction($step->fraction);
$step->data['-mark'] = $state->grade;
$step->data['-maxmark'] = $this->question->maxmark;
} else {
$step->state = 'manfinished';
}
unset($step->data['answer']);
$step->userid = null;
$this->add_step($step);
}
/**
* @param object $question a question definition
* @return qtype_updater
*/
protected function make_qtype_updater() {
global $CFG;
if ($this->question->qtype == 'deleted') {
return new question_deleted_question_attempt_updater(
$this, $this->question, $this->logger, $this->qeupdater);
}
$path = $CFG->dirroot . '/question/type/' . $this->question->qtype . '/db/upgradelib.php';
if (!is_readable($path)) {
throw new coding_exception("Question type {$this->question->qtype}
is missing important code (the file {$path})
required to run the upgrade to the new question engine.");
}
include_once($path);
$class = 'qtype_' . $this->question->qtype . '_qe2_attempt_updater';
if (!class_exists($class)) {
throw new coding_exception("Question type {$this->question->qtype}
is missing important code (the class {$class})
required to run the upgrade to the new question engine.");
}
return new $class($this, $this->question, $this->logger, $this->qeupdater);
}
public function to_text($html) {
return trim(html_to_text($html, 0, false));
}
protected function graded_state_for_fraction($fraction) {
if ($fraction < 0.000001) {
return 'gradedwrong';
} else if ($fraction > 0.999999) {
return 'gradedright';
} else {
return 'gradedpartial';
}
}
protected function manual_graded_state_for_fraction($fraction) {
if ($fraction < 0.000001) {
return 'mangrwrong';
} else if ($fraction > 0.999999) {
return 'mangrright';
} else {
return 'mangrpartial';
}
}
protected function make_step($state){
$step = new stdClass();
$step->data = array();
if ($state->event == 0 || $this->sequencenumber == 0) {
$this->qtypeupdater->set_first_step_data_elements($state, $step->data);
} else {
$this->qtypeupdater->set_data_elements_for_step($state, $step->data);
}
$step->fraction = null;
$step->timecreated = $state->timestamp ? $state->timestamp : time();
$step->userid = $this->attempt->userid;
$summary = $this->qtypeupdater->response_summary($state);
if (!is_null($summary)) {
$this->qa->responsesummary = $summary;
}
$this->qa->timemodified = max($this->qa->timemodified, $state->timestamp);
return $step;
}
}
class qbehaviour_deferredfeedback_converter extends question_behaviour_attempt_updater {
protected function behaviour_name() {
return 'deferredfeedback';
}
protected function process6($step, $state) {
if (!$this->startstate) {
$this->logger->log_assumption("Ignoring bogus submit before open in attempt at question {$state->question}");
// WTF, but this has happened a few times in our DB. It seems it is safe to ignore.
return;
}
if ($this->finishstate) {
if ($this->finishstate->answer != $state->answer ||
$this->finishstate->grade != $state->grade ||
$this->finishstate->raw_grade != $state->raw_grade ||
$this->finishstate->penalty != $state->penalty) {
$this->logger->log_assumption("Two inconsistent finish states found for question session {$this->qsession->id} in attempt at question {$state->question} keeping the later one.");
$this->discard_last_state();
} else {
$this->logger->log_assumption("Ignoring extra finish states in attempt at question {$state->question}");
return;
}
}
if ($this->question->maxmark > 0) {
$step->fraction = $state->grade / $this->question->maxmark;
$step->state = $this->graded_state_for_fraction($step->fraction);
} else {
$step->state = 'finished';
}
$step->data['-finish'] = '1';
$this->finishstate = $state;
$this->add_step($step);
}
protected function process7($step, $state) {
$this->unexpected_event($state);
}
}
class qbehaviour_manualgraded_converter extends question_behaviour_attempt_updater {
protected function behaviour_name() {
return 'manualgraded';
}
protected function process6($step, $state) {
$step->state = 'needsgrading';
if (!$this->finishstate) {
$step->data['-finish'] = '1';
$this->finishstate = $state;
}
$this->add_step($step);
}
protected function process7($step, $state) {
return $this->process2($step, $state);
}
}
class qbehaviour_informationitem_converter extends question_behaviour_attempt_updater {
protected function behaviour_name() {
return 'informationitem';
}
protected function process0($step, $state) {
if ($this->startstate) {
return;
}
$step->state = 'todo';
$this->startstate = $state;
$this->add_step($step);
}
protected function process2($step, $state) {
$this->unexpected_event($state);
}
protected function process3($step, $state) {
$this->unexpected_event($state);
}
protected function process6($step, $state) {
if ($this->finishstate) {
return;
}
$step->state = 'finished';
$step->data['-finish'] = '1';
$this->finishstate = $state;
$this->add_step($step);
}
protected function process7($step, $state) {
return $this->process6($step, $state);
}
protected function process8($step, $state) {
return $this->process6($step, $state);
}
}
class qbehaviour_adaptive_converter extends question_behaviour_attempt_updater {
protected $try;
protected $laststepwasatry = false;
protected $finished = false;
protected $bestrawgrade = 0;
protected function behaviour_name() {
return 'adaptive';
}
protected function finish_up() {
parent::finish_up();
if ($this->finishstate || !$this->attempt->timefinish) {
return;
}
$state = end($this->qstates);
$step = $this->make_step($state);
$this->process6($step, $state);
}
protected function process0($step, $state) {
$this->try = 1;
$this->laststepwasatry = false;
parent::process0($step, $state);
}
protected function process2($step, $state) {
if ($this->finishstate) {
$this->logger->log_assumption("Ignoring bogus save after submit in an " .
"adaptive attempt at question {$state->question} " .
"(question session {$this->qsession->id})");
return;
}
if ($this->question->maxmark > 0) {
$step->fraction = $state->grade / $this->question->maxmark;
}
$this->laststepwasatry = false;
parent::process2($step, $state);
}
protected function process3($step, $state) {
if ($this->question->maxmark > 0) {
$step->fraction = $state->grade / $this->question->maxmark;
if ($this->graded_state_for_fraction($step->fraction) == 'gradedright') {
$step->state = 'complete';
} else {
$step->state = 'todo';
}
} else {
$step->state = 'complete';
}
$this->bestrawgrade = max($state->raw_grade, $this->bestrawgrade);
$step->data['-_try'] = $this->try;
$this->try += 1;
$this->laststepwasatry = true;
if ($this->question->maxmark > 0) {
$step->data['-_rawfraction'] = $state->raw_grade / $this->question->maxmark;
} else {
$step->data['-_rawfraction'] = 0;
}
$step->data['-submit'] = 1;
$this->add_step($step);
}
protected function process6($step, $state) {
if ($this->finishstate) {
if (!$this->qtypeupdater->compare_answers($this->finishstate->answer, $state->answer) ||
$this->finishstate->grade != $state->grade ||
$this->finishstate->raw_grade != $state->raw_grade ||
$this->finishstate->penalty != $state->penalty) {
throw new coding_exception("Two inconsistent finish states found for question session {$this->qsession->id}.");
} else {
$this->logger->log_assumption("Ignoring extra finish states in attempt at question {$state->question}");
return;
}
}
$this->bestrawgrade = max($state->raw_grade, $this->bestrawgrade);
if ($this->question->maxmark > 0) {
$step->fraction = $state->grade / $this->question->maxmark;
$step->state = $this->graded_state_for_fraction(
$this->bestrawgrade / $this->question->maxmark);
} else {
$step->state = 'finished';
}
$step->data['-finish'] = 1;
if ($this->laststepwasatry) {
$this->try -= 1;
}
$step->data['-_try'] = $this->try;
if ($this->question->maxmark > 0) {
$step->data['-_rawfraction'] = $state->raw_grade / $this->question->maxmark;
} else {
$step->data['-_rawfraction'] = 0;
}
$this->finishstate = $state;
$this->add_step($step);
}
protected function process7($step, $state) {
$this->unexpected_event($state);
}
}
class qbehaviour_adaptivenopenalty_converter extends qbehaviour_adaptive_converter {
protected function behaviour_name() {
return 'adaptivenopenalty';
}
}
+95
View File
@@ -0,0 +1,95 @@
<?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/>.
/**
* Code that deals with logging stuff during the question engine upgrade.
*
* @package moodlecore
* @subpackage questionengine
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* This class serves to record all the assumptions that the code had to make
* during the question engine database database upgrade, to facilitate reviewing
* them.
*
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_engine_assumption_logger {
protected $handle;
protected $attemptid;
public function __construct() {
global $CFG;
make_upload_directory('upgradelogs');
$date = date('Ymd-His');
$this->handle = fopen($CFG->dataroot . '/upgradelogs/qe_' .
$date . '.html', 'a');
fwrite($this->handle, '<html><head><title>Question engine upgrade assumptions ' .
$date . '</title></head><body><h2>Question engine upgrade assumptions ' .
$date . "</h2>\n\n");
}
public function set_current_attempt_id($id) {
$this->attemptid = $id;
}
public function log_assumption($description, $quizattemptid = null) {
global $CFG;
$message = '<p>' . $description;
if (!$quizattemptid) {
$quizattemptid = $this->attemptid;
}
if ($quizattemptid) {
$message .= ' (<a href="' . $CFG->wwwroot . '/mod/quiz/review.php?attempt=' .
$quizattemptid . '">Review this attempt</a>)';
}
$message .= "</p>\n";
fwrite($this->handle, $message);
}
public function __destruct() {
fwrite($this->handle, '</body></html>');
fclose($this->handle);
}
}
/**
* Subclass of question_engine_assumption_logger that does nothing, for testing.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class dummy_question_engine_assumption_logger extends question_engine_assumption_logger {
protected $attemptid;
public function __construct() {
}
public function log_assumption($description, $quizattemptid = null) {
}
public function __destruct() {
}
}
+141
View File
@@ -0,0 +1,141 @@
<?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/>.
/**
* This file contains test helper code for testing the upgrade to the new
* question engine. The actual tests are organised by question type in files
* like question/type/truefalse/tests/upgradelibnewqe_test.php.
*
* @package moodlecore
* @subpackage questionengine
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once(__DIR__ . '/../upgradelib.php');
/**
* Subclass of question_engine_attempt_upgrader to help with testing.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class test_question_engine_attempt_upgrader extends question_engine_attempt_upgrader {
public function prevent_timeout() {
}
public function __construct($loader, $logger) {
$this->questionloader = $loader;
$this->logger = $logger;
}
}
/**
* Subclass of question_engine_upgrade_question_loader for unit testing.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class test_question_engine_upgrade_question_loader extends question_engine_upgrade_question_loader {
public function put_question_in_cache($question) {
$this->cache[$question->id] = $question;
}
public function load_question($questionid, $quizid) {
global $CFG;
if (isset($this->cache[$questionid])) {
return $this->cache[$questionid];
}
return null;
}
public function put_dataset_in_cache($questionid, $selecteditem, $dataset) {
$this->datasetcache[$questionid][$selecteditem] = $dataset;
}
public function load_dataset($questionid, $selecteditem) {
global $DB;
if (isset($this->datasetcache[$questionid][$selecteditem])) {
return $this->datasetcache[$questionid][$selecteditem];
}
throw new coding_exception('Test dataset not loaded.');
}
}
/**
* Base class for tests that thest the upgrade of one particular attempt and
* one question.
*
* @copyright 2009 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class question_attempt_upgrader_test_base extends advanced_testcase {
protected $updater;
protected $loader;
protected function setUp(): void {
parent::setUp();
$logger = new dummy_question_engine_assumption_logger();
$this->loader = new test_question_engine_upgrade_question_loader($logger);
$this->updater = new test_question_engine_attempt_upgrader($this->loader, $logger);
}
protected function tearDown(): void {
$this->updater = null;
parent::tearDown();
}
/**
* Clear text, bringing independence of html2text results
*
* Some tests performing text comparisons of converted text are too much
* dependent of the behavior of the html2text library. This function is
* aimed to reduce such dependencies that should not affect the results
* of these question attempt upgrade tests.
*/
protected function clear_html2text_dependencies($qa) {
// Cleaning all whitespace should be enough to ignore any html2text dependency
if (property_exists($qa, 'responsesummary')) {
$qa->responsesummary = preg_replace('/\s/', '', $qa->responsesummary);
}
if (property_exists($qa, 'questionsummary')) {
$qa->questionsummary = preg_replace('/\s/', '', $qa->questionsummary);
}
}
/**
* Compare two qas, ignoring inessential differences.
* @param object $expectedqa the expected qa.
* @param object $qa the actual qa.
*/
protected function compare_qas($expectedqa, $qa) {
$this->clear_html2text_dependencies($expectedqa);
$this->clear_html2text_dependencies($qa);
$this->assertEquals($expectedqa, $qa);
}
}
+484
View File
@@ -0,0 +1,484 @@
<?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/>.
/**
* This file contains the code required to upgrade all the attempt data from
* old versions of Moodle into the tables used by the new question engine.
*
* @package moodlecore
* @subpackage questionengine
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/question/engine/bank.php');
require_once($CFG->dirroot . '/question/engine/upgrade/logger.php');
require_once($CFG->dirroot . '/question/engine/upgrade/behaviourconverters.php');
/**
* This class manages upgrading all the question attempts from the old database
* structure to the new question engine.
*
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_engine_attempt_upgrader {
/** @var question_engine_upgrade_question_loader */
protected $questionloader;
/** @var question_engine_assumption_logger */
protected $logger;
/** @var stdClass */
protected $qsession;
public function save_usage($preferredbehaviour, $attempt, $qas, $quizlayout) {
global $OUTPUT;
$missing = array();
$layout = explode(',', $attempt->layout);
$questionkeys = array_combine(array_values($layout), array_keys($layout));
$this->set_quba_preferred_behaviour($attempt->uniqueid, $preferredbehaviour);
$i = 0;
foreach (explode(',', $quizlayout) as $questionid) {
if ($questionid == 0) {
continue;
}
$i++;
if (!array_key_exists($questionid, $qas)) {
$missing[] = $questionid;
$layout[$questionkeys[$questionid]] = $questionid;
continue;
}
$qa = $qas[$questionid];
$qa->questionusageid = $attempt->uniqueid;
$qa->slot = $i;
if (core_text::strlen($qa->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
// It seems some people write very long quesions! MDL-30760
$qa->questionsummary = core_text::substr($qa->questionsummary,
0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
}
$this->insert_record('question_attempts', $qa);
$layout[$questionkeys[$questionid]] = $qa->slot;
foreach ($qa->steps as $step) {
$step->questionattemptid = $qa->id;
$this->insert_record('question_attempt_steps', $step);
foreach ($step->data as $name => $value) {
$datum = new stdClass();
$datum->attemptstepid = $step->id;
$datum->name = $name;
$datum->value = $value;
$this->insert_record('question_attempt_step_data', $datum, false);
}
}
}
$this->set_quiz_attempt_layout($attempt->uniqueid, implode(',', $layout));
if ($missing) {
$message = "Question sessions for questions " .
implode(', ', $missing) .
" were missing when upgrading question usage {$attempt->uniqueid}.";
echo $OUTPUT->notification($message);
}
}
protected function set_quba_preferred_behaviour($qubaid, $preferredbehaviour) {
global $DB;
$DB->set_field('question_usages', 'preferredbehaviour', $preferredbehaviour,
array('id' => $qubaid));
}
protected function set_quiz_attempt_layout($qubaid, $layout) {
global $DB;
$DB->set_field('quiz_attempts', 'layout', $layout, array('uniqueid' => $qubaid));
}
protected function delete_quiz_attempt($qubaid) {
global $DB;
$DB->delete_records('quiz_attempts', array('uniqueid' => $qubaid));
$DB->delete_records('question_attempts', array('id' => $qubaid));
}
protected function insert_record($table, $record, $saveid = true) {
global $DB;
$newid = $DB->insert_record($table, $record, $saveid);
if ($saveid) {
$record->id = $newid;
}
return $newid;
}
public function load_question($questionid, $quizid = null) {
return $this->questionloader->get_question($questionid, $quizid);
}
public function load_dataset($questionid, $selecteditem) {
return $this->questionloader->load_dataset($questionid, $selecteditem);
}
public function get_next_question_session($attempt, moodle_recordset $questionsessionsrs) {
if (!$questionsessionsrs->valid()) {
return false;
}
$qsession = $questionsessionsrs->current();
if ($qsession->attemptid != $attempt->uniqueid) {
// No more question sessions belonging to this attempt.
return false;
}
// Session found, move the pointer in the RS and return the record.
$questionsessionsrs->next();
return $qsession;
}
public function get_question_states($attempt, $question, moodle_recordset $questionsstatesrs) {
$qstates = array();
while ($questionsstatesrs->valid()) {
$state = $questionsstatesrs->current();
if ($state->attempt != $attempt->uniqueid ||
$state->question != $question->id) {
// We have found all the states for this attempt. Stop.
break;
}
// Add the new state to the array, and advance.
$qstates[] = $state;
$questionsstatesrs->next();
}
return $qstates;
}
protected function get_converter_class_name($question, $quiz, $qsessionid) {
global $DB;
if ($question->qtype == 'deleted') {
$where = '(question = :questionid OR '.$DB->sql_like('answer', ':randomid').') AND event = 7';
$params = array('questionid'=>$question->id, 'randomid'=>"random{$question->id}-%");
if ($DB->record_exists_select('question_states', $where, $params)) {
$this->logger->log_assumption("Assuming that deleted question {$question->id} was manually graded.");
return 'qbehaviour_manualgraded_converter';
}
}
$qtype = question_bank::get_qtype($question->qtype, false);
if ($qtype->is_manual_graded()) {
return 'qbehaviour_manualgraded_converter';
} else if ($question->qtype == 'description') {
return 'qbehaviour_informationitem_converter';
} else if ($quiz->preferredbehaviour == 'deferredfeedback') {
return 'qbehaviour_deferredfeedback_converter';
} else if ($quiz->preferredbehaviour == 'adaptive') {
return 'qbehaviour_adaptive_converter';
} else if ($quiz->preferredbehaviour == 'adaptivenopenalty') {
return 'qbehaviour_adaptivenopenalty_converter';
} else {
throw new coding_exception("Question session {$qsessionid}
has an unexpected preferred behaviour {$quiz->preferredbehaviour}.");
}
}
public function supply_missing_question_attempt($quiz, $attempt, $question) {
if ($question->qtype == 'random') {
throw new coding_exception("Cannot supply a missing qsession for question
{$question->id} in attempt {$attempt->id}.");
}
$converterclass = $this->get_converter_class_name($question, $quiz, 'missing');
$qbehaviourupdater = new $converterclass($quiz, $attempt, $question,
null, null, $this->logger, $this);
$qa = $qbehaviourupdater->supply_missing_qa();
$qbehaviourupdater->discard();
return $qa;
}
public function convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates) {
if ($question->qtype == 'random') {
list($question, $qstates) = $this->decode_random_attempt($qstates, $question->maxmark);
$qsession->questionid = $question->id;
}
$converterclass = $this->get_converter_class_name($question, $quiz, $qsession->id);
$qbehaviourupdater = new $converterclass($quiz, $attempt, $question, $qsession,
$qstates, $this->logger, $this);
$qa = $qbehaviourupdater->get_converted_qa();
$qbehaviourupdater->discard();
return $qa;
}
protected function decode_random_attempt($qstates, $maxmark) {
$realquestionid = null;
foreach ($qstates as $i => $state) {
if (strpos($state->answer, '-') < 6) {
// Broken state, skip it.
$this->logger->log_assumption("Had to skip brokes state {$state->id}
for question {$state->question}.");
unset($qstates[$i]);
continue;
}
list($randombit, $realanswer) = explode('-', $state->answer, 2);
$newquestionid = substr($randombit, 6);
if ($realquestionid && $realquestionid != $newquestionid) {
throw new coding_exception("Question session {$this->qsession->id}
for random question points to two different real questions
{$realquestionid} and {$newquestionid}.");
}
$qstates[$i]->answer = $realanswer;
}
if (empty($newquestionid)) {
// This attempt only had broken states. Set a fake $newquestionid to
// prevent a null DB error later.
$newquestionid = 0;
}
$newquestion = $this->load_question($newquestionid);
$newquestion->maxmark = $maxmark;
return array($newquestion, $qstates);
}
public function prepare_to_restore() {
$this->logger = new dummy_question_engine_assumption_logger();
$this->questionloader = new question_engine_upgrade_question_loader($this->logger);
}
}
/**
* This class deals with loading (and caching) question definitions during the
* question engine upgrade.
*
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_engine_upgrade_question_loader {
protected $cache = array();
protected $datasetcache = array();
/** @var base_logger */
protected $logger;
public function __construct($logger) {
$this->logger = $logger;
}
protected function load_question($questionid, $quizid) {
global $DB;
if ($quizid) {
$question = $DB->get_record_sql("
SELECT q.*, slot.maxmark
FROM {question} q
JOIN {quiz_slots} slot ON slot.questionid = q.id
WHERE q.id = ? AND slot.quizid = ?", array($questionid, $quizid));
} else {
$question = $DB->get_record('question', array('id' => $questionid));
}
if (!$question) {
return null;
}
if (empty($question->defaultmark)) {
if (!empty($question->defaultgrade)) {
$question->defaultmark = $question->defaultgrade;
} else {
$question->defaultmark = 0;
}
unset($question->defaultgrade);
}
$qtype = question_bank::get_qtype($question->qtype, false);
if ($qtype->name() === 'missingtype') {
$this->logger->log_assumption("Dealing with question id {$question->id}
that is of an unknown type {$question->qtype}.");
$question->questiontext = '<p>' . get_string('warningmissingtype', 'quiz') .
'</p>' . $question->questiontext;
}
$qtype->get_question_options($question);
return $question;
}
public function get_question($questionid, $quizid) {
if (isset($this->cache[$questionid])) {
return $this->cache[$questionid];
}
$question = $this->load_question($questionid, $quizid);
if (!$question) {
$this->logger->log_assumption("Dealing with question id {$questionid}
that was missing from the database.");
$question = new stdClass();
$question->id = $questionid;
$question->qtype = 'deleted';
$question->maxmark = 1; // Guess, but that is all we can do.
$question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
}
$this->cache[$questionid] = $question;
return $this->cache[$questionid];
}
public function load_dataset($questionid, $selecteditem) {
global $DB;
if (isset($this->datasetcache[$questionid][$selecteditem])) {
return $this->datasetcache[$questionid][$selecteditem];
}
$this->datasetcache[$questionid][$selecteditem] = $DB->get_records_sql_menu('
SELECT qdd.name, qdi.value
FROM {question_dataset_items} qdi
JOIN {question_dataset_definitions} qdd ON qdd.id = qdi.definition
JOIN {question_datasets} qd ON qdd.id = qd.datasetdefinition
WHERE qd.question = ?
AND qdi.itemnumber = ?
', array($questionid, $selecteditem));
return $this->datasetcache[$questionid][$selecteditem];
}
}
/**
* Base class for the classes that convert the question-type specific bits of
* the attempt data.
*
* @copyright 2010 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class question_qtype_attempt_updater {
/** @var object the question definition data. */
protected $question;
/** @var question_behaviour_attempt_updater */
protected $updater;
/** @var question_engine_assumption_logger */
protected $logger;
/** @var question_engine_attempt_upgrader */
protected $qeupdater;
public function __construct($updater, $question, $logger, $qeupdater) {
$this->updater = $updater;
$this->question = $question;
$this->logger = $logger;
$this->qeupdater = $qeupdater;
}
public function discard() {
// Help the garbage collector, which seems to be struggling.
$this->updater = null;
$this->question = null;
$this->logger = null;
$this->qeupdater = null;
}
protected function to_text($html) {
return $this->updater->to_text($html);
}
public function question_summary() {
return $this->to_text($this->question->questiontext);
}
public function compare_answers($answer1, $answer2) {
return $answer1 == $answer2;
}
public function is_blank_answer($state) {
return $state->answer == '';
}
abstract public function right_answer();
abstract public function response_summary($state);
abstract public function was_answered($state);
abstract public function set_first_step_data_elements($state, &$data);
abstract public function set_data_elements_for_step($state, &$data);
abstract public function supply_missing_first_step_data(&$data);
}
class question_deleted_question_attempt_updater extends question_qtype_attempt_updater {
public function right_answer() {
return '';
}
public function response_summary($state) {
return $state->answer;
}
public function was_answered($state) {
return !empty($state->answer);
}
public function set_first_step_data_elements($state, &$data) {
$data['upgradedfromdeletedquestion'] = $state->answer;
}
public function supply_missing_first_step_data(&$data) {
}
public function set_data_elements_for_step($state, &$data) {
$data['upgradedfromdeletedquestion'] = $state->answer;
}
}
/**
* This check verifies that all quiz attempts were upgraded since following
* the question engine upgrade in Moodle 2.1.
*
* Note: This custom check (and its environment.xml declaration) will be safely
* removed once we raise min required Moodle version to be 2.7 or newer.
*
* @param environment_results object to update, if relevant.
* @return environment_results updated results object, or null if this test is not relevant.
*/
function quiz_attempts_upgraded(environment_results $result) {
global $DB;
$dbman = $DB->get_manager();
$table = new xmldb_table('quiz_attempts');
$field = new xmldb_field('needsupgradetonewqe');
if (!$dbman->table_exists($table) || !$dbman->field_exists($table, $field)) {
// DB already upgraded. This test is no longer relevant.
return null;
}
if (!$DB->record_exists('quiz_attempts', array('needsupgradetonewqe' => 1))) {
// No 1s present in that column means there are no problems.
return null;
}
// Only display anything if the admins need to be aware of the problem.
$result->setStatus(false);
return $result;
}