MDL-38538 question auto-save back end.
1. Autosave works in some ways just like a normal save. We ultimately call $behaviour->process_save() to do the work, and create a new step to hold the data. 2. However, we come in through a completely different route through the API, starting with separate auto-save methods. This keeps the auto-save changes mostly separate, and so reduced the chance of breaking existing working code. 3. When the time comes to store the auto-save step in the database, we save it using a negative sequence number. This is a clever trick that not only distinguises these steps, but also avoids unique key errors when an auto-save and a real action happen simultaneously. (There are unit tests for these tricky edge cases.) 4. When we load the data back from the database, most of the time the auto-save steps are loaded back as if they were a real save, and so the auto-saved data is used when the question is then rendered. 5. However, before we process another action, we remove the auto-saved step, so it does not appear in the final history.
This commit is contained in:
@@ -435,6 +435,19 @@ abstract class question_behaviour {
|
||||
*/
|
||||
public abstract function process_action(question_attempt_pending_step $pendingstep);
|
||||
|
||||
/**
|
||||
* Auto-saved data. By default this does nothing. interesting processing is
|
||||
* done in {@link question_behaviour_with_save}.
|
||||
*
|
||||
* @param question_attempt_pending_step $pendingstep a partially initialised step
|
||||
* containing all the information about the action that is being peformed. This
|
||||
* information can be accessed using {@link question_attempt_step::get_behaviour_var()}.
|
||||
* @return bool either {@link question_attempt::KEEP} or {@link question_attempt::DISCARD}
|
||||
*/
|
||||
public function process_autosave(question_attempt_pending_step $pendingstep) {
|
||||
return question_attempt::DISCARD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of processing a manual comment/grade action that should
|
||||
* be suitable for most subclasses.
|
||||
@@ -570,6 +583,29 @@ abstract class question_behaviour_with_save extends question_behaviour {
|
||||
return $this->question->is_complete_response($pendingstep->get_qt_data());
|
||||
}
|
||||
|
||||
public function process_autosave(question_attempt_pending_step $pendingstep) {
|
||||
// If already finished. Nothing to do.
|
||||
if ($this->qa->get_state()->is_finished()) {
|
||||
return question_attempt::DISCARD;
|
||||
}
|
||||
|
||||
// If the new data is the same as we already have, then we don't need it.
|
||||
if ($this->is_same_response($pendingstep)) {
|
||||
return question_attempt::DISCARD;
|
||||
}
|
||||
|
||||
// Repeat that test discarding any existing autosaved data.
|
||||
if ($this->qa->has_autosaved_step()) {
|
||||
$this->qa->discard_autosaved_step();
|
||||
if ($this->is_same_response($pendingstep)) {
|
||||
return question_attempt::DISCARD;
|
||||
}
|
||||
}
|
||||
|
||||
// OK, we need to save.
|
||||
return $this->process_save($pendingstep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of processing a save action that should be suitable for
|
||||
* most subclasses.
|
||||
|
||||
@@ -63,7 +63,7 @@ class question_engine_data_mapper {
|
||||
/**
|
||||
* @param moodle_database $db a database connectoin. Defaults to global $DB.
|
||||
*/
|
||||
public function __construct($db = null) {
|
||||
public function __construct(moodle_database $db = null) {
|
||||
if (is_null($db)) {
|
||||
global $DB;
|
||||
$this->db = $DB;
|
||||
@@ -613,12 +613,10 @@ ORDER BY qa.slot
|
||||
* @return array of question_attempts.
|
||||
*/
|
||||
public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
|
||||
global $DB;
|
||||
|
||||
$params = $qubaids->from_where_params();
|
||||
$params['questionid'] = $questionid;
|
||||
|
||||
$records = $DB->get_recordset_sql("
|
||||
$records = $this->db->get_recordset_sql("
|
||||
SELECT
|
||||
quba.contextid,
|
||||
quba.preferredbehaviour,
|
||||
|
||||
@@ -71,10 +71,11 @@ abstract class question_engine {
|
||||
/**
|
||||
* Load a {@link question_usage_by_activity} from the database, based on its id.
|
||||
* @param int $qubaid the id of the usage to load.
|
||||
* @param moodle_database $db a database connectoin. Defaults to global $DB.
|
||||
* @return question_usage_by_activity loaded from the database.
|
||||
*/
|
||||
public static function load_questions_usage_by_activity($qubaid) {
|
||||
$dm = new question_engine_data_mapper();
|
||||
public static function load_questions_usage_by_activity($qubaid, moodle_database $db = null) {
|
||||
$dm = new question_engine_data_mapper($db);
|
||||
return $dm->load_questions_usage_by_activity($qubaid);
|
||||
}
|
||||
|
||||
@@ -83,9 +84,10 @@ abstract class question_engine {
|
||||
* if the usage was newly created by {@link make_questions_usage_by_activity()}
|
||||
* or loaded from the database using {@link load_questions_usage_by_activity()}
|
||||
* @param question_usage_by_activity the usage to save.
|
||||
* @param moodle_database $db a database connectoin. Defaults to global $DB.
|
||||
*/
|
||||
public static function save_questions_usage_by_activity(question_usage_by_activity $quba) {
|
||||
$dm = new question_engine_data_mapper();
|
||||
public static function save_questions_usage_by_activity(question_usage_by_activity $quba, moodle_database $db = null) {
|
||||
$dm = new question_engine_data_mapper($db);
|
||||
$observer = $quba->get_observer();
|
||||
if ($observer instanceof question_engine_unit_of_work) {
|
||||
$observer->save($dm);
|
||||
|
||||
@@ -118,6 +118,12 @@ class question_attempt {
|
||||
/** @var array of {@link question_attempt_step}s. The steps in this attempt. */
|
||||
protected $steps = array();
|
||||
|
||||
/**
|
||||
* @var question_attempt_step if, when we loaded the step from the DB, there was
|
||||
* an autosaved step, we save a pointer to it here. (It is also added to the $steps array.)
|
||||
*/
|
||||
protected $autosavedstep = null;
|
||||
|
||||
/** @var boolean whether the user has flagged this attempt within the usage. */
|
||||
protected $flagged = false;
|
||||
|
||||
@@ -362,6 +368,14 @@ class question_attempt {
|
||||
return end($this->steps);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean whether this question_attempt has autosaved data from
|
||||
* some time in the past.
|
||||
*/
|
||||
public function has_autosaved_step() {
|
||||
return !is_null($this->autosavedstep);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return question_attempt_step_iterator for iterating over the steps in
|
||||
* this attempt, in order.
|
||||
@@ -788,6 +802,31 @@ class question_attempt {
|
||||
$this->observer->notify_step_added($step, $this, key($this->steps));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an auto-saved step to this question attempt. We mark auto-saved steps by
|
||||
* changing saving the step number with a - sign.
|
||||
* @param question_attempt_step $step the new step.
|
||||
*/
|
||||
protected function add_autosaved_step(question_attempt_step $step) {
|
||||
$this->steps[] = $step;
|
||||
$this->autosavedstep = $step;
|
||||
end($this->steps);
|
||||
$this->observer->notify_step_added($step, $this, -key($this->steps));
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard any auto-saved data belonging to this question attempt.
|
||||
*/
|
||||
public function discard_autosaved_step() {
|
||||
if (!$this->has_autosaved_step()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$autosaved = array_pop($this->steps);
|
||||
$this->autosavedstep = null;
|
||||
$this->observer->notify_step_deleted($autosaved, $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a strategy to pick a variant.
|
||||
* @param question_variant_selection_strategy $variantstrategy a strategy.
|
||||
@@ -1045,10 +1084,12 @@ class question_attempt {
|
||||
* Perform the action described by $submitteddata.
|
||||
* @param array $submitteddata the submitted data the determines the action.
|
||||
* @param int $timestamp the time to record for the action. (If not given, use now.)
|
||||
* @param int $userid the user to attribute the aciton to. (If not given, use the current user.)
|
||||
* @param int $userid the user to attribute the action to. (If not given, use the current user.)
|
||||
* @param int $existingstepid used by the regrade code.
|
||||
*/
|
||||
public function process_action($submitteddata, $timestamp = null, $userid = null, $existingstepid = null) {
|
||||
$pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid, $existingstepid);
|
||||
$this->discard_autosaved_step();
|
||||
if ($this->behaviour->process_action($pendingstep) == self::KEEP) {
|
||||
$this->add_step($pendingstep);
|
||||
if ($pendingstep->response_summary_changed()) {
|
||||
@@ -1057,6 +1098,22 @@ class question_attempt {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an autosave.
|
||||
* @param array $submitteddata the submitted data the determines the action.
|
||||
* @param int $timestamp the time to record for the action. (If not given, use now.)
|
||||
* @param int $userid the user to attribute the action to. (If not given, use the current user.)
|
||||
* @return bool whether anything was saved.
|
||||
*/
|
||||
public function process_autosave($submitteddata, $timestamp = null, $userid = null) {
|
||||
$pendingstep = new question_attempt_pending_step($submitteddata, $timestamp, $userid);
|
||||
if ($this->behaviour->process_autosave($pendingstep) == self::KEEP) {
|
||||
$this->add_autosaved_step($pendingstep);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a finish action on this question attempt. This corresponds to an
|
||||
* external finish action, for example the user pressing Submit all and finish
|
||||
@@ -1212,6 +1269,7 @@ class question_attempt {
|
||||
|
||||
$qa->behaviour = question_engine::make_behaviour(
|
||||
$record->behaviour, $qa, $preferredbehaviour);
|
||||
$qa->observer = $observer;
|
||||
|
||||
// If attemptstepid is null (which should not happen, but has happened
|
||||
// due to corrupt data, see MDL-34251) then the current pointer in $records
|
||||
@@ -1223,12 +1281,28 @@ class question_attempt {
|
||||
}
|
||||
|
||||
$i = 0;
|
||||
$autosavedstep = null;
|
||||
$autosavedsequencenumber = null;
|
||||
while ($record && $record->questionattemptid == $questionattemptid && !is_null($record->attemptstepid)) {
|
||||
$qa->steps[$i] = question_attempt_step::load_from_records($records, $record->attemptstepid);
|
||||
if ($i == 0) {
|
||||
$question->apply_attempt_state($qa->steps[0]);
|
||||
$sequencenumber = $record->sequencenumber;
|
||||
$nextstep = question_attempt_step::load_from_records($records, $record->attemptstepid);
|
||||
|
||||
if ($sequencenumber < 0) {
|
||||
if (!$autosavedstep) {
|
||||
$autosavedstep = $nextstep;
|
||||
$autosavedsequencenumber = -$sequencenumber;
|
||||
} else {
|
||||
// Old redundant data. Mark it for deletion.
|
||||
$qa->observer->notify_step_deleted($nextstep, $qa);
|
||||
}
|
||||
} else {
|
||||
$qa->steps[$i] = $nextstep;
|
||||
if ($i == 0) {
|
||||
$question->apply_attempt_state($qa->steps[0]);
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
$i++;
|
||||
|
||||
if ($records->valid()) {
|
||||
$record = $records->current();
|
||||
} else {
|
||||
@@ -1236,7 +1310,14 @@ class question_attempt {
|
||||
}
|
||||
}
|
||||
|
||||
$qa->observer = $observer;
|
||||
if ($autosavedstep) {
|
||||
if ($autosavedsequencenumber >= $i) {
|
||||
$qa->autosavedstep = $autosavedstep;
|
||||
$qa->steps[$i] = $qa->autosavedstep;
|
||||
} else {
|
||||
$qa->observer->notify_step_deleted($autosavedstep, $qa);
|
||||
}
|
||||
}
|
||||
|
||||
return $qa;
|
||||
}
|
||||
|
||||
@@ -511,7 +511,49 @@ class question_usage_by_activity {
|
||||
* instead of the data from $_POST.
|
||||
*/
|
||||
public function process_all_actions($timestamp = null, $postdata = null) {
|
||||
// note: we must not use "question_attempt::get_submitted_var()" because there is no attempt instance!!!
|
||||
foreach ($this->get_slots_in_request($postdata) as $slot) {
|
||||
if (!$this->validate_sequence_number($slot, $postdata)) {
|
||||
continue;
|
||||
}
|
||||
$submitteddata = $this->extract_responses($slot, $postdata);
|
||||
$this->process_action($slot, $submitteddata, $timestamp);
|
||||
}
|
||||
$this->update_question_flags($postdata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all the question autosave data in the current request.
|
||||
*
|
||||
* If there is a parameter slots included in the post data, then only
|
||||
* those question numbers will be processed, otherwise all questions in this
|
||||
* useage will be.
|
||||
*
|
||||
* This function also does {@link update_question_flags()}.
|
||||
*
|
||||
* @param int $timestamp optional, use this timestamp as 'now'.
|
||||
* @param array $postdata optional, only intended for testing. Use this data
|
||||
* instead of the data from $_POST.
|
||||
*/
|
||||
public function process_all_autosaves($timestamp = null, $postdata = null) {
|
||||
foreach ($this->get_slots_in_request($postdata) as $slot) {
|
||||
if (!$this->is_autosave_required($slot, $postdata)) {
|
||||
continue;
|
||||
}
|
||||
$submitteddata = $this->extract_responses($slot, $postdata);
|
||||
$this->process_autosave($slot, $submitteddata, $timestamp);
|
||||
}
|
||||
$this->update_question_flags($postdata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of slot numbers that should be processed as part of processing
|
||||
* the current request.
|
||||
* @param array $postdata optional, only intended for testing. Use this data
|
||||
* instead of the data from $_POST.
|
||||
* @return array of slot numbers.
|
||||
*/
|
||||
protected function get_slots_in_request($postdata = null) {
|
||||
// Note: we must not use "question_attempt::get_submitted_var()" because there is no attempt instance!!!
|
||||
if (is_null($postdata)) {
|
||||
$slots = optional_param('slots', null, PARAM_SEQUENCE);
|
||||
} else if (array_key_exists('slots', $postdata)) {
|
||||
@@ -526,14 +568,7 @@ class question_usage_by_activity {
|
||||
} else {
|
||||
$slots = explode(',', $slots);
|
||||
}
|
||||
foreach ($slots as $slot) {
|
||||
if (!$this->validate_sequence_number($slot, $postdata)) {
|
||||
continue;
|
||||
}
|
||||
$submitteddata = $this->extract_responses($slot, $postdata);
|
||||
$this->process_action($slot, $submitteddata, $timestamp);
|
||||
}
|
||||
$this->update_question_flags($postdata);
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,6 +595,18 @@ class question_usage_by_activity {
|
||||
$this->observer->notify_attempt_modified($qa);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an autosave action on a specific question.
|
||||
* @param int $slot the number used to identify this question within this usage.
|
||||
* @param $submitteddata the submitted data that constitutes the action.
|
||||
*/
|
||||
public function process_autosave($slot, $submitteddata, $timestamp = null) {
|
||||
$qa = $this->get_question_attempt($slot);
|
||||
if ($qa->process_autosave($submitteddata, $timestamp)) {
|
||||
$this->observer->notify_attempt_modified($qa);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the sequence number, that detects weird things like the student
|
||||
* clicking back, is OK. If the sequence check variable is not present, returns
|
||||
|
||||
@@ -140,4 +140,116 @@ class question_attempt_db_test extends data_loading_method_test_base {
|
||||
$this->assertEquals(1, $step->get_user_id());
|
||||
$this->assertEquals(array(), $step->get_all_data());
|
||||
}
|
||||
|
||||
public function test_load_with_autosaved_data() {
|
||||
$records = new question_test_recordset(array(
|
||||
array('questionattemptid', 'contextid', 'questionusageid', 'slot',
|
||||
'behaviour', 'questionid', 'variant', 'maxmark', 'minfraction', '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, 0, '', '', '', 1256233790, 4, -3, 'complete', null, 1256233715, 1, 'answer', '1'),
|
||||
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null),
|
||||
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', null, 1256233705, 1, 'answer', '1'),
|
||||
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.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()->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() {
|
||||
// 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', '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, 0, '', '', '', 1256233790, 5, -2, 'complete', null, 1256233715, 1, 'answer', '0'),
|
||||
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 4, -1, 'complete', null, 1256233715, 1, 'answer', '0'),
|
||||
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo', null, 1256233700, 1, null, null),
|
||||
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', null, 1256233705, 1, 'answer', '1'),
|
||||
array(1, 123, 1, 1, 'deferredfeedback', -1, 1, 2.0000000, 0.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()->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,562 @@
|
||||
<?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 autosave code in the question_usage class.
|
||||
*
|
||||
* @package moodlecore
|
||||
* @subpackage questionengine
|
||||
* @copyright 2013 The Open University
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
global $CFG;
|
||||
require_once(dirname(__FILE__) . '/../lib.php');
|
||||
require_once(dirname(__FILE__) . '/helpers.php');
|
||||
|
||||
|
||||
/**
|
||||
* Unit tests for the autosave parts of the {@link question_usage} class.
|
||||
*
|
||||
* @copyright 2013 The Open University
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class question_usage_autosave_test extends qbehaviour_walkthrough_test_base {
|
||||
|
||||
public function test_autosave_then_display() {
|
||||
$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');
|
||||
|
||||
// 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->delete_quba();
|
||||
}
|
||||
|
||||
public function test_autosave_then_autosave_different_data() {
|
||||
$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');
|
||||
|
||||
// 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');
|
||||
|
||||
// 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->delete_quba();
|
||||
}
|
||||
|
||||
public function test_autosave_then_autosave_same_data() {
|
||||
$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');
|
||||
|
||||
// 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');
|
||||
|
||||
$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->delete_quba();
|
||||
}
|
||||
|
||||
public function test_autosave_then_autosave_original_data() {
|
||||
$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');
|
||||
|
||||
// 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');
|
||||
|
||||
// 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->delete_quba();
|
||||
}
|
||||
|
||||
public function test_autosave_then_real_save() {
|
||||
$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');
|
||||
|
||||
// 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');
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
public function test_autosave_then_real_save_same() {
|
||||
$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');
|
||||
|
||||
// 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');
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
public function test_autosave_then_submit() {
|
||||
$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');
|
||||
|
||||
// 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');
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
public function test_autosave_and_save_concurrently() {
|
||||
// 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');
|
||||
|
||||
$DB2->dispose();
|
||||
}
|
||||
|
||||
public function test_concurrent_autosaves() {
|
||||
// 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->setExpectedException('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');
|
||||
|
||||
$DB2->dispose();
|
||||
}
|
||||
}
|
||||
@@ -59,4 +59,29 @@ class core_question_generator extends component_generator_base {
|
||||
$record['id'] = $DB->insert_record('question_categories', $record);
|
||||
return (object) $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new question. The question is initialised using one of the
|
||||
* examples from the appropriate {@link question_test_helper} subclass.
|
||||
* Then, any files you want to change from the value in the base example you
|
||||
* can override using $overrides.
|
||||
* @param string $qtype the question type to create an example of.
|
||||
* @param string $which as for the corresponding argument of
|
||||
* {@link question_test_helper::get_question_form_data}. null for the default one.
|
||||
* @param array|stdClass $overrides any fields that should be different from the base example.
|
||||
*/
|
||||
public function create_question($qtype, $which = null, $overrides = null) {
|
||||
global $CFG;
|
||||
require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
|
||||
|
||||
$fromform = test_question_maker::get_question_form_data($qtype, $which);
|
||||
$fromform = (object) $this->datagenerator->combine_defaults_and_record(
|
||||
(array) $fromform, $overrides);
|
||||
|
||||
$question = new stdClass();
|
||||
$question->category = $fromform->category;
|
||||
$question->qtype = $qtype;
|
||||
$question->createdby = 0;
|
||||
return question_bank::get_qtype($qtype)->save_question($question, $fromform);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user