MDL-20636 Current work-in-progress converting the question engine to Moodle 2.0
authorTim Hunt <T.J.Hunt@open.ac.uk>
Mon, 20 Dec 2010 16:16:09 +0000 (16:16 +0000)
committerTim Hunt <T.J.Hunt@open.ac.uk>
Thu, 13 Jan 2011 18:35:29 +0000 (18:35 +0000)
106 files changed:
mod/quiz/db/install.xml [changed mode: 0755->0644]
mod/quiz/pix/icon.gif [changed mode: 0755->0644]
question/behaviour/adaptive/behaviour.php [new file with mode: 0644]
question/behaviour/adaptive/lang/en_utf8/qbehaviour_adaptive.php [new file with mode: 0644]
question/behaviour/adaptive/renderer.php [new file with mode: 0644]
question/behaviour/adaptive/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/adaptivenopenalty/behaviour.php [new file with mode: 0644]
question/behaviour/adaptivenopenalty/lang/en_utf8/qbehaviour_adaptivenopenalty.php [new file with mode: 0644]
question/behaviour/adaptivenopenalty/renderer.php [new file with mode: 0644]
question/behaviour/adaptivenopenalty/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/behaviourbase.php [new file with mode: 0644]
question/behaviour/deferredcbm/behaviour.php [new file with mode: 0644]
question/behaviour/deferredcbm/lang/en_utf8/qbehaviour_deferredcbm.php [new file with mode: 0644]
question/behaviour/deferredcbm/renderer.php [new file with mode: 0644]
question/behaviour/deferredcbm/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/deferredfeedback/behaviour.php [new file with mode: 0644]
question/behaviour/deferredfeedback/lang/en_utf8/qbehaviour_deferredfeedback.php [new file with mode: 0644]
question/behaviour/deferredfeedback/renderer.php [new file with mode: 0644]
question/behaviour/deferredfeedback/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/immediatecbm/behaviour.php [new file with mode: 0644]
question/behaviour/immediatecbm/lang/en_utf8/qbehaviour_immediatecbm.php [new file with mode: 0644]
question/behaviour/immediatecbm/renderer.php [new file with mode: 0644]
question/behaviour/immediatecbm/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/immediatefeedback/behaviour.php [new file with mode: 0644]
question/behaviour/immediatefeedback/lang/en_utf8/qbehaviour_immediatefeedback.php [new file with mode: 0644]
question/behaviour/immediatefeedback/renderer.php [new file with mode: 0644]
question/behaviour/immediatefeedback/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/informationitem/behaviour.php [new file with mode: 0644]
question/behaviour/informationitem/lang/en_utf8/qbehaviour_informationitem.php [new file with mode: 0644]
question/behaviour/informationitem/renderer.php [new file with mode: 0644]
question/behaviour/informationitem/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/interactive/behaviour.php [new file with mode: 0644]
question/behaviour/interactive/lang/en_utf8/qbehaviour_interactive.php [new file with mode: 0644]
question/behaviour/interactive/renderer.php [new file with mode: 0644]
question/behaviour/interactive/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/interactivecountback/behaviour.php [new file with mode: 0644]
question/behaviour/interactivecountback/lang/en_utf8/qbehaviour_interactivecountback.php [new file with mode: 0644]
question/behaviour/interactivecountback/renderer.php [new file with mode: 0644]
question/behaviour/interactivecountback/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/manualgraded/behaviour.php [new file with mode: 0644]
question/behaviour/manualgraded/lang/en_utf8/qbehaviour_manualgraded.php [new file with mode: 0644]
question/behaviour/manualgraded/renderer.php [new file with mode: 0644]
question/behaviour/manualgraded/simpletest/testwalkthrough.php [new file with mode: 0644]
question/behaviour/missing/behaviour.php [new file with mode: 0644]
question/behaviour/missing/lang/en_utf8/qbehaviour_missing.php [new file with mode: 0644]
question/behaviour/missing/renderer.php [new file with mode: 0644]
question/behaviour/missing/simpletest/testmissingbehaviour.php [new file with mode: 0644]
question/behaviour/opaque/behaviour.php [new file with mode: 0644]
question/behaviour/opaque/lang/en_utf8/qbehaviour_opaque.php [new file with mode: 0644]
question/behaviour/opaque/renderer.php [new file with mode: 0644]
question/behaviour/opaque/simpletest/testopaquebehaviour.php [new file with mode: 0644]
question/behaviour/rendererbase.php [new file with mode: 0644]
question/editlib.php
question/engine/bank.php [new file with mode: 0644]
question/engine/datalib.php [new file with mode: 0644]
question/engine/lib.php [new file with mode: 0644]
question/engine/renderer.php [new file with mode: 0644]
question/engine/simpletest/helpers.php [new file with mode: 0644]
question/engine/simpletest/testdatalib.php [new file with mode: 0644]
question/engine/simpletest/testquestionattempt.php [new file with mode: 0644]
question/engine/simpletest/testquestionattemptiterator.php [new file with mode: 0644]
question/engine/simpletest/testquestionattemptstep.php [new file with mode: 0644]
question/engine/simpletest/testquestionattemptstepiterator.php [new file with mode: 0644]
question/engine/simpletest/testquestioncbm.php [new file with mode: 0644]
question/engine/simpletest/testquestionengine.php [new file with mode: 0644]
question/engine/simpletest/testquestionstate.php [new file with mode: 0644]
question/engine/simpletest/testquestionusagebyactivity.php [new file with mode: 0644]
question/engine/simpletest/testquestionutils.php [new file with mode: 0644]
question/engine/states.php [new file with mode: 0644]
question/engine/upgradefromoldqe/upgrade.php [new file with mode: 0644]
question/file.php
question/format/examview/format.php [changed mode: 0755->0644]
question/format/gift/format.php [changed mode: 0755->0644]
question/format/learnwise/format.php [changed mode: 0755->0644]
question/format/qti_two/templates/choice.tpl [changed mode: 0755->0644]
question/format/qti_two/templates/choiceMultiple.tpl [changed mode: 0755->0644]
question/format/qti_two/templates/composite.tpl [changed mode: 0755->0644]
question/format/qti_two/templates/extendedText.tpl [changed mode: 0755->0644]
question/format/qti_two/templates/extendedText_simpleEssay.tpl [changed mode: 0755->0644]
question/format/qti_two/templates/imsmanifest.tpl [changed mode: 0755->0644]
question/format/qti_two/templates/mmchoiceMultiple.tpl [changed mode: 0755->0644]
question/format/qti_two/templates/notimplemented.tpl [changed mode: 0755->0644]
question/format/qti_two/templates/numerical.tpl [changed mode: 0755->0644]
question/format/qti_two/templates/textEntry.tpl [changed mode: 0755->0644]
question/format/xhtml/format.php [changed mode: 0755->0644]
question/format/xml/format.php [changed mode: 0755->0644]
question/import_form.php
question/move_form.php
question/preview.js [new file with mode: 0644]
question/type/calculated/pix/icon.gif [changed mode: 0755->0644]
question/type/description/old_questiontype.php [new file with mode: 0644]
question/type/description/question.php [new file with mode: 0644]
question/type/description/questiontype.php
question/type/description/renderer.php [new file with mode: 0644]
question/type/missingtype/old_questiontype.php [new file with mode: 0644]
question/type/missingtype/question.php [new file with mode: 0644]
question/type/missingtype/questiontype.php
question/type/missingtype/renderer.php [new file with mode: 0644]
question/type/old_questiontype.php [new file with mode: 0644]
question/type/questionbase.php [new file with mode: 0644]
question/type/questiontype.php
question/type/rendererbase.php [new file with mode: 0644]
question/type/truefalse/old_questiontype.php [new file with mode: 0644]
question/type/truefalse/question.php [new file with mode: 0644]
question/type/truefalse/questiontype.php
question/type/truefalse/renderer.php [new file with mode: 0644]

old mode 100755 (executable)
new mode 100644 (file)
old mode 100755 (executable)
new mode 100644 (file)
diff --git a/question/behaviour/adaptive/behaviour.php b/question/behaviour/adaptive/behaviour.php
new file mode 100644 (file)
index 0000000..9f7db58
--- /dev/null
@@ -0,0 +1,178 @@
+<?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/>.
+
+
+/**
+ * Question behaviour for the old adaptive mode.
+ *
+ * @package qbehaviour_adaptive
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * Question behaviour for adaptive mode.
+ *
+ * This is the old version of interactive mode.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_adaptive extends question_behaviour_with_save {
+    const IS_ARCHETYPAL = true;
+
+    public function required_question_definition_type() {
+        return 'question_automatically_gradable';
+    }
+
+    public function get_expected_data() {
+        if ($this->qa->get_state()->is_active()) {
+            return array('submit' => PARAM_BOOL);
+        }
+        return parent::get_expected_data();
+    }
+
+    public function get_right_answer_summary() {
+        return $this->question->get_right_answer_summary();
+    }
+
+    public function adjust_display_options(question_display_options $options) {
+        parent::adjust_display_options($options);
+        if (!$this->qa->get_state()->is_finished() &&
+                $this->qa->get_last_behaviour_var('_try')) {
+            $options->feedback = true;
+        }
+    }
+
+    public function get_state_string($showcorrectness) {
+        $state = $this->qa->get_state();
+
+        $laststep = $this->qa->get_last_step();
+        if ($laststep->has_behaviour_var('_try')) {
+            $state = question_state::graded_state_for_fraction(
+                    $laststep->get_behaviour_var('_rawfraction'));
+        }
+
+        return $state->default_string($showcorrectness);
+    }
+
+    public function process_action(question_attempt_pending_step $pendingstep) {
+        if ($pendingstep->has_behaviour_var('comment')) {
+            return $this->process_comment($pendingstep);
+        } else if ($pendingstep->has_behaviour_var('finish')) {
+            return $this->process_finish($pendingstep);
+        } else if ($pendingstep->has_behaviour_var('submit')) {
+            return $this->process_submit($pendingstep);
+        } else {
+            return $this->process_save($pendingstep);
+        }
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        if ($step->has_behaviour_var('comment')) {
+            return $this->summarise_manual_comment($step);
+        } else if ($step->has_behaviour_var('finish')) {
+            return $this->summarise_finish($step);
+        } else if ($step->has_behaviour_var('submit')) {
+            return $this->summarise_submit($step);
+        } else {
+            return $this->summarise_save($step);
+        }
+    }
+
+    public function process_save(question_attempt_pending_step $pendingstep) {
+        $status = parent::process_save($pendingstep);
+        $prevgrade = $this->qa->get_fraction();
+        if (!is_null($prevgrade)) {
+            $pendingstep->set_fraction($prevgrade);
+        }
+        $pendingstep->set_state(question_state::$todo);
+        return $status;
+    }
+
+    protected function adjusted_fraction($fraction, $prevtries) {
+        return $fraction - $this->question->penalty * $prevtries;
+    }
+
+    public function process_submit(question_attempt_pending_step $pendingstep) {
+        $status = $this->process_save($pendingstep);
+
+        $response = $pendingstep->get_qt_data();
+        if (!$this->question->is_gradable_response($response)) {
+            $pendingstep->set_state(question_state::$invalid);
+            if ($this->qa->get_state() != question_state::$invalid) {
+                $status = question_attempt::KEEP;
+            }
+            return $status;
+        }
+
+        $prevtries = $this->qa->get_last_behaviour_var('_try', 0);
+        $prevbest = $pendingstep->get_fraction();
+        if (is_null($prevbest)) {
+            $prevbest = 0;
+        }
+
+        list($fraction, $state) = $this->question->grade_response($response);
+
+        $pendingstep->set_fraction(max($prevbest, $this->adjusted_fraction($fraction, $prevtries)));
+        if ($state == question_state::$gradedright) {
+            $pendingstep->set_state(question_state::$complete);
+        } else {
+            $pendingstep->set_state(question_state::$todo);
+        }
+        $pendingstep->set_behaviour_var('_try', $prevtries + 1);
+        $pendingstep->set_behaviour_var('_rawfraction', $fraction);
+        $pendingstep->set_new_response_summary($this->question->summarise_response($response));
+
+        return question_attempt::KEEP;
+    }
+
+    public function process_finish(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        $laststep = $this->qa->get_last_step();
+        $response = $laststep->get_qt_data();
+        if (!$this->question->is_gradable_response($response)) {
+            $pendingstep->set_state(question_state::$gaveup);
+            return question_attempt::KEEP;
+        }
+
+        $prevtries = $this->qa->get_last_behaviour_var('_try', 0);
+        $prevbest = $pendingstep->get_fraction();
+        if (is_null($prevbest)) {
+            $prevbest = 0;
+        }
+
+        if ($laststep->has_behaviour_var('_try')) {
+            // Last answer was graded, we want to regrade it. Otherwise the answer
+            // has changed, and we are grading a new try.
+            $prevtries -= 1;
+        }
+
+        list($fraction, $state) = $this->question->grade_response($response);
+
+        $pendingstep->set_fraction(max($prevbest, $this->adjusted_fraction($fraction, $prevtries)));
+        $pendingstep->set_state($state);
+        $pendingstep->set_behaviour_var('_try', $prevtries + 1);
+        $pendingstep->set_behaviour_var('_rawfraction', $fraction);
+        $pendingstep->set_new_response_summary($this->question->summarise_response($response));
+        return question_attempt::KEEP;
+    }
+}
diff --git a/question/behaviour/adaptive/lang/en_utf8/qbehaviour_adaptive.php b/question/behaviour/adaptive/lang/en_utf8/qbehaviour_adaptive.php
new file mode 100644 (file)
index 0000000..2de0227
--- /dev/null
@@ -0,0 +1,6 @@
+<?php
+
+$string['adaptive'] = 'Adaptive mode';
+$string['gradingdetails'] = 'Marks for this submission: $a->raw/$a->max.';
+$string['gradingdetailsadjustment'] = 'With previous penalties this gives <strong>$a->cur/$a->max</strong>.';
+$string['gradingdetailspenalty'] = 'This submission attracted a penalty of $a.';
diff --git a/question/behaviour/adaptive/renderer.php b/question/behaviour/adaptive/renderer.php
new file mode 100644 (file)
index 0000000..7393d63
--- /dev/null
@@ -0,0 +1,98 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the legacy
+ * adaptive behaviour.
+ *
+ * @package qbehaviour_adaptive
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+class qbehaviour_adaptive_renderer extends qbehaviour_renderer {
+    protected function get_graded_step(question_attempt $qa) {
+        foreach ($qa->get_reverse_step_iterator() as $step) {
+            if ($step->has_behaviour_var('_try')) {
+                return $step;
+            }
+        }
+    }
+
+    public function controls(question_attempt $qa, question_display_options $options) {
+        return $this->submit_button($qa, $options);
+    }
+
+    public function feedback(question_attempt $qa, question_display_options $options) {
+        // Try to find the last graded step.
+
+        $gradedstep = $this->get_graded_step($qa);
+        if (is_null($gradedstep) || $qa->get_max_mark() == 0 || !$options->marks) {
+            return '';
+        }
+
+        // Display the grading details from the last graded state
+        $mark = new stdClass;
+        $mark->max = $qa->format_max_mark($options->markdp);
+
+        $actualmark = $gradedstep->get_fraction() * $qa->get_max_mark();
+        $mark->cur = format_float($actualmark, $options->markdp);
+
+        $rawmark = $gradedstep->get_behaviour_var('_rawfraction') * $qa->get_max_mark();
+        $mark->raw = format_float($rawmark, $options->markdp);
+
+        // let student know wether the answer was correct
+        if ($qa->get_state()->is_commented()) {
+            $class = $qa->get_state()->get_feedback_class();
+        } else {
+            $class = question_state::graded_state_for_fraction(
+                    $gradedstep->get_behaviour_var('_rawfraction'))->get_feedback_class();
+        }
+
+        $gradingdetails = get_string('gradingdetails', 'qbehaviour_adaptive', $mark);
+
+        $gradingdetails .= $this->penalty_info($qa, $mark);
+
+        $output = '';
+        $output .= html_writer::tag('div', get_string($class, 'question'),
+                array('class' => 'correctness ' . $class));
+        $output .= html_writer::tag('div', $gradingdetails,
+                array('class' => 'gradingdetails'));
+        return $output;
+    }
+
+    protected function penalty_info($qa, $mark) {
+        if (!$qa->get_question()->penalty) {
+            return '';
+        }
+        $output = '';
+
+        // print details of grade adjustment due to penalties
+        if ($mark->raw != $mark->cur) {
+            $output .= ' ' . get_string('gradingdetailsadjustment', 'qbehaviour_adaptive', $mark);
+        }
+
+        // print info about new penalty
+        // penalty is relevant only if the answer is not correct and further attempts are possible
+        if (!$qa->get_state()->is_finished()) {
+            $output .= ' ' . get_string('gradingdetailspenalty', 'qbehaviour_adaptive', $qa->get_question()->penalty);
+        }
+
+        return $output;
+    }
+}
diff --git a/question/behaviour/adaptive/simpletest/testwalkthrough.php b/question/behaviour/adaptive/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..c097f3d
--- /dev/null
@@ -0,0 +1,239 @@
+<?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 that walks a question through the adaptive
+ * behaviour.
+ *
+ * @package qbehaviour_adaptive
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_adaptive_walkthrough_test extends qbehaviour_walkthrough_test_base {
+    public function test_adaptive_multichoice() {
+
+        // Create a multiple choice, single response question.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $mc->penalty = 0.3333333;
+        $this->start_attempt_at_question($mc, 'adaptive', 3);
+
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process a submit.
+        $this->process_submission(array('answer' => $wrongindex, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(0);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0),
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 2) % 3, true, false),
+                $this->get_contains_incorrect_expectation());
+        $this->assertPattern('/B|C/',
+                $this->quba->get_response_summary($this->slot));
+
+        // Process a change of answer to the right one, but not sumbitted.
+        $this->process_submission(array('answer' => $rightindex));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(0);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0),
+                $this->get_contains_mc_radio_expectation($rightindex, true, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, true, false));
+        $this->assertPattern('/B|C/',
+                $this->quba->get_response_summary($this->slot));
+
+        // Now submit the right answer.
+        $this->process_submission(array('answer' => $rightindex, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(3 * (1 - $mc->penalty));
+        $this->check_current_output(
+                $this->get_contains_mark_summary(3 * (1 - $mc->penalty)),
+                $this->get_contains_mc_radio_expectation($rightindex, true, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, true, false),
+                $this->get_contains_correct_expectation());
+        $this->assertEqual('A',
+                $this->quba->get_response_summary($this->slot));
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(3 * (1 - $mc->penalty));
+        $this->check_current_output(
+                $this->get_contains_mark_summary(3 * (1 - $mc->penalty)),
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, false, false),
+                $this->get_contains_correct_expectation());
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 1);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(1),
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+
+        // Now change the correct answer to the question, and regrade.
+        $mc->answers[13]->fraction = -0.33333333;
+        $mc->answers[15]->fraction = 1;
+        $this->quba->regrade_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(1),
+                $this->get_contains_partcorrect_expectation());
+
+        $autogradedstep = $this->get_step($this->get_step_count() - 2);
+        $this->assertWithinMargin($autogradedstep->get_fraction(), 0, 0.0000001);
+    }
+
+    public function test_adaptive_multichoice2() {
+
+        // Create a multiple choice, multiple response question.
+        $mc = test_question_maker::make_a_multichoice_multi_question();
+        $mc->penalty = 0.3333333;
+        $mc->shuffleanswers = 0;
+        $this->start_attempt_at_question($mc, 'adaptive', 2);
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process a submit.
+        $this->process_submission(array('choice0' => 1, 'choice2' => 1, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(2);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(2),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_contains_correct_expectation());
+
+        // Save the same correct answer again. Should no do anything.
+        $numsteps = $this->get_step_count();
+        $this->process_submission(array('choice0' => 1, 'choice2' => 1));
+
+        // Verify.
+        $this->check_step_count($numsteps);
+        $this->check_current_state(question_state::$complete);
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_step_count($numsteps + 1);
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(2);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(2),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_correct_expectation());
+    }
+
+    public function test_adaptive_shortanswer_try_to_submit_blank() {
+
+        // Create a short answer question with correct answer true.
+        $sa = test_question_maker::make_a_shortanswer_question();
+        $this->start_attempt_at_question($sa, 'adaptive');
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Submit with blank answer.
+        $this->process_submission(array('-submit' => 1, 'answer' => ''));
+
+        // Verify.
+        $this->check_current_state(question_state::$invalid);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_contains_validation_error_expectation());
+        $this->assertNull($this->quba->get_response_summary($this->slot));
+
+        // Now get it wrong.
+        $this->process_submission(array('-submit' => 1, 'answer' => 'toad'));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(0.8);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0.8),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_contains_partcorrect_expectation(),
+                $this->get_does_not_contain_validation_error_expectation());
+
+
+        // Now submit blank again.
+        $this->process_submission(array('-submit' => 1, 'answer' => ''));
+
+        // Verify.
+        $this->check_current_state(question_state::$invalid);
+        $this->check_current_mark(0.8);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0.8),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_contains_partcorrect_expectation(),
+                $this->get_contains_validation_error_expectation());
+    }
+}
diff --git a/question/behaviour/adaptivenopenalty/behaviour.php b/question/behaviour/adaptivenopenalty/behaviour.php
new file mode 100644 (file)
index 0000000..9a6dee2
--- /dev/null
@@ -0,0 +1,43 @@
+<?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/>.
+
+
+/**
+ * Question behaviour for the old adaptive mode, with no penalties.
+ *
+ * @package qbehaviour_adaptivenopenalty
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(dirname(__FILE__) . '/../adaptive/behaviour.php');
+
+/**
+ * Question behaviour for adaptive mode, with no penalties.
+ *
+ * This is the old version of interactive mode, without penalties.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_adaptivenopenalty extends qbehaviour_adaptive {
+    const IS_ARCHETYPAL = true;
+
+    protected function adjusted_fraction($fraction, $prevtries) {
+        return $fraction;
+    }
+}
diff --git a/question/behaviour/adaptivenopenalty/lang/en_utf8/qbehaviour_adaptivenopenalty.php b/question/behaviour/adaptivenopenalty/lang/en_utf8/qbehaviour_adaptivenopenalty.php
new file mode 100644 (file)
index 0000000..a785810
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['adaptivenopenalty'] = 'Adaptive mode (no penalties)';
diff --git a/question/behaviour/adaptivenopenalty/renderer.php b/question/behaviour/adaptivenopenalty/renderer.php
new file mode 100644 (file)
index 0000000..cd02a10
--- /dev/null
@@ -0,0 +1,34 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the legacy
+ * adaptive (no penalties) behaviour.
+ *
+ * @package qbehaviour_adaptivenopenalty
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(dirname(__FILE__) . '/../adaptive/renderer.php');
+
+class qbehaviour_adaptivenopenalty_renderer extends qbehaviour_adaptive_renderer {
+    protected function penalty_info($qa, $mark) {
+        return '';
+    }
+}
diff --git a/question/behaviour/adaptivenopenalty/simpletest/testwalkthrough.php b/question/behaviour/adaptivenopenalty/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..fe9cdef
--- /dev/null
@@ -0,0 +1,186 @@
+<?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 that walks a question through the adaptive (no penalties)k
+ * behaviour.
+ *
+ * @package qbehaviour_adaptivenopenalty
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_adaptivenopenalty_walkthrough_test extends qbehaviour_walkthrough_test_base {
+    public function test_multichoice() {
+
+        // Create a multiple choice, single response question.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $mc->penalty = 0.3333333;
+        $this->start_attempt_at_question($mc, 'adaptivenopenalty', 3);
+
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process a submit.
+        $this->process_submission(array('answer' => $wrongindex, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(0);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0),
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 2) % 3, true, false),
+                $this->get_contains_incorrect_expectation());
+        $this->assertPattern('/B|C/',
+                $this->quba->get_response_summary($this->slot));
+
+        // Process a change of answer to the right one, but not sumbitted.
+        $this->process_submission(array('answer' => $rightindex));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(0);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0),
+                $this->get_contains_mc_radio_expectation($rightindex, true, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, true, false));
+        $this->assertPattern('/B|C/',
+                $this->quba->get_response_summary($this->slot));
+
+        // Now submit the right answer.
+        $this->process_submission(array('answer' => $rightindex, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(3);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(3),
+                $this->get_contains_mc_radio_expectation($rightindex, true, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, true, false),
+                $this->get_contains_correct_expectation());
+        $this->assertEqual('A',
+                $this->quba->get_response_summary($this->slot));
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(3);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(3),
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 2) % 3, false, false),
+                $this->get_contains_correct_expectation());
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 1);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(1),
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+
+        // Now change the correct answer to the question, and regrade.
+        $mc->answers[13]->fraction = -0.33333333;
+        $mc->answers[15]->fraction = 1;
+        $this->quba->regrade_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(1),
+                $this->get_contains_partcorrect_expectation());
+
+        $autogradedstep = $this->get_step($this->get_step_count() - 2);
+        $this->assertWithinMargin($autogradedstep->get_fraction(), 0, 0.0000001);
+    }
+
+    public function test_multichoice2() {
+
+        // Create a multiple choice, multiple response question.
+        $mc = test_question_maker::make_a_multichoice_multi_question();
+        $mc->penalty = 0.3333333;
+        $mc->shuffleanswers = 0;
+        $this->start_attempt_at_question($mc, 'adaptivenopenalty', 2);
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process a submit.
+        $this->process_submission(array('choice0' => 1, 'choice2' => 1, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(2);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(2),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_contains_correct_expectation());
+
+        // Save the same correct answer again. Should no do anything.
+        $numsteps = $this->get_step_count();
+        $this->process_submission(array('choice0' => 1, 'choice2' => 1));
+
+        // Verify.
+        $this->check_step_count($numsteps);
+        $this->check_current_state(question_state::$complete);
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_step_count($numsteps + 1);
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(2);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(2),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_correct_expectation());
+    }
+}
diff --git a/question/behaviour/behaviourbase.php b/question/behaviour/behaviourbase.php
new file mode 100644 (file)
index 0000000..8b17343
--- /dev/null
@@ -0,0 +1,631 @@
+<?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/>.
+
+
+/**
+ * Defines the quetsion behaviour base class
+ *
+ * @package moodlecore
+ * @subpackage questionbehaviours
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * The base class for question behaviours.
+ *
+ * A question behaviour is used by the question engine, specifically by
+ * a {@link question_attempt} to manage the flow of actions a student can take
+ * as they work through a question, and later, as a teacher manually grades it.
+ * In turn, the behaviour will delegate certain processing to the
+ * relevant {@link question_definition}.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class question_behaviour {
+    /**
+     * Certain behaviours are definitive of a  way that questions can
+     * behave when attempted. For example deferredfeedback model, interactive
+     * model, etc. These are the options that should be listed in the
+     * user-interface. These models should define the class constant
+     * IS_ARCHETYPAL as true. Other models are more implementation details, for
+     * example the informationitem model, or a special subclass like
+     * interactive_adapted_for_my_qtype. These models should IS_ARCHETYPAL as
+     * false.
+     * @var boolean
+     */
+    const IS_ARCHETYPAL = false;
+
+    /** @var question_attempt the question attempt we are managing. */
+    protected $qa;
+    /** @var question_definition shortcut to $qa->get_question(). */
+    protected $question;
+
+    /**
+     * Normally you should not call this constuctor directly. The appropriate
+     * behaviour object is created automatically as part of
+     * {@link question_attempt::start()}.
+     * @param question_attempt $qa the question attempt we will be managing.
+     * @param string $preferredbehaviour the type of behaviour that was actually
+     *      requested. This information is not needed in most cases, the type of
+     *      subclass is enough, but occasionally it is needed.
+     */
+    public function __construct(question_attempt $qa, $preferredbehaviour) {
+        $this->qa = $qa;
+        $this->question = $qa->get_question();
+        $requiredclass = $this->required_question_definition_type();
+        if (!$this->question instanceof $requiredclass) {
+            throw new Exception('This behaviour (' . $this->get_name() .
+                    ') cannot work with this question (' . get_class($this->question) . ')');
+        }
+    }
+
+    /**
+     * Most behaviours can only work with {@link question_definition}s
+     * of a particular subtype, or that implement a particular interface.
+     * This method lets the behaviour document that. The type of
+     * question passed to the constructor is then checked against this type.
+     * @return string class/interface name.
+     */
+    public abstract function required_question_definition_type();
+
+    /**
+     * @return string the name of this behaviour. For example the name of
+     * qbehaviour_mymodle is 'mymodel'.
+     */
+    public function get_name() {
+        return substr(get_class($this), 11);
+    }
+
+    /**
+     * 'Override' this method if there are some display options that do not make
+     * sense 'during the attempt'.
+     * @return array of {@link question_display_options} field names, that are
+     * not relevant to this behaviour before a 'finish' action.
+     */
+    public static function get_unused_display_options() {
+        return array();
+    }
+
+    /**
+     * Cause the question to be renderered. This gets the appropriate behaviour
+     * renderer using {@link get_renderer()}, and adjusts the display
+     * options using {@link adjust_display_options()} and then calls
+     * {@link core_question_renderer::question()} to do the work.
+     * @param question_display_options $options controls what should and should not be displayed.
+     * @param unknown_type $number the question number to display.
+     * @param core_question_renderer $qoutput the question renderer that will coordinate everything.
+     * @param qtype_renderer $qtoutput the question type renderer that will be helping.
+     * @return HTML fragment.
+     */
+    public function render(question_display_options $options, $number,
+            core_question_renderer $qoutput, qtype_renderer $qtoutput) {
+        $behaviouroutput = $this->get_renderer();
+        $options = clone($options);
+        $this->adjust_display_options($options);
+        return $qoutput->question($this->qa, $behaviouroutput, $qtoutput, $options, $number);
+    }
+
+    /**
+     * @return qbehaviour_renderer get the appropriate renderer to use for this model.
+     */
+    public function get_renderer() {
+        return renderer_factory::get_renderer(get_class($this));
+    }
+
+    /**
+     * Make any changes to the display options before a question is rendered, so
+     * that it can be displayed in a way that is appropriate for the statue it is
+     * currently in. For example, by default, if the question is finished, we
+     * ensure that it is only ever displayed read-only.
+     * @param question_display_options $options the options to adjust. Just change
+     * the properties of this object - objects are passed by referece.
+     */
+    public function adjust_display_options(question_display_options $options) {
+        if (!$this->qa->has_marks()) {
+            $options->correctness = false;
+            $options->numpartscorrect = false;
+        }
+        if ($this->qa->get_state()->is_finished()) {
+            $options->readonly = true;
+            $options->numpartscorrect = $options->numpartscorrect &&
+                    $this->qa->get_state()->is_partially_correct() &&
+                    !empty($this->question->shownumcorrect);
+        } else {
+            $options->hide_all_feedback();
+        }
+    }
+
+    /**
+     * Get the most applicable hint for the question in its current state.
+     * @return question_hint the most applicable hint, or null, if none.
+     */
+    public function get_applicable_hint() {
+        return null;
+    }
+
+    /**
+     * What is the minimum fraction that can be scored for this question.
+     * Normally this will be based on $this->question->init_first_step($step),
+     * but may be modified in some way by the model.
+     *
+     * @return number the minimum fraction when this question is attempted under
+     * this model.
+     */
+    public function get_min_fraction() {
+        return 0;
+    }
+
+    /**
+     * Adjust a random guess score for a question using this model. You have to
+     * do this without knowing details of the specific question, or which usage
+     * it is in.
+     * @param number $fraction the random guess score from the question type.
+     * @return number the adjusted fraction.
+     */
+    public static function adjust_random_guess_score($fraction) {
+        return $fraction;
+    }
+
+    /**
+     * Return an array of the behaviour variables that could be submitted
+     * as part of a question of this type, with their types, so they can be
+     * properly cleaned.
+     * @return array variable name => PARAM_... constant.
+     */
+    public function get_expected_data() {
+        if (!$this->qa->get_state()->is_finished()) {
+            return array();
+        }
+
+        $vars = array('comment' => PARAM_RAW);
+        if ($this->qa->get_max_mark()) {
+            $vars['mark'] = question_attempt::PARAM_MARK;
+            $vars['maxmark'] = PARAM_NUMBER;
+        }
+        return $vars;
+    }
+
+    /**
+     * Return an array of question type variables for the question in its current
+     * state. Normally, if {@link adjust_display_options()} would set
+     * {@link question_display_options::$readonly} to true, then this method
+     * should return an empty array, otherwise it should return
+     * $this->question->get_expected_data(). Thus, there should be little need to
+     * override this method.
+     * @return array|string variable name => PARAM_... constant, or, as a special case
+     *      that should only be used in unavoidable, the constant question_attempt::USE_RAW_DATA
+     *      meaning take all the raw submitted data belonging to this question.
+     */
+    public function get_expected_qt_data() {
+        $fakeoptions = new question_display_options();
+        $fakeoptions->readonly = false;
+        $this->adjust_display_options($fakeoptions);
+        if ($fakeoptions->readonly) {
+            return array();
+        } else {
+            return $this->question->get_expected_data();
+        }
+    }
+
+    /**
+     * Return an array of any im variables, and the value required to get full
+     * marks.
+     * @return array variable name => value.
+     */
+    public function get_correct_response() {
+        return array();
+    }
+
+    /**
+     * Generate a brief, plain-text, summary of this question. This is used by
+     * various reports. This should show the particular variant of the question
+     * as presented to students. For example, the calculated quetsion type would
+     * fill in the particular numbers that were presented to the student.
+     * This method will return null if such a summary is not possible, or
+     * inappropriate.
+     *
+     * Normally, this method delegates to {question_definition::get_question_summary()}.
+     *
+     * @return string|null a plain text summary of this question.
+     */
+    public function get_question_summary() {
+        return $this->question->get_question_summary();
+    }
+
+    /**
+     * Generate a brief, plain-text, summary of the correct answer to this question.
+     * This is used by various reports, and can also be useful when testing.
+     * This method will return null if such a summary is not possible, or
+     * inappropriate.
+     *
+     * @return string|null a plain text summary of the right answer to this question.
+     */
+    public function get_right_answer_summary() {
+        return null;
+    }
+
+    /**
+     * Used by {@link start_based_on()} to get the data needed to start a new
+     * attempt from the point this attempt has go to.
+     * @return array name => value pairs.
+     */
+    public function get_resume_data() {
+        $olddata = $this->qa->get_step(0)->get_all_data();
+        $olddata = $this->qa->get_last_qt_data() + $olddata;
+        $olddata = $this->get_our_resume_data() + $olddata;
+        return $olddata;
+    }
+
+    /**
+     * Used by {@link start_based_on()} to get the data needed to start a new
+     * attempt from the point this attempt has go to.
+     * @return unknown_type
+     */
+    protected function get_our_resume_data() {
+        return array();
+    }
+
+    /**
+     * @return array subpartid => object with fields
+     *      ->responseclassid the 
+     *      ->response the actual response the student gave to this part, as a string.
+     *      ->fraction the credit awarded for this subpart, may be null.
+     *      returns an empty array if no analysis is possible.
+     */
+    public function classify_response() {
+        return $this->question->classify_response($this->qa->get_last_qt_data());
+    }
+
+    /**
+     * Generate a brief textual description of the current state of the question,
+     * normally displayed under the question number.
+     *
+     * @param boolean $showcorrectness Whether right/partial/wrong states should
+     * be distinguised.
+     * @return string a brief summary of the current state of the qestion attempt.
+     */
+    public function get_state_string($showcorrectness) {
+        return $this->qa->get_state()->default_string($showcorrectness);
+    }
+
+    abstract public function summarise_action(question_attempt_step $step);
+
+    /**
+     * Initialise the first step in a question attempt.
+     *
+     * This method must call $this->question->init_first_step($step), and may
+     * perform additional processing if the model requries it.
+     *
+     * @param question_attempt_step $step the step being initialised.
+     */
+    public function init_first_step(question_attempt_step $step) {
+        $this->question->init_first_step($step);
+    }
+
+    /**
+     * Checks whether two manual grading actions are the same. That is, whether
+     * the comment, and the mark (if given) is the same.
+     *
+     * @param question_attempt_step $pendingstep contains the new responses.
+     * @return boolean whether the new response is the same as we already have.
+     */
+    protected function is_same_comment($pendingstep) {
+        $previouscomment = $this->qa->get_last_behaviour_var('comment');
+        $newcomment = $pendingstep->get_behaviour_var('comment');
+
+        if (is_null($previouscomment) && !html_is_blank($newcomment) ||
+                $previouscomment != $newcomment) {
+            return false;
+        }
+
+        // So, now we know the comment is the same, so check the mark, if present.
+        $previousfraction = $this->qa->get_fraction();
+        $newmark = $pendingstep->get_behaviour_var('mark');
+
+        if (is_null($previousfraction)) {
+            return is_null($newmark) || $newmark === '';
+        } else if (is_null($newmark) || $newmark === '') {
+            return false;
+        }
+
+        $newfraction = $newmark / $pendingstep->get_behaviour_var('maxmark');
+
+        return abs($newfraction - $previousfraction) < 0.0000001;
+    }
+
+    /**
+     * The main entry point for processing an action.
+     *
+     * All the various operations that can be performed on a
+     * {@link question_attempt} get channeled through this function, except for
+     * {@link question_attempt::start()} which goes to {@link init_first_step()}.
+     * {@link question_attempt::finish()} becomes an action with im vars
+     * finish => 1, and manual comment/grade becomes an action with im vars
+     * comment => comment text, and mark => ..., max_mark => ... if the question
+     * is graded.
+     *
+     * This method should first determine whether the action is significant. For
+     * example, if no actual action is being performed, but instead the current
+     * responses are being saved, and there has been no change since the last
+     * set of responses that were saved, this the action is not significatn. In
+     * this case, this method should return {@link question_attempt::DISCARD}.
+     * Otherwise it should return {@link question_attempt::KEEP}.
+     *
+     * If the action is significant, this method should also perform any
+     * necessary updates to $pendingstep. For example, it should call
+     * {@link question_attempt_step::set_state()} to set the state that results
+     * from this action, and if this is a grading action, it should call
+     * {@link question_attempt_step::set_fraction()}.
+     *
+     * This method can also call {@link question_attempt_step::set_behaviour_var()} to
+     * store additional infomation. There are two main uses for this. This can
+     * be used to store the result of any randomisation done. It is important to
+     * store the result of randomisation once, and then in future use the same
+     * outcome if the actions are ever replayed. This is how regrading works.
+     * The other use is to cache the result of expensive computations performed
+     * on the raw response data, so that subsequent display and review of the
+     * question does not have to repeat the same expensive computations.
+     *
+     * Often this method is implemented as a dispatching method that examines
+     * the pending step to determine the kind of action being performed, and
+     * then calls a more specific method like {@link process_save()} or
+     * {@link process_comment()}. Look at some of the standard behaviours
+     * for examples.
+     *
+     * @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 boolean either {@link question_attempt::KEEP} or {@link question_attempt::DISCARD}
+     */
+    public abstract function process_action(question_attempt_pending_step $pendingstep);
+
+    /**
+     * Implementation of processing a manual comment/grade action that should
+     * be suitable for most subclasses.
+     * @param question_attempt_pending_step $pendingstep a partially initialised step
+     *      containing all the information about the action that is being peformed.
+     * @return boolean either {@link question_attempt::KEEP}
+     */
+    public function process_comment(question_attempt_pending_step $pendingstep) {
+        if (!$this->qa->get_state()->is_finished()) {
+            throw new coding_exception('Cannot manually grade a question before it is finshed.');
+        }
+
+        if ($this->is_same_comment($pendingstep)) {
+            return question_attempt::DISCARD;
+        }
+
+        if ($pendingstep->has_behaviour_var('mark')) {
+            $fraction = $pendingstep->get_behaviour_var('mark') / $pendingstep->get_behaviour_var('maxmark');
+            if ($pendingstep->get_behaviour_var('mark') === '') {
+                $fraction = null;
+            } else if ($fraction > 1 || $fraction < $this->qa->get_min_fraction()) {
+                throw new coding_exception('Score out of range when processing ' .
+                        'a manual grading action.', $pendingstep);
+            }
+            $pendingstep->set_fraction($fraction);
+        }
+
+        $pendingstep->set_state($this->qa->get_state()->
+                corresponding_commented_state($pendingstep->get_fraction()));
+        return question_attempt::KEEP;
+    }
+
+    /**
+     * @param $comment the comment text to format. If omitted,
+     *      $this->qa->get_manual_comment() is used.
+     * @return string the comment, ready to be output.
+     */
+    public function format_comment($comment = null) {
+        $formatoptions = new stdClass;
+        $formatoptions->noclean = true;
+        $formatoptions->para = false;
+
+        if (is_null($comment)) {
+            $comment = $this->qa->get_manual_comment();
+        }
+
+        return format_text($comment, FORMAT_HTML, $formatoptions);
+    }
+
+    /**
+     * @return string a summary of a manual comment action.
+     * @param unknown_type $step
+     */
+    protected function summarise_manual_comment($step) {
+        $a = new stdClass;
+        if ($step->has_behaviour_var('comment')) {
+            $a->comment = shorten_text(html_to_text($this->format_comment(
+                    $step->get_behaviour_var('comment')), 0, false), 200);
+        } else {
+            $a->comment = '';
+        }
+
+        $mark = $step->get_behaviour_var('mark');
+        if (is_null($mark) || $mark === '') {
+            return get_string('commented', 'question', $a->comment);
+        } else {
+            $a->mark = $mark / $step->get_behaviour_var('maxmark') * $this->qa->get_max_mark();
+            return get_string('manuallygraded', 'question', $a);
+        }
+    }
+
+    public function summarise_start($step) {
+        return get_string('started', 'question');
+    }
+
+    public function summarise_finish($step) {
+        return get_string('attemptfinished', 'question');
+    }
+}
+
+
+/**
+ * A subclass of {@link question_behaviour} that implements a save
+ * action that is suitable for most questions that implement the
+ * {@link question_manually_gradable} interface.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class question_behaviour_with_save extends question_behaviour {
+    public function required_question_definition_type() {
+        return 'question_manually_gradable';
+    }
+
+    /**
+     * Work out whether the response in $pendingstep are significantly different
+     * from the last set of responses we have stored.
+     * @param question_attempt_step $pendingstep contains the new responses.
+     * @return boolean whether the new response is the same as we already have.
+     */
+    protected function is_same_response(question_attempt_step $pendingstep) {
+        return $this->question->is_same_response(
+                $this->qa->get_last_step()->get_qt_data(), $pendingstep->get_qt_data());
+    }
+
+    /**
+     * Work out whether the response in $pendingstep represent a complete answer
+     * to the question. Normally this will call
+     * {@link question_manually_gradable::is_complete_response}, but some
+     * behaviours, for example the CBM ones, have their own parts to the
+     * response.
+     * @param question_attempt_step $pendingstep contains the new responses.
+     * @return boolean whether the new response is complete.
+     */
+    protected function is_complete_response(question_attempt_step $pendingstep) {
+        return $this->question->is_complete_response($pendingstep->get_qt_data());
+    }
+
+    /**
+     * Implementation of processing a save action that should be suitable for
+     * most subclasses.
+     * @param question_attempt_pending_step $pendingstep a partially initialised step
+     *      containing all the information about the action that is being peformed.
+     * @return boolean either {@link question_attempt::KEEP} or {@link question_attempt::DISCARD}
+     */
+    public function process_save(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        } else if (!$this->qa->get_state()->is_active()) {
+            throw new Exception('Question is not active, cannot process_actions.');
+        }
+
+        if ($this->is_same_response($pendingstep)) {
+            return question_attempt::DISCARD;
+        }
+
+        if ($this->is_complete_response($pendingstep)) {
+            $pendingstep->set_state(question_state::$complete);
+        } else {
+            $pendingstep->set_state(question_state::$todo);
+        }
+        return question_attempt::KEEP;
+    }
+
+    public function summarise_submit(question_attempt_step $step) {
+        return get_string('submitted', 'question',
+                $this->question->summarise_response($step->get_qt_data()));
+    }
+
+    public function summarise_save(question_attempt_step $step) {
+        $data = $step->get_submitted_data();
+        if (empty($data)) {
+            return $this->summarise_start($step);
+        }
+        return get_string('saved', 'question',
+                $this->question->summarise_response($step->get_qt_data()));
+    }
+
+
+    public function summarise_finish($step) {
+        $data = $step->get_qt_data();
+        if ($data) {
+            return get_string('attemptfinishedsubmitting', 'question',
+                    $this->question->summarise_response($data));
+        }
+        return get_string('attemptfinished', 'question');
+    }
+}
+
+
+/**
+ * This helper class contains the constants and methods required for
+ * manipulating scores for certainly based marking.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class question_cbm {
+    /**#@+ @var integer named constants for the certainty levels. */
+    const LOW = 1;
+    const MED = 2;
+    const HIGH = 3;
+    /**#@-*/
+
+    /** @var array list of all the certainty levels. */
+    public static $certainties = array(self::LOW, self::MED, self::HIGH);
+
+    /**#@+ @var array coefficients used to adjust the fraction based on certainty.. */
+    protected static $factor = array(
+        self::LOW => 0.333333333333333,
+        self::MED => 1.333333333333333,
+        self::HIGH => 3,
+    );
+    protected static $offset = array(
+        self::LOW => 0,
+        self::MED => -0.666666666666667,
+        self::HIGH => -2,
+    );
+    /**#@-*/
+
+    /**
+     * @return integer the default certaintly level that should be assuemd if
+     * the student does not choose one.
+     */
+    public static function default_certainty() {
+        return self::LOW;
+    }
+
+    /**
+     * Given a fraction, and a certainly, compute the adjusted fraction.
+     * @param number $fraction the raw fraction for this question.
+     * @param integer $certainty one of the certainly level constants.
+     * @return number the adjusted fraction taking the certainly into account.
+     */
+    public static function adjust_fraction($fraction, $certainty) {
+        return self::$offset[$certainty] + self::$factor[$certainty] * $fraction;
+    }
+
+    /**
+     * @param integer $certainty one of the LOW/MED/HIGH constants.
+     * @return string a textual desciption of this certainly.
+     */
+    public static function get_string($certainty) {
+        return get_string('certainty' . $certainty, 'qbehaviour_deferredcbm');
+    }
+
+    public static function summary_with_certainty($summary, $certainty) {
+        if (is_null($certainty)) {
+            return $summary;
+        }
+        return $summary . ' [' . self::get_string($certainty) . ']';
+    }
+}
diff --git a/question/behaviour/deferredcbm/behaviour.php b/question/behaviour/deferredcbm/behaviour.php
new file mode 100644 (file)
index 0000000..02c4600
--- /dev/null
@@ -0,0 +1,125 @@
+<?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/>.
+
+
+/**
+ * Question behaviour that is like the deferred feedback model, but with
+ * certainly based marking. That is, in addition to the other controls, there are
+ * where the student can indicate how certain they are that their answer is right.
+ *
+ * @package qbehaviour_deferredcbm
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../deferredfeedback/behaviour.php');
+
+/**
+ * Question behaviour for deferred feedback with certainty based marking.
+ *
+ * The student enters their response during the attempt, along with a certainty,
+ * that is, how sure they are that they are right, and it is saved. Later,
+ * when the whole attempt is finished, their answer is graded. Their degree
+ * of certainty affects their score.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_deferredcbm extends qbehaviour_deferredfeedback {
+    const IS_ARCHETYPAL = true;
+
+    public static function get_unused_display_options() {
+        return array('correctness', 'marks', 'specificfeedback', 'generalfeedback',
+                'rightanswer');
+    }
+
+    public function get_min_fraction() {
+        return question_cbm::adjust_fraction(parent::get_min_fraction(), question_cbm::HIGH);
+    }
+
+    public function get_expected_data() {
+        if ($this->qa->get_state()->is_active()) {
+            return array('certainty' => PARAM_INT);
+        }
+        return parent::get_expected_data();
+    }
+
+    public function get_right_answer_summary() {
+        $summary = parent::get_right_answer_summary();
+        return $summary . ' [' . question_cbm::get_string(question_cbm::HIGH) . ']';
+    }
+
+    public function get_correct_response() {
+        if ($this->qa->get_state()->is_active()) {
+            return array('certainty' => question_cbm::HIGH);
+        }
+        return array();
+    }
+
+    protected function get_our_resume_data() {
+        $lastcertainty = $this->qa->get_last_behaviour_var('certainty');
+        if ($lastcertainty) {
+            return array('-certainty' => $lastcertainty);
+        } else {
+            return array();
+        }
+    }
+
+    protected function is_same_response($pendingstep) {
+        return parent::is_same_response($pendingstep) &&
+                $this->qa->get_last_behaviour_var('certainty') == $pendingstep->get_behaviour_var('certainty');
+    }
+
+    protected function is_complete_response($pendingstep) {
+        return parent::is_complete_response($pendingstep) && $pendingstep->has_behaviour_var('certainty');
+    }
+
+    public function process_finish(question_attempt_pending_step $pendingstep) {
+        $status = parent::process_finish($pendingstep);
+        if ($status == question_attempt::KEEP) {
+            $fraction = $pendingstep->get_fraction();
+            if ($this->qa->get_last_step()->has_behaviour_var('certainty')) {
+                $certainty = $this->qa->get_last_step()->get_behaviour_var('certainty');
+            } else {
+                $certainty = question_cbm::default_certainty();
+                $pendingstep->set_behaviour_var('_assumedcertainty', $certainty);
+            }
+            if (!is_null($fraction)) {
+                $pendingstep->set_behaviour_var('_rawfraction', $fraction);
+                $pendingstep->set_fraction(question_cbm::adjust_fraction($fraction, $certainty));
+            }
+            $pendingstep->set_new_response_summary(
+                    question_cbm::summary_with_certainty($pendingstep->get_new_response_summary(),
+                    $this->qa->get_last_step()->get_behaviour_var('certainty')));
+        }
+        return $status;
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        $summary = parent::summarise_action($step);
+        if ($step->has_behaviour_var('certainty')) {
+            $summary = question_cbm::summary_with_certainty($summary,
+                    $step->get_behaviour_var('certainty'));
+        }
+        return $summary;
+    }
+
+    public static function adjust_random_guess_score($fraction) {
+        return question_cbm::adjust_fraction($fraction, question_cbm::default_certainty());
+    }
+}
diff --git a/question/behaviour/deferredcbm/lang/en_utf8/qbehaviour_deferredcbm.php b/question/behaviour/deferredcbm/lang/en_utf8/qbehaviour_deferredcbm.php
new file mode 100644 (file)
index 0000000..d065d5e
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+$string['assumingcertainty'] = 'You did not select a certainty. Assuming: {$a}.';
+$string['certainty1'] = 'Not very (less than 67%%)';
+$string['certainty2'] = 'Fairly (more than 67%%)';
+$string['certainty3'] = 'Very (more than 85%%)';
+$string['deferredcbm'] = 'Deferred feedback with CBM';
+$string['howcertainareyou'] = 'How certain are you? $a';
+$string['markadjustment'] = 'Based on the certainty you expressed, your base mark of {$a->rawmark} was adjusted to {$a->mark}.';
diff --git a/question/behaviour/deferredcbm/renderer.php b/question/behaviour/deferredcbm/renderer.php
new file mode 100644 (file)
index 0000000..2f5f6e6
--- /dev/null
@@ -0,0 +1,87 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the deferred
+ * feedback behaviour.
+ *
+ * @package qbehaviour_deferredcbm
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+class qbehaviour_deferredcbm_renderer extends qbehaviour_renderer {
+    protected function certainly_choices($controlname, $selected, $readonly) {
+        $attributes = array(
+            'type' => 'radio',
+            'name' => $controlname,
+        );
+        if ($readonly) {
+            $attributes['disabled'] = 'disabled';
+        }
+
+        $choices = '';
+        foreach (question_cbm::$certainties as $certainty) {
+            $id = $controlname . $certainty;
+            $attributes['id'] = $id;
+            $attributes['value'] = $certainty;
+            if ($selected == $certainty) {
+                $attributes['checked'] = 'checked';
+            } else {
+                unset($attributes['checked']);
+            }
+            $choices .= ' ' . html_writer::empty_tag('input', $attributes) . ' ' .
+                    html_writer::tag('label', question_cbm::get_string($certainty),
+                            array('for' => $id));
+        }
+        return $choices;
+    }
+
+    public function controls(question_attempt $qa, question_display_options $options) {
+        return html_writer::tag('div', get_string('howcertainareyou', 'qbehaviour_deferredcbm',
+                $this->certainly_choices($qa->get_behaviour_field_name('certainty'),
+                $qa->get_last_behaviour_var('certainty'), $options->readonly)),
+                array('class' => 'certaintychoices'));
+    }
+
+    public function feedback(question_attempt $qa, question_display_options $options) {
+        if (!$options->feedback) {
+            return '';
+        }
+
+        if ($qa->get_state() == question_state::$gaveup || $qa->get_state() == question_state::$mangaveup) {
+            return '';
+        }
+
+        $feedback = '';
+        if (!$qa->get_last_behaviour_var('certainty')) {
+            $feedback .= html_writer::tag('p', get_string('assumingcertainty', 'qbehaviour_deferredcbm',
+                    question_cbm::get_string($qa->get_last_behaviour_var('_assumedcertainty'))));
+        }
+
+        if ($options->marks) {
+            $a->rawmark = format_float(
+                    $qa->get_last_behaviour_var('_rawfraction') * $qa->get_max_mark(), $options->markdp);
+            $a->mark = $qa->format_mark($options->markdp);
+            $feedback .= html_writer::tag('p', get_string('markadjustment', 'qbehaviour_deferredcbm', $a));
+        }
+
+        return $feedback;
+    }
+}
\ No newline at end of file
diff --git a/question/behaviour/deferredcbm/simpletest/testwalkthrough.php b/question/behaviour/deferredcbm/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..742911d
--- /dev/null
@@ -0,0 +1,261 @@
+<?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 that walks a question through the deferred feedback
+ * behaviour.
+ *
+ * @package qbehaviour_deferredcbm
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_deferredcbm_walkthrough_test extends qbehaviour_walkthrough_test_base {
+    public function test_deferred_cbm_truefalse_high_certainty() {
+
+        // Create a true-false question with correct answer true.
+        $tf = test_question_maker::make_a_truefalse_question();
+        $this->start_attempt_at_question($tf, 'deferredcbm', 2);
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($tf),
+                $this->get_contains_tf_true_radio_expectation(true, false),
+                $this->get_contains_tf_false_radio_expectation(true, false),
+                $this->get_contains_cbm_radio_expectation(1, true, false),
+                $this->get_contains_cbm_radio_expectation(2, true, false),
+                $this->get_contains_cbm_radio_expectation(3, true, false),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process the data extracted for this question.
+        $this->process_submission(array('answer' => 1, '-certainty' => 3));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_tf_true_radio_expectation(true, true),
+                $this->get_contains_cbm_radio_expectation(3, true, true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process the same data again, check it does not create a new step.
+        $numsteps = $this->get_step_count();
+        $this->process_submission(array('answer' => 1, '-certainty' => 3));
+        $this->check_step_count($numsteps);
+
+        // Process different data, check it creates a new step.
+        $this->process_submission(array('answer' => 1, '-certainty' => 1));
+        $this->check_step_count($numsteps + 1);
+        $this->check_current_state(question_state::$complete);
+
+        // Change back, check it creates a new step.
+        $this->process_submission(array('answer' => 1, '-certainty' => 3));
+        $this->check_step_count($numsteps + 2);
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(2);
+        $this->check_current_output(
+                $this->get_contains_tf_true_radio_expectation(false, true),
+                $this->get_contains_cbm_radio_expectation(3, false, true),
+                $this->get_contains_correct_expectation());
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 1);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(1);
+        $this->check_current_output(new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+
+        // Now change the correct answer to the question, and regrade.
+        $tf->rightanswer = false;
+        $this->quba->regrade_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(1);
+        $autogradedstep = $this->get_step($this->get_step_count() - 2);
+        $this->assertWithinMargin($autogradedstep->get_fraction(), -2, 0.0000001);
+    }
+
+    public function test_deferred_cbm_truefalse_low_certainty() {
+
+        // Create a true-false question with correct answer true.
+        $tf = test_question_maker::make_a_truefalse_question();
+        $this->start_attempt_at_question($tf, 'deferredcbm', 2);
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_contains_cbm_radio_expectation(1, true, false),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Submit ansewer with low certainty.
+        $this->process_submission(array('answer' => 1, '-certainty' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_current_output($this->get_does_not_contain_correctness_expectation(),
+                $this->get_contains_cbm_radio_expectation(1, true, true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(0.6666667);
+        $this->check_current_output($this->get_contains_correct_expectation(),
+                $this->get_contains_cbm_radio_expectation(1, false, true));
+        $this->assertEqual(get_string('true', 'qtype_truefalse') . ' [' . question_cbm::get_string(1) . ']',
+                $this->quba->get_response_summary($this->slot));
+    }
+
+    public function test_deferred_cbm_truefalse_default_certainty() {
+
+        // Create a true-false question with correct answer true.
+        $tf = test_question_maker::make_a_truefalse_question();
+        $this->start_attempt_at_question($tf, 'deferredcbm', 2);
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_contains_cbm_radio_expectation(1, true, false),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Submit ansewer with low certainty and finish the attempt.
+        $this->process_submission(array('answer' => 1));
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $qa = $this->quba->get_question_attempt($this->slot);
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(0.6666667);
+        $this->check_current_output($this->get_contains_correct_expectation(),
+                $this->get_contains_cbm_radio_expectation(1, false, false),
+                new PatternExpectation('/' . preg_quote(get_string('assumingcertainty', 'qbehaviour_deferredcbm',
+                    question_cbm::get_string($qa->get_last_behaviour_var('_assumedcertainty')))) . '/'));
+        $this->assertEqual(get_string('true', 'qtype_truefalse'),
+                $this->quba->get_response_summary($this->slot));
+    }
+
+    public function test_deferredcbm_resume_multichoice_single() {
+
+        // Create a multiple-choice question.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+
+        // Attempt it getting it wrong.
+        $this->start_attempt_at_question($mc, 'deferredcbm', 3);
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+        $this->process_submission(array('answer' => $wrongindex, '-certainty' => 2));
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedwrong);
+        $this->check_current_mark(-3.3333333);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, false, true),
+                $this->get_contains_cbm_radio_expectation(2, false, true),
+                $this->get_contains_incorrect_expectation());
+        $this->assertEqual('A [' . question_cbm::get_string(question_cbm::HIGH) . ']',
+                $this->quba->get_right_answer_summary($this->slot));
+        $this->assertPattern('/' . preg_quote($mc->questiontext) . '/',
+                $this->quba->get_question_summary($this->slot));
+        $this->assertPattern('/(B|C) \[' . preg_quote(question_cbm::get_string(2)) . '\]/',
+                $this->quba->get_response_summary($this->slot));
+
+        // Save the old attempt.
+        $oldqa = $this->quba->get_question_attempt($this->slot);
+
+        // Reinitialise.
+        $this->setUp();
+        $this->quba->set_preferred_behaviour('deferredcbm');
+        $this->slot = $this->quba->add_question($mc, 3);
+        $this->quba->start_question_based_on($this->slot, $oldqa);
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_contains_cbm_radio_expectation(2, true, true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_does_not_contain_correctness_expectation());
+        $this->assertEqual('A [' . question_cbm::get_string(question_cbm::HIGH) . ']',
+                $this->quba->get_right_answer_summary($this->slot));
+        $this->assertPattern('/' . preg_quote($mc->questiontext) . '/',
+                $this->quba->get_question_summary($this->slot));
+        $this->assertNull($this->quba->get_response_summary($this->slot));
+
+        // Now get it right.
+        $this->process_submission(array('answer' => $rightindex, '-certainty' => 3));
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(3);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_cbm_radio_expectation(3, false, true),
+                $this->get_contains_correct_expectation());
+        $this->assertPattern('/(A) \[' . preg_quote(question_cbm::get_string(3)) . '\]/',
+                $this->quba->get_response_summary($this->slot));
+    }
+
+    public function test_deferred_cbm_truefalse_no_certainty_feedback_when_not_answered() {
+
+        // Create a true-false question with correct answer true.
+        $tf = test_question_maker::make_a_truefalse_question();
+        $this->start_attempt_at_question($tf, 'deferredcbm', 2);
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_contains_cbm_radio_expectation(1, true, false),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Finish without answering.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gaveup);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new NoPatternExpectation('/class=\"im-feedback/'));
+    }
+}
+
diff --git a/question/behaviour/deferredfeedback/behaviour.php b/question/behaviour/deferredfeedback/behaviour.php
new file mode 100644 (file)
index 0000000..937799a
--- /dev/null
@@ -0,0 +1,94 @@
+<?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/>.
+
+
+/**
+ * Question behaviour for the case when the student's answer is just
+ * saved until they submit the whole attempt, and then it is graded.
+ *
+ * @package qbehaviour_deferredfeedback
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * Question behaviour for deferred feedback.
+ *
+ * The student enters their response during the attempt, and it is saved. Later,
+ * when the whole attempt is finished, their answer is graded.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_deferredfeedback extends question_behaviour_with_save {
+    const IS_ARCHETYPAL = true;
+
+    public function required_question_definition_type() {
+        return 'question_automatically_gradable';
+    }
+
+    public static function get_unused_display_options() {
+        return array('correctness', 'marks', 'specificfeedback', 'generalfeedback',
+                'rightanswer');
+    }
+
+    public function get_min_fraction() {
+        return $this->question->get_min_fraction();
+    }
+
+    public function get_right_answer_summary() {
+        return $this->question->get_right_answer_summary();
+    }
+
+    public function process_action(question_attempt_pending_step $pendingstep) {
+        if ($pendingstep->has_behaviour_var('comment')) {
+            return $this->process_comment($pendingstep);
+        } else if ($pendingstep->has_behaviour_var('finish')) {
+            return $this->process_finish($pendingstep);
+        } else {
+            return $this->process_save($pendingstep);
+        }
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        if ($step->has_behaviour_var('comment')) {
+            return $this->summarise_manual_comment($step);
+        } else if ($step->has_behaviour_var('finish')) {
+            return $this->summarise_finish($step);
+        } else {
+            return $this->summarise_save($step);
+        }
+    }
+
+    public function process_finish(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        $response = $this->qa->get_last_step()->get_qt_data();
+        if (!$this->question->is_gradable_response($response)) {
+            $pendingstep->set_state(question_state::$gaveup);
+        } else {
+            list($fraction, $state) = $this->question->grade_response($response);
+            $pendingstep->set_fraction($fraction);
+            $pendingstep->set_state($state);
+        }
+        $pendingstep->set_new_response_summary($this->question->summarise_response($response));
+        return question_attempt::KEEP;
+    }
+}
diff --git a/question/behaviour/deferredfeedback/lang/en_utf8/qbehaviour_deferredfeedback.php b/question/behaviour/deferredfeedback/lang/en_utf8/qbehaviour_deferredfeedback.php
new file mode 100644 (file)
index 0000000..a6646f6
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['deferredfeedback'] = 'Deferred feedback';
diff --git a/question/behaviour/deferredfeedback/renderer.php b/question/behaviour/deferredfeedback/renderer.php
new file mode 100644 (file)
index 0000000..a3a2976
--- /dev/null
@@ -0,0 +1,30 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the deferred
+ * feedback behaviour.
+ *
+ * @package qbehaviour_deferredfeedback
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+class qbehaviour_deferredfeedback_renderer extends qbehaviour_renderer {
+}
diff --git a/question/behaviour/deferredfeedback/simpletest/testwalkthrough.php b/question/behaviour/deferredfeedback/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..8f4eee1
--- /dev/null
@@ -0,0 +1,205 @@
+<?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 that walks a question through the deferred feedback
+ * behaviour.
+ *
+ * @package qbehaviour_deferredfeedback
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_deferredfeedback_walkthrough_test extends qbehaviour_walkthrough_test_base {
+    public function test_deferredfeedback_feedback_truefalse() {
+
+        // Create a true-false question with correct answer true.
+        $tf = test_question_maker::make_a_truefalse_question();
+        $this->start_attempt_at_question($tf, 'deferredfeedback', 2);
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output($this->get_contains_question_text_expectation($tf),
+                $this->get_does_not_contain_feedback_expectation());
+        $this->assertEqual(get_string('true', 'qtype_truefalse'),
+                $this->quba->get_right_answer_summary($this->slot));
+        $this->assertPattern('/' . preg_quote($tf->questiontext) . '/',
+                $this->quba->get_question_summary($this->slot));
+        $this->assertNull($this->quba->get_response_summary($this->slot));
+
+        // Process a true answer and check the expected result.
+        $this->process_submission(array('answer' => 1));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_current_output($this->get_contains_tf_true_radio_expectation(true, true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process the same data again, check it does not create a new step.
+        $numsteps = $this->get_step_count();
+        $this->process_submission(array('answer' => 1));
+        $this->check_step_count($numsteps);
+
+        // Process different data, check it creates a new step.
+        $this->process_submission(array('answer' => 0));
+        $this->check_step_count($numsteps + 1);
+        $this->check_current_state(question_state::$complete);
+
+        // Change back, check it creates a new step.
+        $this->process_submission(array('answer' => 1));
+        $this->check_step_count($numsteps + 2);
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(2);
+        $this->check_current_output($this->get_contains_correct_expectation(),
+                $this->get_contains_tf_true_radio_expectation(false, true),
+                new PatternExpectation('/class="r0 correct"/'));
+        $this->assertEqual(get_string('true', 'qtype_truefalse'),
+                $this->quba->get_response_summary($this->slot));
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 1);
+
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+
+        // Now change the correct answer to the question, and regrade.
+        $tf->rightanswer = false;
+        $this->quba->regrade_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(1);
+
+        $autogradedstep = $this->get_step($this->get_step_count() - 2);
+        $this->assertWithinMargin($autogradedstep->get_fraction(), 0, 0.0000001);
+    }
+
+    public function test_deferredfeedback_feedback_multichoice_single() {
+
+        // Create a true-false question with correct answer true.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $this->start_attempt_at_question($mc, 'deferredfeedback', 3);
+
+        // Start a deferred feedback attempt and add the question to it.
+        $rightindex = $this->get_mc_right_answer_index($mc);
+
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process the data extracted for this question.
+        $this->process_submission(array('answer' => $rightindex));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($rightindex, true, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, true, false),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(3);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_correct_expectation());
+
+        // Now change the correct answer to the question, and regrade.
+        $mc->answers[13]->fraction = -0.33333333;
+        $mc->answers[14]->fraction = 1;
+        $this->quba->regrade_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedwrong);
+        $this->check_current_mark(-1);
+        $this->check_current_output(
+                $this->get_contains_incorrect_expectation());
+    }
+
+    public function test_deferredfeedback_resume_multichoice_single() {
+
+        // Create a multiple-choice question.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+
+        // Attempt it getting it wrong.
+        $this->start_attempt_at_question($mc, 'deferredfeedback', 3);
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+        $this->process_submission(array('answer' => $wrongindex));
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedwrong);
+        $this->check_current_mark(-1);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, false, true),
+                $this->get_contains_incorrect_expectation());
+
+        // Save the old attempt.
+        $oldqa = $this->quba->get_question_attempt($this->slot);
+
+        // Reinitialise.
+        $this->setUp();
+        $this->quba->set_preferred_behaviour('deferredfeedback');
+        $this->slot = $this->quba->add_question($mc, 3);
+        $this->quba->start_question_based_on($this->slot, $oldqa);
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_does_not_contain_correctness_expectation());
+
+        // Now get it right.
+        $this->process_submission(array('answer' => $rightindex));
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(3);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_correct_expectation());
+    }
+}
diff --git a/question/behaviour/immediatecbm/behaviour.php b/question/behaviour/immediatecbm/behaviour.php
new file mode 100644 (file)
index 0000000..ef8ac16
--- /dev/null
@@ -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/>.
+
+
+/**
+ * Question behaviour where the student can submit questions one at a
+ * time for immediate feedback, with certainty based marking.
+ *
+ * @package qbehaviour_immediatecbm
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../immediatefeedback/behaviour.php');
+
+/**
+ * Question behaviour for immediate feedback with CBM.
+ *
+ * Each question has a submit button next to it along with some radio buttons
+ * to input a certainly, that is, how sure they are that they are right.
+ * The student can submit their answer at any time for immediate feedback.
+ * Once the qustion is submitted, it is not possible for the student to change
+ * their answer any more. The student's degree of certainly affects their score.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_immediatecbm extends qbehaviour_immediatefeedback {
+    const IS_ARCHETYPAL = true;
+
+    public function get_min_fraction() {
+        return question_cbm::adjust_fraction(parent::get_min_fraction(), question_cbm::HIGH);
+    }
+
+    public function get_expected_data() {
+        if ($this->qa->get_state()->is_active()) {
+            return array(
+                'submit' => PARAM_BOOL,
+                'certainty' => PARAM_INT,
+            );
+        }
+        return parent::get_expected_data();
+    }
+
+    public function get_right_answer_summary() {
+        $summary = parent::get_right_answer_summary();
+        return question_cbm::summary_with_certainty($summary, question_cbm::HIGH);
+    }
+
+    public function get_correct_response() {
+        if ($this->qa->get_state()->is_active()) {
+            return array('certainty' => question_cbm::HIGH);
+        }
+        return array();
+    }
+
+    protected function get_our_resume_data() {
+        $lastcertainty = $this->qa->get_last_behaviour_var('certainty');
+        if ($lastcertainty) {
+            return array('-certainty' => $lastcertainty);
+        } else {
+            return array();
+        }
+    }
+
+    protected function is_same_response($pendingstep) {
+        return parent::is_same_response($pendingstep) &&
+                $this->qa->get_last_behaviour_var('certainty') == $pendingstep->get_behaviour_var('certainty');
+    }
+
+    protected function is_complete_response($pendingstep) {
+        return parent::is_complete_response($pendingstep) && $pendingstep->has_behaviour_var('certainty');
+    }
+
+    public function process_submit(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        if (!$this->qa->get_question()->is_gradable_response($pendingstep->get_qt_data()) ||
+                !$pendingstep->has_behaviour_var('certainty')) {
+            $pendingstep->set_state(question_state::$invalid);
+            return question_attempt::KEEP;
+        }
+
+        return $this->do_grading($pendingstep, $pendingstep);
+    }
+
+    public function process_finish(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        $laststep = $this->qa->get_last_step();
+        return $this->do_grading($laststep, $pendingstep);
+    }
+
+    protected function do_grading(question_attempt_step $responsesstep,
+            question_attempt_pending_step $pendingstep) {
+        if (!$this->question->is_gradable_response($responsesstep->get_qt_data())) {
+            $pendingstep->set_state(question_state::$gaveup);
+
+        } else {
+            $response = $responsesstep->get_qt_data();
+            list($fraction, $state) = $this->question->grade_response($response);
+
+            if ($responsesstep->has_behaviour_var('certainty')) {
+                $certainty = $responsesstep->get_behaviour_var('certainty');
+            } else {
+                $certainty = question_cbm::default_certainty();
+                $pendingstep->set_behaviour_var('_assumedcertainty', $certainty);
+            }
+
+            $pendingstep->set_behaviour_var('_rawfraction', $fraction);
+            $pendingstep->set_fraction(question_cbm::adjust_fraction($fraction, $certainty));
+            $pendingstep->set_state($state);
+            $pendingstep->set_new_response_summary(
+                    question_cbm::summary_with_certainty(
+                    $this->question->summarise_response($response), $responsesstep->get_behaviour_var('certainty')));
+        }
+        return question_attempt::KEEP;
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        $summary = parent::summarise_action($step);
+        if ($step->has_behaviour_var('certainty')) {
+            $summary = question_cbm::summary_with_certainty($summary,
+                    $step->get_behaviour_var('certainty'));
+        }
+        return $summary;
+    }
+
+    public static function adjust_random_guess_score($fraction) {
+        return question_cbm::adjust_fraction($fraction, question_cbm::default_certainty());
+    }
+}
diff --git a/question/behaviour/immediatecbm/lang/en_utf8/qbehaviour_immediatecbm.php b/question/behaviour/immediatecbm/lang/en_utf8/qbehaviour_immediatecbm.php
new file mode 100644 (file)
index 0000000..e754515
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+$string['immediatecbm'] = 'Immediate feedback with CBM';
+$string['pleaseselectacertainty'] = 'Please select a certainty.';
diff --git a/question/behaviour/immediatecbm/renderer.php b/question/behaviour/immediatecbm/renderer.php
new file mode 100644 (file)
index 0000000..c36a03e
--- /dev/null
@@ -0,0 +1,43 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the immediate
+ * feedback with CBM behaviour.
+ *
+ * @package qbehaviour_immediatecbm
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../deferredcbm/renderer.php');
+
+
+class qbehaviour_immediatecbm_renderer extends qbehaviour_deferredcbm_renderer {
+    public function controls(question_attempt $qa, question_display_options $options) {
+        $output = parent::controls($qa, $options);
+        if ($qa->get_state() == question_state::$invalid && !$qa->get_last_step()->has_behaviour_var('certainty')) {
+            $output .= html_writer::tag('div',
+                    get_string('pleaseselectacertainty', 'qbehaviour_immediatecbm'),
+                    array('class' => 'validationerror'));
+        }
+        $output .= $this->submit_button($qa, $options);
+        return $output;
+    }
+}
diff --git a/question/behaviour/immediatecbm/simpletest/testwalkthrough.php b/question/behaviour/immediatecbm/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..425ec4e
--- /dev/null
@@ -0,0 +1,282 @@
+<?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 that walks a question through the immediate cbm
+ * behaviour.
+ *
+ * @package qbehaviour_immediatecbm
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_immediatecbm_walkthrough_test extends qbehaviour_walkthrough_test_base {
+    public function test_immediatecbm_feedback_multichoice_right() {
+
+        // Create a true-false question with correct answer true.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $this->start_attempt_at_question($mc, 'immediatecbm');
+
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+        $this->assertEqual('A [' . question_cbm::get_string(question_cbm::HIGH) . ']',
+                $this->quba->get_right_answer_summary($this->slot));
+        $this->assertPattern('/' . preg_quote($mc->questiontext) . '/',
+                $this->quba->get_question_summary($this->slot));
+        $this->assertNull($this->quba->get_response_summary($this->slot));
+
+        // Save the wrong answer.
+        $this->process_submission(array('answer' => $wrongindex, '-certainty' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Submit the right answer.
+        $this->process_submission(array('answer' => $rightindex, '-certainty' => 2, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(2/3);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_correct_expectation());
+        $this->assertEqual('A [' . question_cbm::get_string(2) . ']',
+                $this->quba->get_response_summary($this->slot));
+
+        $numsteps = $this->get_step_count();
+
+        // Finish the attempt - should not need to add a new state.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->assertEqual($numsteps, $this->get_step_count());
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(2/3);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_correct_expectation());
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 0.5);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(0.5);
+        $this->check_current_output(
+                $this->get_contains_partcorrect_expectation(),
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+
+        // Now change the correct answer to the question, and regrade.
+        $mc->answers[13]->fraction = -0.33333333;
+        $mc->answers[15]->fraction = 1;
+        $this->quba->regrade_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(0.5);
+        $this->check_current_output(
+                $this->get_contains_partcorrect_expectation());
+
+        $autogradedstep = $this->get_step($this->get_step_count() - 2);
+        $this->assertWithinMargin($autogradedstep->get_fraction(), -10/9, 0.0000001);
+    }
+
+    public function test_immediatecbm_feedback_multichoice_try_to_submit_blank() {
+
+        // Create a true-false question with correct answer true.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $this->start_attempt_at_question($mc, 'immediatecbm');
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Submit nothing.
+        $this->process_submission(array('-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$invalid);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_contains_validation_error_expectation());
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gaveup);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation(0, false, false),
+                $this->get_contains_mc_radio_expectation(1, false, false),
+                $this->get_contains_mc_radio_expectation(2, false, false));
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 0.5);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(0.5);
+        $this->check_current_output(
+                $this->get_contains_partcorrect_expectation(),
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+    }
+
+    public function test_immediatecbm_feedback_shortanswer_try_to_submit_no_certainty() {
+
+        // Create a short answer question with correct answer true.
+        $sa = test_question_maker::make_a_shortanswer_question();
+        $this->start_attempt_at_question($sa, 'immediatecbm');
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Submit with certainty missing.
+        $this->process_submission(array('-submit' => 1, 'answer' => 'frog'));
+
+        // Verify.
+        $this->check_current_state(question_state::$invalid);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_contains_validation_error_expectation());
+
+        // Now get it right.
+        $this->process_submission(array('-submit' => 1, 'answer' => 'frog', '-certainty' => 3));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+                $this->get_does_not_contain_validation_error_expectation());
+    }
+
+    public function test_immediatecbm_feedback_multichoice_wrong_on_finish() {
+
+        // Create a true-false question with correct answer true.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $this->start_attempt_at_question($mc, 'immediatecbm');
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+
+        // Save the wrong answer.
+        $this->process_submission(array('answer' => $wrongindex, '-certainty' => 3));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation());
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedwrong);
+        $this->check_current_mark(-3);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, false, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_incorrect_expectation());
+    }
+
+    public function test_immediatecbm_cbm_truefalse_no_certainty_feedback_when_not_answered() {
+
+        // Create a true-false question with correct answer true.
+        $tf = test_question_maker::make_a_truefalse_question();
+        $this->start_attempt_at_question($tf, 'deferredcbm', 2);
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_contains_cbm_radio_expectation(1, true, false),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Finish without answering.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gaveup);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new NoPatternExpectation('/class=\"im-feedback/'));
+    }
+}
diff --git a/question/behaviour/immediatefeedback/behaviour.php b/question/behaviour/immediatefeedback/behaviour.php
new file mode 100644 (file)
index 0000000..cf7b4c3
--- /dev/null
@@ -0,0 +1,131 @@
+<?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/>.
+
+
+/**
+ * Question behaviour where the student can submit questions one at a
+ * time for immediate feedback.
+ *
+ * @package qbehaviour_immediatefeedback
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * Question behaviour for immediate feedback.
+ *
+ * Each question has a submit button next to it which the student can use to
+ * submit it. Once the qustion is submitted, it is not possible for the
+ * student to change their answer any more, but the student gets full feedback
+ * straight away.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_immediatefeedback extends question_behaviour_with_save {
+    const IS_ARCHETYPAL = true;
+
+    public function required_question_definition_type() {
+        return 'question_automatically_gradable';
+    }
+
+    public function get_min_fraction() {
+        return $this->question->get_min_fraction();
+    }
+
+    public function get_expected_data() {
+        if ($this->qa->get_state()->is_active()) {
+            return array(
+                'submit' => PARAM_BOOL,
+            );
+        }
+        return parent::get_expected_data();
+    }
+
+    public function get_right_answer_summary() {
+        return $this->question->get_right_answer_summary();
+    }
+
+    public function process_action(question_attempt_pending_step $pendingstep) {
+        if ($pendingstep->has_behaviour_var('comment')) {
+            return $this->process_comment($pendingstep);
+        } else if ($pendingstep->has_behaviour_var('submit')) {
+            return $this->process_submit($pendingstep);
+        } else if ($pendingstep->has_behaviour_var('finish')) {
+            return $this->process_finish($pendingstep);
+        } else {
+            return $this->process_save($pendingstep);
+        }
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        if ($step->has_behaviour_var('comment')) {
+            return $this->summarise_manual_comment($step);
+        } else if ($step->has_behaviour_var('finish')) {
+            return $this->summarise_finish($step);
+        } else if ($step->has_behaviour_var('submit')) {
+            return $this->summarise_submit($step);
+        } else {
+            return $this->summarise_save($step);
+        }
+    }
+
+    public function process_submit(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        if (!$this->is_complete_response($pendingstep)) {
+            $pendingstep->set_state(question_state::$invalid);
+
+        } else {
+            $response = $pendingstep->get_qt_data();
+            list($fraction, $state) = $this->question->grade_response($response);
+            $pendingstep->set_fraction($fraction);
+            $pendingstep->set_state($state);
+            $pendingstep->set_new_response_summary($this->question->summarise_response($response));
+        }
+        return question_attempt::KEEP;
+    }
+
+    public function process_finish(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        $response = $this->qa->get_last_step()->get_qt_data();
+        if (!$this->question->is_gradable_response($response)) {
+            $pendingstep->set_state(question_state::$gaveup);
+
+        } else {
+            list($fraction, $state) = $this->question->grade_response($response);
+            $pendingstep->set_fraction($fraction);
+            $pendingstep->set_state($state);
+        }
+        $pendingstep->set_new_response_summary($this->question->summarise_response($response));
+        return question_attempt::KEEP;
+    }
+
+    public function process_save(question_attempt_pending_step $pendingstep) {
+        $status = parent::process_save($pendingstep);
+        if ($status == question_attempt::KEEP && $pendingstep->get_state() == question_state::$complete) {
+            $pendingstep->set_state(question_state::$todo);
+        }
+        return $status;
+    }
+}
diff --git a/question/behaviour/immediatefeedback/lang/en_utf8/qbehaviour_immediatefeedback.php b/question/behaviour/immediatefeedback/lang/en_utf8/qbehaviour_immediatefeedback.php
new file mode 100644 (file)
index 0000000..2aed0db
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['immediatefeedback'] = 'Immediate feedback';
diff --git a/question/behaviour/immediatefeedback/renderer.php b/question/behaviour/immediatefeedback/renderer.php
new file mode 100644 (file)
index 0000000..5189587
--- /dev/null
@@ -0,0 +1,33 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the immediate
+ * feedback behaviour.
+ *
+ * @package qbehaviour_immediatefeedback
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+class qbehaviour_immediatefeedback_renderer extends qbehaviour_renderer {
+    public function controls(question_attempt $qa, question_display_options $options) {
+        return $this->submit_button($qa, $options);
+    }
+}
diff --git a/question/behaviour/immediatefeedback/simpletest/testwalkthrough.php b/question/behaviour/immediatefeedback/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..6811eaf
--- /dev/null
@@ -0,0 +1,237 @@
+<?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 that walks a question through the immediate feedback
+ * behaviour.
+ *
+ * @package qbehaviour_immediatefeedback
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_immediatefeedback_walkthrough_test extends qbehaviour_walkthrough_test_base {
+    public function test_immediatefeedback_feedback_multichoice_right() {
+
+        // Create a true-false question with correct answer true.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $this->start_attempt_at_question($mc, 'immediatefeedback');
+
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Save the wrong answer.
+        $this->process_submission(array('answer' => $wrongindex));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Submit the right answer.
+        $this->process_submission(array('answer' => $rightindex, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_correct_expectation());
+        $this->assertEqual('A',
+                $this->quba->get_response_summary($this->slot));
+
+        $numsteps = $this->get_step_count();
+
+        // Now try to save again - as if the user clicked next in the quiz.
+        $this->process_submission(array('answer' => $rightindex));
+
+        // Verify.
+        $this->assertEqual($numsteps, $this->get_step_count());
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_correct_expectation());
+
+        // Finish the attempt - should not need to add a new state.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->assertEqual($numsteps, $this->get_step_count());
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_correct_expectation());
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 0.5);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(0.5);
+        $this->check_current_output(
+                $this->get_contains_partcorrect_expectation(),
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+
+        // Now change the correct answer to the question, and regrade.
+        $mc->answers[13]->fraction = -0.33333333;
+        $mc->answers[15]->fraction = 1;
+        $this->quba->regrade_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(0.5);
+        $this->check_current_output(
+                $this->get_contains_partcorrect_expectation());
+
+        $autogradedstep = $this->get_step($this->get_step_count() - 2);
+        $this->assertWithinMargin($autogradedstep->get_fraction(), -0.3333333, 0.0000001);
+    }
+
+    public function test_immediatefeedback_feedback_multichoice_try_to_submit_blank() {
+
+        // Create a true-false question with correct answer true.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $this->start_attempt_at_question($mc, 'immediatefeedback');
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Submit nothing.
+        $this->process_submission(array('-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$invalid);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_contains_validation_error_expectation());
+        $this->assertNull($this->quba->get_response_summary($this->slot));
+    
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gaveup);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation(0, false, false),
+                $this->get_contains_mc_radio_expectation(1, false, false),
+                $this->get_contains_mc_radio_expectation(2, false, false));
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 0.5);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(0.5);
+        $this->check_current_output(
+                $this->get_contains_partcorrect_expectation(),
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+    }
+
+    public function test_immediatefeedback_feedback_multichoice_wrong_on_finish() {
+
+        // Create a true-false question with correct answer true.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $this->start_attempt_at_question($mc, 'immediatefeedback');
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation());
+
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+
+        // Save the wrong answer.
+        $this->process_submission(array('answer' => $wrongindex));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedwrong);
+        $this->check_current_mark(-0.3333333);
+        $this->check_current_output(
+                $this->get_contains_mc_radio_expectation($wrongindex, false, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_incorrect_expectation());
+        $this->assertPattern('/B|C/',
+                $this->quba->get_response_summary($this->slot));
+    }
+}
diff --git a/question/behaviour/informationitem/behaviour.php b/question/behaviour/informationitem/behaviour.php
new file mode 100644 (file)
index 0000000..a5b3c4c
--- /dev/null
@@ -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/>.
+
+
+/**
+ * This behaviour is for informaiton items.
+ *
+ * @package qbehaviour_informationitem
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * Question behaviour informaiton items.
+ *
+ * For example for the 'Description' 'Question type'. There is no grade,
+ * and the question type is marked complete the first time the user navigates
+ * away from a page that contains that question.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_informationitem extends question_behaviour {
+
+    public function required_question_definition_type() {
+        return 'question_definition';
+    }
+
+    public function get_expected_data() {
+        if ($this->qa->get_state() == question_state::$todo) {
+            return array('seen' => PARAM_BOOL);
+        }
+        return parent::get_expected_data();
+    }
+
+    public function get_correct_response() {
+        if ($this->qa->get_state() == question_state::$todo) {
+            return array('seen' => 1);
+        }
+        return array();
+    }
+
+    public function adjust_display_options(question_display_options $options) {
+        parent::adjust_display_options($options);
+
+        $options->marks = question_display_options::HIDDEN;
+
+        // At the moment, the code exists to process a manual comment on an
+        // information item, but we don't display the UI unless there is already
+        // a comment.
+        if (!$this->qa->get_state()->is_commented()) {
+            $options->manualcomment = question_display_options::HIDDEN;
+        }
+    }
+
+    public function get_state_string($showcorrectness) {
+        return '';
+    }
+
+    public function process_action(question_attempt_pending_step $pendingstep) {
+        if ($pendingstep->has_behaviour_var('comment')) {
+            return $this->process_comment($pendingstep);
+        } else if ($pendingstep->has_behaviour_var('finish')) {
+            return $this->process_finish($pendingstep);
+        } else if ($pendingstep->has_behaviour_var('seen')) {
+            return $this->process_seen($pendingstep);
+        } else {
+            return question_attempt::DISCARD;
+        }
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        if ($step->has_behaviour_var('comment')) {
+            return $this->summarise_manual_comment($step);
+        } else if ($step->has_behaviour_var('finish')) {
+            return $this->summarise_finish($step);
+        } else if ($step->has_behaviour_var('seen')) {
+            return get_string('seen', 'qbehaviour_informationitem');
+        }
+        return $this->summarise_start($step);
+    }
+
+    public function process_comment(question_attempt_pending_step $pendingstep) {
+        if ($pendingstep->has_behaviour_var('mark')) {
+            throw new Exception('Information items cannot be graded.');
+        }
+        return parent::process_comment($pendingstep);
+    }
+
+    public function process_finish(question_attempt_pending_step $pendingstep) {
+        $pendingstep->set_state(question_state::$finished);
+        return question_attempt::KEEP;
+    }
+
+    public function process_seen(question_attempt_pending_step $pendingstep) {
+        $pendingstep->set_state(question_state::$complete);
+        return question_attempt::KEEP;
+    }
+}
diff --git a/question/behaviour/informationitem/lang/en_utf8/qbehaviour_informationitem.php b/question/behaviour/informationitem/lang/en_utf8/qbehaviour_informationitem.php
new file mode 100644 (file)
index 0000000..a147123
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+$string['informationitem'] = 'behaviour for information items';
+$string['seen'] = 'Seen';
\ No newline at end of file
diff --git a/question/behaviour/informationitem/renderer.php b/question/behaviour/informationitem/renderer.php
new file mode 100644 (file)
index 0000000..9162e09
--- /dev/null
@@ -0,0 +1,42 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the information
+ * item behaviour.
+ *
+ * @package qbehaviour_deferredfeedback
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+class qbehaviour_informationitem_renderer extends qbehaviour_renderer {
+    public function controls(question_attempt $qa, question_display_options $options) {
+        if ($qa->get_state() != question_state::$todo) {
+            return '';
+        }
+
+        // Hidden input to move the question into the complete state.
+        return html_writer::empty_tag('input', array(
+            'type' => 'hidden',
+            'name' => $qa->get_behaviour_field_name('seen'),
+            'value' => 1,
+        ));
+    }
+}
diff --git a/question/behaviour/informationitem/simpletest/testwalkthrough.php b/question/behaviour/informationitem/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..bc01852
--- /dev/null
@@ -0,0 +1,78 @@
+<?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 that walks a question through the information item
+ * behaviour.
+ *
+ * @package qbehaviour_informationitem
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_informationitem_walkthrough_test extends qbehaviour_walkthrough_test_base {
+    public function test_informationitem_feedback_description() {
+
+        // Create a true-false question with correct answer true.
+        $description = test_question_maker::make_a_description_question();
+        $this->start_attempt_at_question($description, 'deferredfeedback');
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output($this->get_contains_question_text_expectation($description),
+                new ContainsTagWithAttributes('input', array('type' => 'hidden',
+                'name' => $this->quba->get_field_prefix($this->slot) . '-seen', 'value' => 1)),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process a submission indicating this question has been seen.
+        $this->process_submission(array('-seen' => 1));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_current_output($this->get_does_not_contain_correctness_expectation(),
+                new NoPatternExpectation('/type=\"hidden\"[^>]*name=\"[^"]*seen\"|name=\"[^"]*seen\"[^>]*type=\"hidden\"/'),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$finished);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($description),
+                $this->get_contains_general_feedback_expectation($description));
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', null);
+
+        $this->check_current_state(question_state::$manfinished);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+
+        // Check that trying to process a manual comment with a grade causes an exception.
+        $this->expectException();
+        $this->manual_grade('Not good enough!', 1);
+    }
+}
diff --git a/question/behaviour/interactive/behaviour.php b/question/behaviour/interactive/behaviour.php
new file mode 100644 (file)
index 0000000..d9328d2
--- /dev/null
@@ -0,0 +1,243 @@
+<?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/>.
+
+
+/**
+ * Question behaviour where the student can submit questions one at a
+ * time for immediate feedback.
+ *
+ * @package qbehaviour_interactive
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * Question behaviour for the interactive model.
+ *
+ * Each question has a submit button next to it which the student can use to
+ * submit it. Once the qustion is submitted, it is not possible for the
+ * student to change their answer any more, but the student gets full feedback
+ * straight away.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_interactive extends question_behaviour_with_save {
+    const IS_ARCHETYPAL = true;
+
+    /**
+     * Special value used for {@link question_display_options::$readonly when
+     * we are showing the try again button to the student during an attempt.
+     * The particular number was chosen randomly. PHP will treat it the same
+     * as true, but in the renderer we reconginse it display the try again
+     * button enabled even though the rest of the question is disabled.
+     * @var integer
+     */
+    const READONLY_EXCEPT_TRY_AGAIN = 23485299;
+
+    public function required_question_definition_type() {
+        return 'question_automatically_gradable';
+    }
+
+    public function get_right_answer_summary() {
+        return $this->question->get_right_answer_summary();
+    }
+
+    /**
+     * @return boolean are we are currently in the try_again state.
+     */
+    protected function is_try_again_state() {
+        $laststep = $this->qa->get_last_step();
+        return $this->qa->get_state()->is_active() &&
+                $laststep->has_behaviour_var('submit') && $laststep->has_behaviour_var('_triesleft');
+    }
+
+    public function adjust_display_options(question_display_options $options) {
+        // We only need different behaviour in try again states.
+        if (!$this->is_try_again_state()) {
+            parent::adjust_display_options($options);
+            return;
+        }
+
+        // Let the hint adjust the options.
+        $hint = $this->get_applicable_hint();
+        if (!is_null($hint)) {
+            $hint->adjust_display_options($options);
+        }
+
+        // Now call the base class method, but protect some fields from being overwritten.
+        $save = clone($options);
+        parent::adjust_display_options($options);
+        $options->feedback = $save->feedback;
+        $options->numpartscorrect = $save->numpartscorrect;
+
+        // In a try-again state, everything except the try again button
+        // Should be read-only. This is a mild hack to achieve this.
+        if (!$options->readonly) {
+            $options->readonly = self::READONLY_EXCEPT_TRY_AGAIN;
+        }
+    }
+
+    public function get_applicable_hint() {
+        if (!$this->is_try_again_state()) {
+            return null;
+        }
+        return $this->question->get_hint(count($this->question->hints) -
+                $this->qa->get_last_behaviour_var('_triesleft'), $this->qa);
+    }
+
+    public function get_expected_data() {
+        if ($this->is_try_again_state()) {
+            return array(
+                'tryagain' => PARAM_BOOL,
+            );
+        } else if ($this->qa->get_state()->is_active()) {
+            return array(
+                'submit' => PARAM_BOOL,
+            );
+        }
+        return parent::get_expected_data();
+    }
+
+    public function get_expected_qt_data() {
+        $hint = $this->get_applicable_hint();
+        if (!empty($hint->clearwrong)) {
+            return $this->question->get_expected_data();
+        }
+        return parent::get_expected_qt_data();
+    }
+
+    public function get_state_string($showcorrectness) {
+        $state = $this->qa->get_state();
+        if (!$state->is_active() || $state == question_state::$invalid) {
+            return parent::get_state_string($showcorrectness);
+        }
+
+        if ($this->is_try_again_state()) {
+            return get_string('notcomplete', 'qbehaviour_interactive');
+        } else {
+            return get_string('triesremaining', 'qbehaviour_interactive',
+                    $this->qa->get_last_behaviour_var('_triesleft'));
+        }
+    }
+
+    public function init_first_step(question_attempt_step $step) {
+        parent::init_first_step($step);
+        $step->set_behaviour_var('_triesleft', count($this->question->hints) + 1);
+    }
+
+    public function process_action(question_attempt_pending_step $pendingstep) {
+        if ($pendingstep->has_behaviour_var('finish')) {
+            return $this->process_finish($pendingstep);
+        }
+        if ($this->is_try_again_state()) {
+            if ($pendingstep->has_behaviour_var('tryagain')) {
+                return $this->process_try_again($pendingstep);
+            } else {
+                return question_attempt::DISCARD;
+            }
+        } else {
+            if ($pendingstep->has_behaviour_var('comment')) {
+                return $this->process_comment($pendingstep);
+            } else if ($pendingstep->has_behaviour_var('submit')) {
+                return $this->process_submit($pendingstep);
+            } else {
+                return $this->process_save($pendingstep);
+            }
+        }
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        if ($step->has_behaviour_var('comment')) {
+            return $this->summarise_manual_comment($step);
+        } else if ($step->has_behaviour_var('finish')) {
+            return $this->summarise_finish($step);
+        } else if ($step->has_behaviour_var('tryagain')) {
+            return get_string('tryagain', 'qbehaviour_interactive');
+        } else if ($step->has_behaviour_var('submit')) {
+            return $this->summarise_submit($step);
+        } else {
+            return $this->summarise_save($step);
+        }
+    }
+
+    public function process_try_again(question_attempt_pending_step $pendingstep) {
+        $pendingstep->set_state(question_state::$todo);
+        return question_attempt::KEEP;
+    }
+
+    public function process_submit(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        if (!$this->is_complete_response($pendingstep)) {
+            $pendingstep->set_state(question_state::$invalid);
+
+        } else {
+            $triesleft = $this->qa->get_last_behaviour_var('_triesleft');
+            $response = $pendingstep->get_qt_data();
+            list($fraction, $state) = $this->question->grade_response($response);
+            if ($state == question_state::$gradedright || $triesleft == 1) {
+                $pendingstep->set_state($state);
+                $pendingstep->set_fraction($this->adjust_fraction($fraction, $pendingstep));
+
+            } else {
+                $pendingstep->set_behaviour_var('_triesleft', $triesleft - 1);
+                $pendingstep->set_state(question_state::$todo);
+            }
+            $pendingstep->set_new_response_summary($this->question->summarise_response($response));
+        }
+        return question_attempt::KEEP;
+    }
+
+    protected function adjust_fraction($fraction, question_attempt_pending_step $pendingstep) {
+        $totaltries = $this->qa->get_step(0)->get_behaviour_var('_triesleft');
+        $triesleft = $this->qa->get_last_behaviour_var('_triesleft');
+
+        $fraction -= ($totaltries - $triesleft) * $this->question->penalty;
+        $fraction = max($fraction, 0);
+        return $fraction;
+    }
+
+    public function process_finish(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        $response = $this->qa->get_last_qt_data();
+        if (!$this->question->is_gradable_response($response)) {
+            $pendingstep->set_state(question_state::$gaveup);
+
+        } else {
+            list($fraction, $state) = $this->question->grade_response($response);
+            $pendingstep->set_fraction($this->adjust_fraction($fraction, $pendingstep));
+            $pendingstep->set_state($state);
+        }
+        $pendingstep->set_new_response_summary($this->question->summarise_response($response));
+        return question_attempt::KEEP;
+    }
+
+    public function process_save(question_attempt_pending_step $pendingstep) {
+        $status = parent::process_save($pendingstep);
+        if ($status == question_attempt::KEEP && $pendingstep->get_state() == question_state::$complete) {
+            $pendingstep->set_state(question_state::$todo);
+        }
+        return $status;
+    }
+}
diff --git a/question/behaviour/interactive/lang/en_utf8/qbehaviour_interactive.php b/question/behaviour/interactive/lang/en_utf8/qbehaviour_interactive.php
new file mode 100644 (file)
index 0000000..63a34c5
--- /dev/null
@@ -0,0 +1,6 @@
+<?php
+
+$string['interactive'] = 'Interactive with multiple tries';
+$string['notcomplete'] = 'Not complete';
+$string['triesremaining'] = 'Tries remaining: $a';
+$string['tryagain'] = 'Try again';
diff --git a/question/behaviour/interactive/renderer.php b/question/behaviour/interactive/renderer.php
new file mode 100644 (file)
index 0000000..b1a6c69
--- /dev/null
@@ -0,0 +1,62 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the interactive
+ * behaviour.
+ *
+ * @package qbehaviour_interactive
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * Interactive behaviour renderer.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_interactive_renderer extends qbehaviour_renderer {
+    public function controls(question_attempt $qa, question_display_options $options) {
+        return $this->submit_button($qa, $options);
+    }
+
+    public function feedback(question_attempt $qa, question_display_options $options) {
+        if (!$qa->get_state()->is_active() || !$options->readonly) {
+            return '';
+        }
+
+        $attributes = array(
+            'type' => 'submit',
+            'id' => $qa->get_behaviour_field_name('tryagain'),
+            'name' => $qa->get_behaviour_field_name('tryagain'),
+            'value' => get_string('tryagain', 'qbehaviour_interactive'),
+            'class' => 'submit btn',
+        );
+        if ($options->readonly !== qbehaviour_interactive::READONLY_EXCEPT_TRY_AGAIN) {
+            $attributes['disabled'] = 'disabled';
+        }
+        $output = html_writer::empty_tag('input', $attributes);
+        if (empty($attributes['disabled'])) {
+            $output .= print_js_call('question_init_submit_button',
+                    array($attributes['id'], $qa->get_slot()), true);
+        }
+        return $output;
+    }
+}
diff --git a/question/behaviour/interactive/simpletest/testwalkthrough.php b/question/behaviour/interactive/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..f6fc9cb
--- /dev/null
@@ -0,0 +1,470 @@
+<?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 that walks a question through the interactive
+ * behaviour.
+ *
+ * @package qbehaviour_interactive
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_interactive_walkthrough_test extends qbehaviour_walkthrough_test_base {
+
+    public function test_interactive_feedback_multichoice_right() {
+
+        // Create a multichoice single question.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $mc->hints = array(
+            new question_hint_with_parts('This is the first hint.', false, false),
+            new question_hint_with_parts('This is the second hint.', true, true),
+        );
+        $this->start_attempt_at_question($mc, 'interactive');
+
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(3),
+                $this->get_no_hint_visible_expectation());
+
+        // Save the wrong answer.
+        $this->process_submission(array('answer' => $wrongindex));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(3),
+                $this->get_no_hint_visible_expectation());
+
+        // Submit the wrong answer.
+        $this->process_submission(array('answer' => $wrongindex, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_mc_radio_expectation($wrongindex, false, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_try_again_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'),
+                $this->get_contains_hint_expectation('This is the first hint'));
+
+        // Check that, if we review in this state, the try again button is disabled.
+        $displayoptions = new question_display_options();
+        $displayoptions->readonly = true;
+        $html = $this->quba->render_question($this->slot, $displayoptions);
+        $this->assert($this->get_contains_try_again_button_expectation(false), $html);
+
+        // Do try again.
+        $this->process_submission(array('-tryagain' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_mc_radio_expectation($wrongindex, true, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(2),
+                $this->get_no_hint_visible_expectation());
+
+        // Submit the right answer.
+        $this->process_submission(array('answer' => $rightindex, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(0.6666667);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0.6666667),
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_correct_expectation(),
+                $this->get_no_hint_visible_expectation());
+
+        // Finish the attempt - should not need to add a new state.
+        $numsteps = $this->get_step_count();
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->assertEqual($numsteps, $this->get_step_count());
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(0.6666667);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0.6666667),
+                $this->get_contains_mc_radio_expectation($rightindex, false, true),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($rightindex + 1) % 3, false, false),
+                $this->get_contains_correct_expectation(),
+                $this->get_no_hint_visible_expectation());
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 0.5);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(0.5);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0.5),
+                $this->get_contains_partcorrect_expectation(),
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+
+        // Check regrading does not mess anything up.
+        $this->quba->regrade_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(0.5);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0.5),
+                $this->get_contains_partcorrect_expectation());
+
+        $autogradedstep = $this->get_step($this->get_step_count() - 2);
+        $this->assertWithinMargin($autogradedstep->get_fraction(), 0.6666667, 0.0000001);
+    }
+
+    public function test_interactive_finish_when_try_again_showing() {
+
+        // Create a multichoice single question.
+        $mc = test_question_maker::make_a_multichoice_single_question();
+        $mc->hints = array(
+            new question_hint_with_parts('This is the first hint.', false, false),
+        );
+        $this->start_attempt_at_question($mc, 'interactive');
+
+        $rightindex = $this->get_mc_right_answer_index($mc);
+        $wrongindex = ($rightindex + 1) % 3;
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_radio_expectation(0, true, false),
+                $this->get_contains_mc_radio_expectation(1, true, false),
+                $this->get_contains_mc_radio_expectation(2, true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(2),
+                $this->get_no_hint_visible_expectation(),
+                new PatternExpectation('/' . preg_quote(get_string('selectone', 'qtype_multichoice'), '/') . '/'));
+
+        // Submit the wrong answer.
+        $this->process_submission(array('answer' => $wrongindex, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_mc_radio_expectation($wrongindex, false, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_try_again_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'),
+                $this->get_contains_hint_expectation('This is the first hint'));
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedwrong);
+        $this->check_current_mark(0);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0),
+                $this->get_contains_mc_radio_expectation($wrongindex, false, true),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_mc_radio_expectation(($wrongindex + 1) % 3, false, false),
+                $this->get_contains_incorrect_expectation(),
+                $this->get_no_hint_visible_expectation());
+    }
+
+    public function test_interactive_shortanswer_try_to_submit_blank() {
+
+        // Create a short answer question.
+        $sa = test_question_maker::make_a_shortanswer_question();
+        $sa->hints = array(
+            new question_hint('This is the first hint.'),
+            new question_hint('This is the second hint.'),
+        );
+        $this->start_attempt_at_question($sa, 'interactive');
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_does_not_contain_validation_error_expectation(),
+                $this->get_does_not_contain_try_again_button_expectation(),
+                $this->get_no_hint_visible_expectation());
+
+        // Submit blank.
+        $this->process_submission(array('-submit' => 1, 'answer' => ''));
+
+        // Verify.
+        $this->check_current_state(question_state::$invalid);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_contains_validation_error_expectation(),
+                $this->get_does_not_contain_try_again_button_expectation(),
+                $this->get_no_hint_visible_expectation());
+
+        // Now get it wrong.
+        $this->process_submission(array('-submit' => 1, 'answer' => 'newt'));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_does_not_contain_validation_error_expectation(),
+                $this->get_contains_try_again_button_expectation(true),
+                new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'),
+                $this->get_contains_hint_expectation('This is the first hint'));
+        $this->assertEqual('newt',
+                $this->quba->get_response_summary($this->slot));
+
+        // Try again.
+        $this->process_submission(array('-tryagain' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_does_not_contain_validation_error_expectation(),
+                $this->get_does_not_contain_try_again_button_expectation(),
+                $this->get_no_hint_visible_expectation());
+
+        // Now submit blank again.
+        $this->process_submission(array('-submit' => 1, 'answer' => ''));
+
+        // Verify.
+        $this->check_current_state(question_state::$invalid);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_contains_validation_error_expectation(),
+                $this->get_does_not_contain_try_again_button_expectation(),
+                $this->get_no_hint_visible_expectation());
+
+        // Now get it right.
+        $this->process_submission(array('-submit' => 1, 'answer' => 'frog'));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(0.6666667);
+        $this->check_current_output(
+                $this->get_contains_mark_summary(0.6666667),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_correct_expectation(),
+                $this->get_does_not_contain_validation_error_expectation(),
+                $this->get_no_hint_visible_expectation());
+        $this->assertEqual('frog',
+                $this->quba->get_response_summary($this->slot));
+    }
+
+    public function test_interactive_feedback_multichoice_multiple_reset() {
+
+        // Create a multichoice multiple question.
+        $mc = test_question_maker::make_a_multichoice_multi_question();
+        $mc->hints = array(
+            new question_hint_with_parts('This is the first hint.', true, true),
+            new question_hint_with_parts('This is the second hint.', true, true),
+        );
+        $this->start_attempt_at_question($mc, 'interactive', 2);
+
+        $right = array_keys($mc->get_correct_response());
+        $wrong = array_diff(array('choice0', 'choice1', 'choice2', 'choice3'), $right);
+        $wrong = array_values(array_diff(array('choice0', 'choice1', 'choice2', 'choice3'), $right));
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_question_text_expectation($mc),
+                $this->get_contains_mc_checkbox_expectation('choice0', true, false),
+                $this->get_contains_mc_checkbox_expectation('choice1', true, false),
+                $this->get_contains_mc_checkbox_expectation('choice2', true, false),
+                $this->get_contains_mc_checkbox_expectation('choice3', true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_does_not_contain_num_parts_correct(),
+                $this->get_tries_remaining_expectation(3),
+                $this->get_no_hint_visible_expectation(),
+                new PatternExpectation('/' . preg_quote(get_string('selectmulti', 'qtype_multichoice'), '/') . '/'));
+
+        // Submit an answer with one right, and one wrong.
+        $this->process_submission(array($right[0] => 1, $wrong[0] => 1, '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_mc_checkbox_expectation($right[0], false, true),
+                $this->get_contains_mc_checkbox_expectation($right[1], false, false),
+                $this->get_contains_mc_checkbox_expectation($wrong[0], false, true),
+                $this->get_contains_mc_checkbox_expectation($wrong[1], false, false),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_try_again_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'),
+                $this->get_contains_hint_expectation('This is the first hint'),
+                $this->get_contains_num_parts_correct(1),
+                $this->get_contains_standard_incorrect_combined_feedback_expectation(),
+                $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . $right[0], '1'),
+                $this->get_does_not_contain_hidden_expectation($this->quba->get_field_prefix($this->slot) . $right[1]),
+                $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . $wrong[0], '0'),
+                $this->get_does_not_contain_hidden_expectation($this->quba->get_field_prefix($this->slot) . $wrong[1]));
+
+        // Do try again.
+        $this->process_submission(array($right[0] => 1, '-tryagain' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_marked_out_of_summary(),
+                $this->get_contains_mc_checkbox_expectation($right[0], true, true),
+                $this->get_contains_mc_checkbox_expectation($right[1], true, false),
+                $this->get_contains_mc_checkbox_expectation($wrong[0], true, false),
+                $this->get_contains_mc_checkbox_expectation($wrong[1], true, false),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(2),
+                $this->get_no_hint_visible_expectation());
+    }
+
+    public function test_interactive_regrade_changing_num_tries_leaving_open() {
+        // Create a multichoice multiple question.
+        $q = test_question_maker::make_a_shortanswer_question();
+        $q->hints = array(
+            new question_hint_with_parts('This is the first hint.', true, true),
+            new question_hint_with_parts('This is the second hint.', true, true),
+        );
+        $this->start_attempt_at_question($q, 'interactive', 3);
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_tries_remaining_expectation(3));
+
+        // Submit the right answer.
+        $this->process_submission(array('answer' => 'frog', '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(3);
+
+        // Now change the quiestion so that answer is only partially right, and regrade.
+        $q->answers[0]->fraction = 0.6666667;
+        $q->answers[1]->fraction = 1;
+
+        $this->quba->regrade_all_questions(false);
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+    }
+
+    public function test_interactive_regrade_changing_num_tries_finished() {
+        // Create a multichoice multiple question.
+        $q = test_question_maker::make_a_shortanswer_question();
+        $q->hints = array(
+            new question_hint_with_parts('This is the first hint.', true, true),
+            new question_hint_with_parts('This is the second hint.', true, true),
+        );
+        $this->start_attempt_at_question($q, 'interactive', 3);
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_tries_remaining_expectation(3));
+
+        // Submit the right answer.
+        $this->process_submission(array('answer' => 'frog', '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(3);
+
+        // Now change the quiestion so that answer is only partially right, and regrade.
+        $q->answers[0]->fraction = 0.6666667;
+        $q->answers[1]->fraction = 1;
+
+        $this->quba->regrade_all_questions(true);
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedpartial);
+        // TODO I don't think 1 is the right fraction here. However, it is what
+        // you get attempting a question like this without regrading being involved,
+        // and I am currently interested in testing regrading here. 
+        $this->check_current_mark(1);
+    }
+}
diff --git a/question/behaviour/interactivecountback/behaviour.php b/question/behaviour/interactivecountback/behaviour.php
new file mode 100644 (file)
index 0000000..8a54839
--- /dev/null
@@ -0,0 +1,91 @@
+<?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/>.
+
+
+/**
+ * Question behaviour that is like the interactive behaviour, but where the
+ * student is credited for parts of the question they got right on earlier tries.
+ *
+ * @package qbehaviour_interactivecountback
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../interactive/behaviour.php');
+
+/**
+ * Question behaviour for interactive mode with count-back scoring.
+ *
+ * As an example, suppose we have a matching question with 4 parts, and 3 tries
+ * (penalty 1/3), and the question is worth 12 marks (so, 3 marks for each part).
+ * Suppose also that:
+ *  - on the first try, the student gets the first two parts right, and the
+ *    other two wrong.
+ *  - on the second try, they are sure they got the first part right, so keep
+ *    their answer the same, but they change their answer to the second part.
+ *    They also get the answer to the thrid part right on this try, but still
+ *    get the 4th part wrong.
+ *  - On the final try, they get the first 3 parts right, but the 4th part still
+ *    wrong.
+ * We want to grade them as follows.
+ *  - For the first part, they were right first time, and did not change their
+ *    answer, so we credit that part as right first time: 3/3
+ *  - For the second part, although they were right first time, they then changed
+ *    their mind, an only finally got it right on the third try, so 1/3.
+ *  - For the third part, they got it right on the second try, and then did not
+ *    change their answer, so 2/3.
+ *  - For the last part, they were wrong at the last try, so 0/3.
+ * So, total mark is 6/12. (Really, a fraction of 0.5.)
+ *
+ * Of course, the details of the grading are acutally up to the particular
+ * question type. The point is that the final grade can take into account all
+ * of the tries the student made.
+ *
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_interactivecountback extends qbehaviour_interactive {
+    const IS_ARCHETYPAL = false;
+
+    public function required_question_definition_type() {
+        return 'question_automatically_gradable_with_countback';
+    }
+
+    protected function adjust_fraction($fraction, question_attempt_pending_step $pendingstep) {
+        $totaltries = $this->qa->get_step(0)->get_behaviour_var('_triesleft');
+
+        $responses = array();
+        $lastsave = array();
+        foreach ($this->qa->get_step_iterator() as $step) {
+            if ($step->has_behaviour_var('submit') && $step->get_state() != question_state::$invalid) {
+                $responses[] = $step->get_qt_data();
+                $lastsave = array();
+            } else {
+                $lastsave = $step->get_qt_data();
+            }
+        }
+        $lastresponse = $pendingstep->get_qt_data();
+        if (!empty($lastresponse)) {
+            $responses[] = $lastresponse;
+        } else if (!empty($lastsave)) {
+            $responses[] = $lastsave;
+        }
+
+        return $this->question->compute_final_grade($responses, $totaltries);
+    }
+}
diff --git a/question/behaviour/interactivecountback/lang/en_utf8/qbehaviour_interactivecountback.php b/question/behaviour/interactivecountback/lang/en_utf8/qbehaviour_interactivecountback.php
new file mode 100644 (file)
index 0000000..d8146ea
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['interactivecountback'] = 'Interactive with multiple tries (credit for earlier tries)';
diff --git a/question/behaviour/interactivecountback/renderer.php b/question/behaviour/interactivecountback/renderer.php
new file mode 100644 (file)
index 0000000..cb6424c
--- /dev/null
@@ -0,0 +1,36 @@
+<?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/>.
+
+
+require_once(dirname(__FILE__) . '/../interactive/renderer.php');
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the interactive with
+ * countback behaviour.
+ *
+ * There are not differences from the interactive output. We just need a class
+ * definition.
+ *
+ * @package qbehaviour_interactivecountback
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+class qbehaviour_interactivecountback_renderer extends qbehaviour_interactive_renderer {
+}
\ No newline at end of file
diff --git a/question/behaviour/interactivecountback/simpletest/testwalkthrough.php b/question/behaviour/interactivecountback/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..14de088
--- /dev/null
@@ -0,0 +1,135 @@
+<?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 that walks a question through the interactive with
+ * countback behaviour.
+ *
+ * @package qbehaviour_interactivecountback
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_interactivecountback_walkthrough_test extends qbehaviour_walkthrough_test_base {
+    public function test_interactive_feedback_match_reset() {
+
+        // Create a matching question.
+        $m = test_question_maker::make_a_matching_question();
+        $m->shufflestems = false;
+        $m->hints = array(
+            new question_hint_with_parts('This is the first hint.', true, true),
+            new question_hint_with_parts('This is the second hint.', true, true),
+        );
+        $this->start_attempt_at_question($m, 'interactive', 12);
+
+        $choiceorder = $m->get_choice_order();
+        $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder));
+        $choices = array(0 => get_string('choose') . '...');
+        foreach ($choiceorder as $key => $choice) {
+            $choices[$key] = $m->choices[$choice];
+        }
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->assertEqual('interactivecountback',
+                $this->quba->get_question_attempt($this->slot)->get_behaviour_name());
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, null, true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, null, true),
+                $this->get_contains_question_text_expectation($m),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(3),
+                $this->get_does_not_contain_num_parts_correct(),
+                $this->get_no_hint_visible_expectation());
+
+        // Submit an answer with two right, and two wrong.
+        $this->process_submission(array('sub0' => $orderforchoice[1],
+                'sub1' => $orderforchoice[1], 'sub2' => $orderforchoice[1],
+                'sub3' => $orderforchoice[1], '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], false),
+                $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[1], false),
+                $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[1], false),
+                $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], false),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_contains_try_again_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_interactive')) . '/'),
+                $this->get_contains_hint_expectation('This is the first hint'),
+                $this->get_contains_num_parts_correct(2),
+                $this->get_contains_standard_partiallycorrect_combined_feedback_expectation(),
+                $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . 'sub0', $orderforchoice[1]),
+                $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . 'sub1', '0'),
+                $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . 'sub2', '0'),
+                $this->get_contains_hidden_expectation($this->quba->get_field_prefix($this->slot) . 'sub3', $orderforchoice[1]));
+
+        // Check that extract responses will return the reset data.
+        $prefix = $this->quba->get_field_prefix($this->slot);
+        $this->assertEqual(array('sub0' => 1),
+                $this->quba->extract_responses($this->slot, array($prefix . 'sub0' => 1)));
+
+        // Do try again.
+        $this->process_submission(array('sub0' => $orderforchoice[1], 'sub3' => $orderforchoice[1], '-tryagain' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], true),
+                $this->get_contains_select_expectation('sub1', $choices, null, true),
+                $this->get_contains_select_expectation('sub2', $choices, null, true),
+                $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], true),
+                $this->get_contains_submit_button_expectation(true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation(),
+                $this->get_tries_remaining_expectation(2),
+                $this->get_no_hint_visible_expectation());
+
+        // Submit the right answer.
+        $this->process_submission(array('sub0' => $orderforchoice[1],
+                'sub1' => $orderforchoice[2], 'sub2' => $orderforchoice[2],
+                'sub3' => $orderforchoice[1], '-submit' => 1));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(10);
+        $this->check_current_output(
+                $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[1], false),
+                $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[2], false),
+                $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[2], false),
+                $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[1], false),
+                $this->get_contains_submit_button_expectation(false),
+                $this->get_does_not_contain_try_again_button_expectation(),
+                $this->get_contains_correct_expectation(),
+                $this->get_contains_standard_correct_combined_feedback_expectation(),
+                new NoPatternExpectation('/class="control\b[^"]*\bpartiallycorrect"/'));
+    }
+}
+
diff --git a/question/behaviour/manualgraded/behaviour.php b/question/behaviour/manualgraded/behaviour.php
new file mode 100644 (file)
index 0000000..ac81e83
--- /dev/null
@@ -0,0 +1,92 @@
+<?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/>.
+
+
+/**
+ * Question behaviour for questions that can only be graded manually.
+ *
+ * @package qbehaviour_manualgraded
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * Question behaviour for questions that can only be graded manually.
+ *
+ * The student enters their response during the attempt, and it is saved. Later,
+ * when the whole attempt is finished, the attempt goes into the NEEDS_GRADING
+ * state, and the teacher must grade it manually.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_manualgraded extends question_behaviour_with_save {
+    const IS_ARCHETYPAL = true;
+
+    public static function get_unused_display_options() {
+        return array('correctness', 'marks', 'specificfeedback', 'generalfeedback',
+                'rightanswer');
+    }
+
+    public function adjust_display_options(question_display_options $options) {
+        parent::adjust_display_options($options);
+
+        if ($this->qa->get_state()->is_finished()) {
+            // Hide all feedback except genfeedback and manualcomment.
+            $save = clone($options);
+            $options->hide_all_feedback();
+            $options->generalfeedback = $save->generalfeedback;
+            $options->manualcomment = $save->manualcomment;
+        }
+    }
+
+    public function process_action(question_attempt_pending_step $pendingstep) {
+        if ($pendingstep->has_behaviour_var('comment')) {
+            return $this->process_comment($pendingstep);
+        } else if ($pendingstep->has_behaviour_var('finish')) {
+            return $this->process_finish($pendingstep);
+        } else {
+            return $this->process_save($pendingstep);
+        }
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        if ($step->has_behaviour_var('comment')) {
+            return $this->summarise_manual_comment($step);
+        } else if ($step->has_behaviour_var('finish')) {
+            return $this->summarise_finish($step);
+        } else {
+            return $this->summarise_save($step);
+        }
+    }
+
+    public function process_finish(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        $response = $this->qa->get_last_step()->get_qt_data();
+        if (!$this->question->is_complete_response($response)) {
+            $pendingstep->set_state(question_state::$gaveup);
+        } else {
+            $pendingstep->set_state(question_state::$needsgrading);
+        }
+        $pendingstep->set_new_response_summary($this->question->summarise_response($response));
+        return question_attempt::KEEP;
+    }
+}
diff --git a/question/behaviour/manualgraded/lang/en_utf8/qbehaviour_manualgraded.php b/question/behaviour/manualgraded/lang/en_utf8/qbehaviour_manualgraded.php
new file mode 100644 (file)
index 0000000..d15f8c3
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['manualgraded'] = 'Manually graded';
diff --git a/question/behaviour/manualgraded/renderer.php b/question/behaviour/manualgraded/renderer.php
new file mode 100644 (file)
index 0000000..7913beb
--- /dev/null
@@ -0,0 +1,31 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question belonging to the manual
+ * graded behaviour.
+ *
+ * @package qbehaviour_manualgraded
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+class qbehaviour_manualgraded_renderer extends qbehaviour_renderer {
+
+}
\ No newline at end of file
diff --git a/question/behaviour/manualgraded/simpletest/testwalkthrough.php b/question/behaviour/manualgraded/simpletest/testwalkthrough.php
new file mode 100644 (file)
index 0000000..3221bae
--- /dev/null
@@ -0,0 +1,259 @@
+<?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 that walks a question through the manual graded
+ * behaviour.
+ *
+ * @package qbehaviour_manualgraded
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+
+class qbehaviour_manualgraded_walkthrough_test extends qbehaviour_walkthrough_test_base {
+    public function test_manual_graded_essay() {
+
+        // Create an essay question.
+        $essay = test_question_maker::make_an_essay_question();
+        $this->start_attempt_at_question($essay, 'deferredfeedback', 10);
+
+        // Check the right model is being used.
+        $this->assertEqual('manualgraded', $this->quba->
+                get_question_attempt($this->slot)->get_behaviour_name());
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output($this->get_contains_question_text_expectation($essay),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Simulate some data submitted by the student.
+        $this->process_submission(array('answer' => 'This is my wonderful essay!'));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new ContainsTagWithAttribute('textarea', 'name',
+                $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process the same data again, check it does not create a new step.
+        $numsteps = $this->get_step_count();
+        $this->process_submission(array('answer' => 'This is my wonderful essay!'));
+        $this->check_step_count($numsteps);
+
+        // Process different data, check it creates a new step.
+        $this->process_submission(array('answer' => ''));
+        $this->check_step_count($numsteps + 1);
+        $this->check_current_state(question_state::$todo);
+
+        // Change back, check it creates a new step.
+        $this->process_submission(array('answer' => 'This is my wonderful essay!'));
+        $this->check_step_count($numsteps + 2);
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$needsgrading);
+        $this->check_current_mark(null);
+        $this->assertEqual('This is my wonderful essay!',
+                $this->quba->get_response_summary($this->slot));
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 10);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrright);
+        $this->check_current_mark(10);
+        $this->check_current_output(
+                new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+
+        // Now change the max mark for the question and regrade.
+        $this->quba->regrade_question($this->slot, true, 1);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrright);
+        $this->check_current_mark(1);
+    }
+
+    public function test_manual_graded_truefalse() {
+
+        // Create a true-false question with correct answer true.
+        $tf = test_question_maker::make_a_truefalse_question();
+        $this->start_attempt_at_question($tf, 'manualgraded', 2);
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($tf),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Process a true answer and check the expected result.
+        $this->process_submission(array('answer' => 1));
+
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_contains_tf_true_radio_expectation(true, true),
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$needsgrading);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                $this->get_does_not_contain_correctness_expectation(),
+                $this->get_does_not_contain_specific_feedback_expectation());
+
+        // Process a manual comment.
+        $this->manual_grade('Not good enough!', 1);
+
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(1);
+        $this->check_current_output(
+            $this->get_does_not_contain_correctness_expectation(),
+            $this->get_does_not_contain_specific_feedback_expectation(),
+            new PatternExpectation('/' . preg_quote('Not good enough!') . '/'));
+    }
+
+    public function test_manual_graded_ignore_repeat_sumbission() {
+        // Create an essay question.
+        $essay = test_question_maker::make_an_essay_question();
+        $this->start_attempt_at_question($essay, 'deferredfeedback', 10);
+
+        // Check the right model is being used.
+        $this->assertEqual('manualgraded', $this->quba->
+                get_question_attempt($this->slot)->get_behaviour_name());
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+
+        // Simulate some data submitted by the student.
+        $this->process_submission(array('answer' => 'This is my wonderful essay!'));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$needsgrading);
+        $this->check_current_mark(null);
+        $this->assertEqual('This is my wonderful essay!',
+                $this->quba->get_response_summary($this->slot));
+
+        // Process a blank manual comment. Ensure it does not change the state.
+        $numsteps = $this->get_step_count();
+        $this->manual_grade('', '');
+        $this->check_step_count($numsteps);
+        $this->check_current_state(question_state::$needsgrading);
+        $this->check_current_mark(null);
+
+        // Process a comment, but with the mark blank. Should be recorded, but
+        // not change the mark.
+        $this->manual_grade('I am not sure what grade to award.', '');
+        $this->check_step_count($numsteps + 1);
+        $this->check_current_state(question_state::$needsgrading);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/' . preg_quote('I am not sure what grade to award.') . '/'));
+
+        // Now grade it.
+        $this->manual_grade('Pretty good!', '9.00000');
+        $this->check_step_count($numsteps + 2);
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(9);
+        $this->check_current_output(
+                new PatternExpectation('/' . preg_quote('Pretty good!') . '/'));
+
+        // Process the same data again, and make sure it does not add a step.
+        $this->manual_grade('Pretty good!', '9.00000');
+        $this->check_step_count($numsteps + 2);
+        $this->check_current_state(question_state::$mangrpartial);
+        $this->check_current_mark(9);
+
+        // Now set the mark back to blank.
+        $this->manual_grade('Actually, I am not sure any more.', '');
+        $this->check_step_count($numsteps + 3);
+        $this->check_current_state(question_state::$needsgrading);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/' . preg_quote('Actually, I am not sure any more.') . '/'));
+
+        $qa = $this->quba->get_question_attempt($this->slot);
+        $this->assertEqual('Commented: Actually, I am not sure any more.',
+                $qa->summarise_action($qa->get_last_step()));
+    }
+
+    public function test_manual_graded_essay_can_grade_0() {
+
+        // Create an essay question.
+        $essay = test_question_maker::make_an_essay_question();
+        $this->start_attempt_at_question($essay, 'deferredfeedback', 10);
+
+        // Check the right model is being used.
+        $this->assertEqual('manualgraded', $this->quba->
+                get_question_attempt($this->slot)->get_behaviour_name());
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output($this->get_contains_question_text_expectation($essay),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Simulate some data submitted by the student.
+        $this->process_submission(array('answer' => 'This is my wonderful essay!'));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new ContainsTagWithAttribute('textarea', 'name',
+                $this->quba->get_question_attempt($this->slot)->get_qt_field_name('answer')),
+                $this->get_does_not_contain_feedback_expectation());
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$needsgrading);
+        $this->check_current_mark(null);
+        $this->assertEqual('This is my wonderful essay!',
+                $this->quba->get_response_summary($this->slot));
+
+        // Process a blank comment and a grade of 0.
+        $this->manual_grade('', 0);
+
+        // Verify.
+        $this->check_current_state(question_state::$mangrwrong);
+        $this->check_current_mark(0);
+    }
+}
diff --git a/question/behaviour/missing/behaviour.php b/question/behaviour/missing/behaviour.php
new file mode 100644 (file)
index 0000000..edcc73e
--- /dev/null
@@ -0,0 +1,65 @@
+<?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/>.
+
+
+/**
+ * Fake question behaviour that is used when the actual qim was not
+ * available.
+ *
+ * @package qbehaviour_missing
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * Fake question behaviour that is used when the actual behaviour
+ * is not available.
+ *
+ * Imagine, for example, that a quiz attempt has been restored from another
+ * Moodle site with more behaviours installed, or an behaviour
+ * that used to be available in this site has been uninstalled. Obviously all we
+ * can do is have some code to prevent fatal errors.
+ *
+ * The approach we take is: The rendering code is still implemented, as far as
+ * possible. A warning is shown that behaviour specific bits may be missing.
+ * Any attempt to process anything causes an exception to be thrown.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_missing extends question_behaviour {
+    public function required_question_definition_type() {
+        return 'question_definition';
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        return '';
+    }
+
+    public function init_first_step(question_attempt_step $step) {
+        throw new Exception('The behaviour used for this question is not available. No processing is possible.');
+    }
+
+    public function process_action(question_attempt_pending_step $pendingstep) {
+        throw new Exception('The behaviour used for this question is not available. No processing is possible.');
+    }
+
+    public function get_min_fraction() {
+        throw new Exception('The behaviour used for this question is not available. No processing is possible.');
+    }
+}
diff --git a/question/behaviour/missing/lang/en_utf8/qbehaviour_missing.php b/question/behaviour/missing/lang/en_utf8/qbehaviour_missing.php
new file mode 100644 (file)
index 0000000..c684c0c
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['questionusedunknownmodel'] = 'This question was attempted with an behaviour that is not currently availalbe. We are attempting to show the question, but there may be problems.';
diff --git a/question/behaviour/missing/renderer.php b/question/behaviour/missing/renderer.php
new file mode 100644 (file)
index 0000000..4174e40
--- /dev/null
@@ -0,0 +1,35 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question when the actual behaviour
+ * used is not available.
+ *
+ * @package qbehaviour_missing
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+class qbehaviour_missing_renderer extends qbehaviour_renderer {
+    public function controls(question_attempt $qa, question_display_options $options) {
+        return html_writer::tag('div',
+                get_string('questionusedunknownmodel', 'qbehaviour_missing'),
+                array('class' => 'warning'));
+    }
+}
\ No newline at end of file
diff --git a/question/behaviour/missing/simpletest/testmissingbehaviour.php b/question/behaviour/missing/simpletest/testmissingbehaviour.php
new file mode 100644 (file)
index 0000000..a4bd3df
--- /dev/null
@@ -0,0 +1,96 @@
+<?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 'missing' behaviour.
+ *
+ * @package qbehaviour_missing
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+require_once(dirname(__FILE__) . '/../behaviour.php');
+
+class qbehaviour_missing_test extends UnitTestCase {
+    public function test_missing_cannot_start() {
+        $qa = new question_attempt(test_question_maker::make_a_truefalse_question(), 0);
+        $behaviour = new qbehaviour_missing($qa, 'deferredfeedback');
+        $this->expectException();
+        $behaviour->init_first_step(new question_attempt_step(array()));
+    }
+
+    public function test_missing_cannot_process() {
+        $qa = new question_attempt(test_question_maker::make_a_truefalse_question(), 0);
+        $behaviour = new qbehaviour_missing($qa, 'deferredfeedback');
+        $this->expectException();
+        $behaviour->process_action(new question_attempt_pending_step(array()));
+    }
+
+    public function test_missing_cannot_get_min_grade() {
+        $qa = new question_attempt(test_question_maker::make_a_truefalse_question(), 0);
+        $behaviour = new qbehaviour_missing($qa, 'deferredfeedback');
+        $this->expectException();
+        $behaviour->get_min_fraction();
+    }
+
+    public function test_render_missing() {
+        $records = testing_db_record_builder::build_db_records(array(
+            array('id', 'questionattemptid', 'questionusageid', 'slot',
+                              'behaviour', 'questionid', 'maxmark', 'minfraction', 'flagged',
+                                                                            'questionsummary', 'rightanswer', 'responsesummary', 'timemodified',
+                                                                                                   'attemptstepid', 'sequencenumber', 'state', 'fraction',
+                                                                                                                          'timecreated', 'userid', 'name', 'value'),
+            array(1, 1, 1, 1, 'strangeunknown', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 1, 0, 'todo',     null, 1256233700, 1,   '_order', '1,2,3'),
+            array(2, 1, 1, 1, 'strangeunknown', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', 0.50, 1256233705, 1,  '-submit',  '1'),
+            array(3, 1, 1, 1, 'strangeunknown', -1, 2.0000000, 0.0000000, 0, '', '', '', 1256233790, 2, 1, 'complete', 0.50, 1256233705, 1,  'choice0',  '1'),
+        ));
+
+        $question = test_question_maker::make_a_truefalse_question();
+        $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->assertEqual(2, $qa->get_num_steps());
+
+        $step = $qa->get_step(0);
+        $this->assertEqual(question_state::$todo, $step->get_state());
+        $this->assertNull($step->get_fraction());
+        $this->assertEqual(1256233700, $step->get_timecreated());
+        $this->assertEqual(1, $step->get_user_id());
+        $this->assertEqual(array('_order' => '1,2,3'), $step->get_all_data());
+
+        $step = $qa->get_step(1);
+        $this->assertEqual(question_state::$complete, $step->get_state());
+        $this->assertEqual(0.5, $step->get_fraction());
+        $this->assertEqual(1256233705, $step->get_timecreated());
+        $this->assertEqual(1, $step->get_user_id());
+        $this->assertEqual(array('-submit' => '1', 'choice0' => '1'), $step->get_all_data());
+
+        $output = $qa->render(new question_display_options(), '1');
+        $this->assertPattern('/' . preg_quote($qa->get_question()->questiontext) . '/', $output);
+        $this->assertPattern('/' . preg_quote(get_string('questionusedunknownmodel', 'qbehaviour_missing')) . '/', $output);
+        $this->assert(new ContainsTagWithAttribute('div', 'class', 'warning'), $output);
+    }
+}
diff --git a/question/behaviour/opaque/behaviour.php b/question/behaviour/opaque/behaviour.php
new file mode 100644 (file)
index 0000000..c43f68b
--- /dev/null
@@ -0,0 +1,197 @@
+<?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 behaviour that is used when the actual qim was not
+ * available.
+ *
+ * @package qbehaviour_opaque
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * This behaviour is specifically for use with the Opaque question type.
+ *
+ *
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qbehaviour_opaque extends question_behaviour {
+    /** @var string */
+    protected $preferredbehaviour;
+    /** @var string */
+    protected $questionsummary;
+
+    public function __construct(question_attempt $qa, $preferredbehaviour) {
+        parent::__construct($qa, $preferredbehaviour);
+        $this->preferredbehaviour = $preferredbehaviour;
+    }
+
+    public function required_question_definition_type() {
+        return 'qtype_opaque_question';
+    }
+
+    public function get_state_string($showcorrectness) {
+        $state = $this->qa->get_state();
+        $omstate = $this->qa->get_last_behaviour_var('_statestring');
+        if ($state->is_finished()) {
+            return $state->default_string($showcorrectness);
+        } else if ($omstate) {
+            return $omstate;
+        } else {
+            return get_string('notcomplete', 'qbehaviour_opaque');
+        }
+    }
+
+    public function init_first_step(question_attempt_step $step) {
+        global $USER;
+
+        if ($step->has_behaviour_var('_randomseed')) {
+            // Reinitialising, nothing to do.
+            return;
+        }
+
+        // Set up the random seed to be the current time in milliseconds.
+        list($micros, $sec) = explode(" ", microtime());
+        $step->set_behaviour_var('_randomseed', $sec . floor($micros * 1000));
+        $step->set_behaviour_var('_userid', $USER->id);
+        $step->set_behaviour_var('_language', current_language());
+        $step->set_behaviour_var('_preferredbehaviour', $this->preferredbehaviour);
+        $opaquestate = update_opaque_state($this->qa, $step);
+        $step->set_behaviour_var('_statestring', $opaquestate->progressinfo);
+
+        // Remember the question summary.
+        $this->questionsummary = html_to_text($opaquestate->xhtml, 0, false);
+    }
+
+    public function get_question_summary() {
+        return $this->questionsummary;
+    }
+
+    protected function is_same_response(question_attempt_step $pendingstep) {
+        $newdata = $pendingstep->get_submitted_data();
+
+        foreach ($newdata as $key => $ignored) {
+            // If an omact_ button has been clicked, never treat this as a duplicate submission.
+            if (strpos($key, 'omact_') === 0) {
+                return false;
+            }
+        }
+
+        $olddata = $this->qa->get_last_step()->get_submitted_data();
+        return question_utils::arrays_have_same_keys_and_values($newdata, $olddata);
+    }
+
+    public function summarise_action(question_attempt_step $step) {
+        if ($step->has_behaviour_var('finish')) {
+            return $this->summarise_finish($step);
+        } else if ($step->has_behaviour_var('comment')) {
+            return $this->summarise_manual_comment($step);
+        } else {
+            $data = $step->get_qt_data();
+            $formatteddata = array();
+            foreach ($data as $name => $value) {
+                if (substr($name, 0, 1) == '_') {
+                    continue;
+                }
+                $formatteddata[] = $name . ' => ' . s($value);
+            }
+            if ($formatteddata) {
+                return get_string('submitted', 'question', implode(', ', $formatteddata));
+            } else {
+                return $this->summarise_start($step);
+            }
+        }
+    }
+
+    public function process_action(question_attempt_pending_step $pendingstep) {
+        if ($pendingstep->has_behaviour_var('finish')) {
+            return $this->process_finish($pendingstep);
+        }
+        if ($pendingstep->has_behaviour_var('comment')) {
+            return $this->process_comment($pendingstep);
+        } else if ($this->is_same_response($pendingstep) ||
+                $this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        } else {
+            return $this->process_remote_action($pendingstep);
+        }
+    }
+
+    public function process_finish(question_attempt_pending_step $pendingstep) {
+        if ($this->qa->get_state()->is_finished()) {
+            return question_attempt::DISCARD;
+        }
+
+        // They tried to finish the usage without having finished this question.
+        // That is, they gave up.
+        $pendingstep->set_state(question_state::$gaveup);
+        return question_attempt::KEEP;
+    }
+
+    public function process_remote_action(question_attempt_pending_step $pendingstep) {
+        $opaquestate = update_opaque_state($this->qa, $pendingstep);
+
+        if (is_string($opaquestate)) {
+            notify($opaquestate);
+            return question_attempt::DISCARD; // TODO
+        }
+
+        if ($opaquestate->resultssequencenumber != $this->qa->get_num_steps()) {
+            $pendingstep->set_state(question_state::$todo);
+            $pendingstep->set_behaviour_var('_statestring', $opaquestate->progressinfo);
+
+        } else {
+            // Look for a score on the default axis.
+            $pendingstep->set_fraction(0);
+            foreach ($opaquestate->results->scores as $score) {
+                if ($score->axis == '') {
+                    $pendingstep->set_fraction($score->marks / $this->question->defaultmark);
+                }
+            }
+
+            if ($opaquestate->results->attempts > 0) {
+                $pendingstep->set_state(question_state::$gradedright);
+            } else {
+                $pendingstep->set_state(
+                        question_state::graded_state_for_fraction($pendingstep->get_fraction()));
+            }
+
+            if (!empty($opaquestate->results->questionLine)) {
+                $this->qa->set_question_summary(
+                        $this->cleanup_results($opaquestate->results->questionLine));
+            }
+            if (!empty($opaquestate->results->answerLine)) {
+                $pendingstep->set_new_response_summary(
+                        $this->cleanup_results($opaquestate->results->answerLine));
+            }
+            if (!empty($opaquestate->results->actionSummary)) {
+                $pendingstep->set_behaviour_var('_actionsummary',
+                        $this->cleanup_results($opaquestate->results->actionSummary));
+            }
+        }
+
+        return question_attempt::KEEP;
+    }
+
+    protected function cleanup_results($line) {
+        return preg_replace('/\\s+/', ' ', $line);
+    }
+}
diff --git a/question/behaviour/opaque/lang/en_utf8/qbehaviour_opaque.php b/question/behaviour/opaque/lang/en_utf8/qbehaviour_opaque.php
new file mode 100644 (file)
index 0000000..aa11ab5
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+$string['notcomplete'] = 'Not complete';
+$string['opaque'] = 'Question managed by a remote engine';
diff --git a/question/behaviour/opaque/renderer.php b/question/behaviour/opaque/renderer.php
new file mode 100644 (file)
index 0000000..d3247c9
--- /dev/null
@@ -0,0 +1,65 @@
+<?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/>.
+
+
+/**
+ * Renderer for outputting parts of a question when the actual behaviour
+ * used is not available.
+ *
+ * @package qbehaviour_opaque
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+class qbehaviour_opaque_renderer extends qbehaviour_renderer {
+    public function controls(question_attempt $qa, question_display_options $options) {
+        if ($qa->get_state()->is_gave_up()) {
+            return html_writer::tag('div', get_string('notcompletedmessage', 'qtype_opaque'),
+                    array('class' => 'question_aborted'));
+        }
+
+        $opaquestate =& update_opaque_state($qa);
+        if (is_string($opaquestate)) {
+            return notify($opaquestate, '', '', true);
+            // TODO
+        }
+
+        return html_writer::tag('div', $opaquestate->xhtml,
+                array('class' => opaque_browser_type()));
+    }
+
+    public function head_code(question_attempt $qa) {
+        $output = '';
+        $opaquestate =& update_opaque_state($qa);
+
+        $question = $qa->get_question();
+        $resourcecache = new opaque_resource_cache($question->engineid,
+                $question->remoteid, $question->remoteversion);
+
+        if (!empty($opaquestate->cssfilename) && $resourcecache->file_in_cache($opaquestate->cssfilename)) {
+            $output .= '<link rel="stylesheet" type="text/css" href="' .
+                    $resourcecache->file_url($opaquestate->cssfilename) . '" />';
+        }
+
+        if(!empty($opaquestate->headXHTML)) {
+            $output .= $opaquestate->headXHTML;
+        }
+
+        return $output;
+    }
+}
\ No newline at end of file
diff --git a/question/behaviour/opaque/simpletest/testopaquebehaviour.php b/question/behaviour/opaque/simpletest/testopaquebehaviour.php
new file mode 100644 (file)
index 0000000..6040b55
--- /dev/null
@@ -0,0 +1,227 @@
+<?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 Opaque behaviour.
+ *
+ * @package qbehaviour_opaque
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/../../../engine/lib.php');
+require_once(dirname(__FILE__) . '/../../../engine/simpletest/helpers.php');
+require_once(dirname(__FILE__) . '/../behaviour.php');
+
+class qbehaviour_opaque_test extends qbehaviour_walkthrough_test_base {
+    /**
+     * Makes an Opaque question that refers to one of the sample questions
+     * supplied by OpenMark.
+     * @return unknown_type
+     */
+    protected function make_standard_om_question() {
+        $engineid = get_field('question_opaque_engines', 'MIN(id)', '', '');
+        if (empty($engineid)) {
+            throw new Exception('Cannot test Opaque. No question engines configured.');
+        }
+
+        question_bank::load_question_definition_classes('opaque');
+        $q = new qtype_opaque_question();
+        test_question_maker::initialise_a_question($q);
+
+        $q->name = 'samples.mu120.module5.question01';
+        $q->qtype = question_bank::get_qtype('opaque');
+        $q->defaultmark = 3;
+
+        $q->engineid = $engineid;
+        $q->remoteid = 'samples.mu120.module5.question01';
+        $q->remoteversion = '1.0';
+
+        return $q;
+    }
+
+    public function test_wrong_three_times() {
+        $q = $this->make_standard_om_question();
+        $this->start_attempt_at_question($q, 'interactive');
+        $qa = $this->quba->get_question_attempt($this->slot);
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/Below is a plan of a proposed garden/'),
+                new PatternExpectation('/You have 3 attempts/'),
+                $this->get_contains_button_expectation($qa->get_qt_field_name('omact_gen_14'), 'Check'));
+        $this->assertPattern('/^\s*Below is a plan of a proposed garden./',
+                $qa->get_question_summary());
+        $this->assertNull($qa->get_right_answer_summary());
+
+        // Submit a wrong answer.
+        $this->process_submission(array('omval_response1' => 1, 'omval_response2' => 666,
+                'omact_gen_14' => 'Check'));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/Below is a plan of a proposed garden/'),
+                new PatternExpectation('/incorrect/'),
+                new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_opaque')) . '/'),
+                $this->get_contains_button_expectation($qa->get_qt_field_name('omact_ok'), 'Try again'));
+
+        // Try again.
+        $this->process_submission(array('omact_ok' => 'Try again'));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/You have 2 attempts/'));
+
+        // Submit a wrong answer again.
+        $this->process_submission(array('omval_response1' => 1, 'omval_response2' => 666,
+                'omact_gen_14' => 'Check'));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/Below is a plan of a proposed garden/'),
+                new PatternExpectation('/still incorrect/'),
+                new PatternExpectation('/' . preg_quote(get_string('notcomplete', 'qbehaviour_opaque')) . '/'));
+
+        // Try again.
+        $this->process_submission(array('omact_ok' => 'Try again'));
+
+        // Verify.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/This is your last attempt/'));
+
+        // Submit a wrong answer third time.
+        $this->process_submission(array('omval_response1' => 1, 'omval_response2' => 666,
+                'omact_gen_14' => 'Check'));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedwrong);
+        $this->check_current_mark(0);
+        $this->check_current_output(
+                new PatternExpectation('/Please see MU120 Preparatory Resource Book B section 5.1/'),
+                new PatternExpectation('/still incorrect/'));
+        $this->assertTrue(preg_match('/What is \(X\*W\) (\d+\.\d+)\*(\d+), \(X\*L\)(\d+\.\d+)\*(\d+)\?/',
+                $qa->get_question_summary(), $matches));
+        $this->assertNull($qa->get_right_answer_summary());
+        $this->assertPattern('/' . $matches[1]*$matches[2] . '.*, ' . $matches[3]*$matches[4] . '/',
+                $qa->get_response_summary());
+    }
+
+    public function test_right_first_time() {
+        $q = $this->make_standard_om_question();
+        $this->start_attempt_at_question($q, 'interactive');
+        $qa = $this->quba->get_question_attempt($this->slot);
+
+        // Work out right answer (yuck!)
+        $html = $this->quba->render_question($this->slot, $this->displayoptions);
+        preg_match('/(0\.5|2\.0|3\.0) metres/', $html, $matches);
+        $scale = $matches[1];
+        preg_match('/Patio|Summer House|Flowerbed|Vegetable Plot|Pond/', $html, $matches);
+        $feature = $matches[0];
+        $sizes = array(
+            'Patio' => array(4, 7),
+            'Summer House' => array(3, 5),
+            'Flowerbed' => array(2, 7),
+            'Vegetable Plot' => array(3, 10),
+            'Pond' => array(2, 3),
+        );
+        $size = $sizes[$feature];
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/Below is a plan of a proposed garden/'),
+                new PatternExpectation('/You have 3 attempts/'),
+                $this->get_contains_button_expectation($qa->get_qt_field_name('omact_gen_14'), 'Check'));
+
+        // Submit the right answer.
+        $this->process_submission(array('omval_response1' => $size[0] * $scale,
+                'omval_response2' => $size[1] * $scale, 'omact_gen_14' => 'Check'));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(3);
+        $this->check_current_output(
+                new PatternExpectation('/Below is a plan of a proposed garden/'),
+                new PatternExpectation('/correct/'));
+    }
+
+    public function test_different_max() {
+        $q = $this->make_standard_om_question();
+        $this->start_attempt_at_question($q, 'interactive', 6.0);
+        $qa = $this->quba->get_question_attempt($this->slot);
+
+        // Work out right answer (yuck!)
+        $html = $this->quba->render_question($this->slot, $this->displayoptions);
+        preg_match('/(0\.5|2\.0|3\.0) metres/', $html, $matches);
+        $scale = $matches[1];
+        preg_match('/Patio|Summer House|Flowerbed|Vegetable Plot|Pond/', $html, $matches);
+        $feature = $matches[0];
+        $sizes = array(
+            'Patio' => array(4, 7),
+            'Summer House' => array(3, 5),
+            'Flowerbed' => array(2, 7),
+            'Vegetable Plot' => array(3, 10),
+            'Pond' => array(2, 3),
+        );
+        $size = $sizes[$feature];
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/Below is a plan of a proposed garden/'),
+                new PatternExpectation('/You have 3 attempts/'),
+                $this->get_contains_button_expectation($qa->get_qt_field_name('omact_gen_14'), 'Check'));
+
+        // Submit the right answer.
+        $this->process_submission(array('omval_response1' => $size[0] * $scale,
+                'omval_response2' => $size[1] * $scale, 'omact_gen_14' => 'Check'));
+
+        // Verify.
+        $this->check_current_state(question_state::$gradedright);
+        $this->check_current_mark(6);
+        $this->check_current_output(
+                new PatternExpectation('/Below is a plan of a proposed garden/'),
+                new PatternExpectation('/correct/'));
+    }
+
+    public function test_gave_up() {
+        $q = $this->make_standard_om_question();
+        $this->start_attempt_at_question($q, 'interactive');
+
+        $this->quba->finish_all_questions();
+
+        $this->check_current_state(question_state::$gaveup);
+        $this->check_current_mark(null);
+        $this->check_current_output(
+                new PatternExpectation('/' .
+                        preg_quote(get_string('notcompletedmessage', 'qtype_opaque')) . '/'));
+    }
+}
diff --git a/question/behaviour/rendererbase.php b/question/behaviour/rendererbase.php
new file mode 100644 (file)
index 0000000..de2da4b
--- /dev/null
@@ -0,0 +1,200 @@
+<?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/>.
+
+
+/**
+ * Defines the renderer base class for question behaviours.
+ *
+ * @package moodlecore
+ * @subpackage questionbehaviours
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * Renderer base class for question behaviours.
+ *
+ * The methods in this class are mostly called from {@link core_question_renderer}
+ * which coordinates the overall output of questions.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class qbehaviour_renderer extends renderer_base {
+    /**
+     * Generate some HTML (which may be blank) that appears in the question
+     * formulation area, afer the question type generated output.
+     *
+     * For example.
+     * immediatefeedback and interactive mode use this to show the Submit button,
+     * and CBM use this to display the certainty choices.
+     *
+     * @param question_attempt $qa a question attempt.
+     * @param question_display_options $options controls what should and should not be displayed.
+     * @return string HTML fragment.
+     */
+    public function controls(question_attempt $qa, question_display_options $options) {
+        return '';
+    }
+
+    /**
+     * Generate some HTML (which may be blank) that appears in the outcome area,
+     * after the question-type generated output.
+     *
+     * For example, the CBM models use this to display an explanation of the score
+     * adjustment that was made based on the certainty selected.
+     *
+     * @param question_attempt $qa a question attempt.
+     * @param question_display_options $options controls what should and should not be displayed.
+     * @return string HTML fragment.
+     */
+    public function feedback(question_attempt $qa, question_display_options $options) {
+        return '';
+    }
+
+    public function manual_comment_fields(question_attempt $qa, question_display_options $options) {
+
+        $commentfield = $qa->get_behaviour_field_name('comment');
+
+        $comment = print_textarea(can_use_html_editor(), 10, 80, null, null, $commentfield, $qa->get_manual_comment(), 0, true);
+        $comment = html_writer::tag('div', html_writer::tag('div',
+                html_writer::tag('label', get_string('comment', 'question'), array('for' => $commentfield)),
+                array('class' => 'fitemtitle')) .
+                html_writer::tag('div', $comment, array('class' => 'felement fhtmleditor')),
+                array('class' => 'fitem'));
+
+        $mark = '';
+        if ($qa->get_max_mark()) {
+            $currentmark = $qa->get_current_manual_mark();
+            $maxmark = $qa->get_max_mark();
+
+            $fieldsize = strlen($qa->format_max_mark($options->markdp)) - 1;
+            $markfield = $qa->get_behaviour_field_name('mark');
+
+            $attributes = array(
+                'type' => 'text',
+                'size' => $fieldsize,
+                'name' => $markfield,
+            );
+            if (!is_null($currentmark)) {
+                $attributes['value'] = $qa->format_fraction_as_mark($currentmark / $maxmark, $options->markdp);
+            }
+            $a = new stdClass;
+            $a->max = $qa->format_max_mark($options->markdp);
+            $a->mark = html_writer::empty_tag('input', $attributes);
+
+            $markrange = html_writer::empty_tag('input', array(
+                'type' => 'hidden',
+                'name' => $qa->get_behaviour_field_name('maxmark'),
+                'value' => $maxmark,
+            )) . html_writer::empty_tag('input', array(
+                'type' => 'hidden',
+                'name' => $qa->get_control_field_name('minfraction'),
+                'value' => $qa->get_min_fraction(),
+            ));
+
+            $errorclass = '';
+            $error = '';
+            if ($currentmark > $maxmark || $currentmark < $maxmark * $qa->get_min_fraction()) {
+                $errorclass = ' error';
+                $error = html_writer::tag('span', get_string('manualgradeoutofrange', 'question'),
+                        array('class' => 'error')) . html_writer::empty_tag('br');
+            }
+
+            $mark = html_writer::tag('div', html_writer::tag('div',
+                    html_writer::tag('label', get_string('mark', 'question'), array('for' => $markfield)),
+                    array('class' => 'fitemtitle')) .
+                    html_writer::tag('div', $error . get_string('xoutofmax', 'question', $a) .
+                        $markrange, array('class' => 'felement ftext' . $errorclass)
+                    ), array('class' => 'fitem'));
+            
+        }
+
+        return html_writer::tag('fieldset', html_writer::tag('div', $comment . $mark,
+                array('class' => 'fcontainer clearfix')), array('class' => 'hidden'));
+    }
+
+    public function manual_comment_view(question_attempt $qa, question_display_options $options) {
+        $output = '';
+        if ($qa->has_manual_comment()) {
+            $output .= get_string('commentx', 'question', $qa->get_behaviour()->format_comment());
+        }
+        if ($options->manualcommentlink) {
+            $strcomment = get_string('commentormark', 'question');
+            $link = link_to_popup_window($options->manualcommentlink .
+                    '&amp;slot=' . $qa->get_slot(),
+                    'commentquestion', $strcomment, 600, 800, $strcomment, 'none', true);
+            $output .= html_writer::tag('div', $link, array('class' => 'commentlink'));
+        }
+        return $output;
+    }
+
+    /**
+     * Display the manual comment, and a link to edit it, if appropriate.
+     *
+     * @param question_attempt $qa a question attempt.
+     * @param question_display_options $options controls what should and should not be displayed.
+     * @return string HTML fragment.
+     */
+    public function manual_comment(question_attempt $qa, question_display_options $options) {
+        if ($options->manualcomment == question_display_options::EDITABLE) {
+            return $this->manual_comment_fields($qa, $options);
+
+        } else if ($options->manualcomment == question_display_options::VISIBLE) {
+            return $this->manual_comment_view($qa, $options);
+
+        } else {
+            return '';
+        }
+    }
+
+    /**
+     * Several behaviours need a submit button, so put the common code here.
+     * The button is disabled if the question is displayed read-only.
+     * @param question_display_options $options controls what should and should not be displayed.
+     * @return string HTML fragment.
+     */
+    protected function submit_button(question_attempt $qa, question_display_options $options) {
+        $attributes = array(
+            'type' => 'submit',
+            'id' => $qa->get_behaviour_field_name('submit'),
+            'name' => $qa->get_behaviour_field_name('submit'),
+            'value' => get_string('check', 'question'),
+            'class' => 'submit btn',
+        );
+        if ($options->readonly) {
+            $attributes['disabled'] = 'disabled';
+        }
+        $output = html_writer::empty_tag('input', $attributes);
+        if (!$options->readonly) {
+            $output .= print_js_call('question_init_submit_button',
+                    array($attributes['id'], $qa->get_slot()), true);
+        }
+        return $output;
+    }
+
+    /**
+     * Return any HTML that needs to be included in the page's <head> when
+     * questions using this model are used.
+     * @param $qa the question attempt that will be displayed on the page.
+     * @return string HTML fragment.
+     */
+    public function head_code(question_attempt $qa) {
+        return '';
+    }
+}
index b07ad75..d03a78c 100644 (file)
@@ -1,37 +1,34 @@
 <?php
 
-///////////////////////////////////////////////////////////////////////////
-//                                                                       //
-// NOTICE OF COPYRIGHT                                                   //
-//                                                                       //
-// Moodle - Modular Object-Oriented Dynamic Learning Environment         //
-//          http://moodle.org                                            //
-//                                                                       //
-// Copyright (C) 1999 onwards Martin Dougiamas and others                //
-//                                                                       //
-// This program 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 2 of the License, or     //
-// (at your option) any later version.                                   //
-//                                                                       //
-// This program 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:                          //
-//                                                                       //
-//          http://www.gnu.org/copyleft/gpl.html                         //
-//                                                                       //
-///////////////////////////////////////////////////////////////////////////
+// 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/>.
+
 
 /**
  * Functions used to show question editing interface
  *
- * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
- * @package questionbank
- *//** */
+ * @package moodlecore
+ * @subpackage questionbank
+ * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
 
 require_once($CFG->libdir.'/questionlib.php');
 
+
 define('DEFAULT_QUESTIONS_PER_PAGE', 20);
 
 function get_module_from_cmid($cmid) {
@@ -1428,10 +1425,10 @@ class question_bank_view {
                         foreach ($questionlist as $questionid) {
                             $questionid = (int)$questionid;
                             question_require_capability_on($questionid, 'edit');
-                            if ($DB->record_exists('quiz_question_instances', array('question' => $questionid))) {
+                            if (questions_in_use(array($questionid))) {
                                 $DB->set_field('question', 'hidden', 1, array('id' => $questionid));
                             } else {
-                                delete_question($questionid);
+                                question_delete_question($questionid);
                             }
                         }
                     }
@@ -1464,7 +1461,7 @@ class question_bank_view {
                     $key = $matches[1];
                     $questionlist .= $key.',';
                     question_require_capability_on($key, 'edit');
-                    if ($DB->record_exists('quiz_question_instances', array('question' => $key))) {
+                    if (questions_in_use(array($key))) {
                         $questionnames .= '* ';
                         $inuse = true;
                     }
diff --git a/question/engine/bank.php b/question/engine/bank.php
new file mode 100644 (file)
index 0000000..3bc394a
--- /dev/null
@@ -0,0 +1,221 @@
+<?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
+ */
+
+
+/**
+ * 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 {
+    /** @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();
+
+    protected static $questionfinder = null;
+
+    /** @var boolean nasty hack to allow unit tests to call {@link load_question()}. */
+    private static $testmode = false;
+    private static $testdata = array();
+
+    /**
+     * Get the question type class for a particular question type.
+     * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
+     * @param boolean $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 = $CFG->dirroot . '/question/type/' . $qtypename . '/questiontype.php';
+        if (!is_readable($file)) {
+            if ($mustexist || $qtypename == 'missingtype') {
+                throw new Exception('Unknown question type ' . $qtypename);
+            } else {
+                return self::get_qtype('missingtype');
+            }
+        }
+        include_once($file);
+        $class = 'qtype_' . $qtypename;
+        self::$questiontypes[$qtypename] = new $class();
+        return self::$questiontypes[$qtypename];
+    }
+
+    /**
+     * @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)->menu_name();
+    }
+
+    /**
+     * @return array all the installed question types.
+     */
+    public static function get_all_qtypes() {
+        $qtypes = array();
+        $plugins = get_list_of_plugins('question/type', 'datasetdependent');
+        foreach ($plugins as $plugin) {
+            $qtypes[$plugin] = self::get_qtype($plugin);
+        }
+        return $qtypes;
+    }
+
+    /**
+     * 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 Exception('Unknown question type (no definition) ' . $qtypename);
+        }
+        include_once($file);
+        self::$loadedqdefs[$qtypename] = 1;
+    }
+
+    /**
+     * Load a question definition from the database. The object returned
+     * will actually be of an appropriate {@link question_definition} subclass.
+     * @param integer $questionid the id of the question to load.
+     * @return question_definition loaded from the database.
+     */
+    public static function load_question($questionid) {
+        if (self::$testmode) {
+            // Evil, test code in production, but now way round it.
+            return self::return_test_question_data($questionid);
+        }
+
+        $questiondata = get_record('question', 'id', $questionid);
+        if (empty($questiondata)) {
+            throw new Exception('Unknown question id ' . $questionid);
+        }
+        get_question_options($questiondata);
+        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) {
+        return self::get_qtype($questiondata->qtype, false)->make_question($questiondata, false);
+    }
+
+    /**
+     * @return question_finder a question finder.
+     */
+    public static function get_finder() {
+        if (is_null(self::$questionfinder)) {
+            self::$questionfinder = new question_finder();
+        }
+        return self::$questionfinder;
+    }
+
+    /**
+     * 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 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 Exception('question_bank::load_test_data called when not in test mode.');
+        }
+        self::$testdata[$question->id] = $question;
+    }
+}
+
+class question_finder {
+    /**
+     * Get the ids of all the questions in a list of categoryies.
+     * @param integer|string|array $categoryids either a categoryid, or a comma-separated list
+     *      category ids, or an array of them.
+     * @param string $extraconditions extra conditions to AND with the rest of the where clause.
+     * @return array questionid => questionid.
+     */
+    public function get_questions_from_categories($categoryids, $extraconditions) {
+        if (is_array($categoryids)) {
+            $categoryids = implode(',', $categoryids);
+        }
+
+        if ($extraconditions) {
+            $extraconditions = ' AND (' . $extraconditions . ')';
+        }
+        $questionids = get_records_select_menu('question',
+                "category IN ($categoryids)
+                 AND parent = 0
+                 AND hidden = 0
+                 $extraconditions", '', 'id,id AS id2');
+        if (!$questionids) {
+            $questionids = array();
+        }
+        return $questionids;
+    }
+}
diff --git a/question/engine/datalib.php b/question/engine/datalib.php
new file mode 100644 (file)
index 0000000..cd1c997
--- /dev/null
@@ -0,0 +1,1101 @@
+<?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 for loading and saving quiz attempts to and from the database.
+ *
+ * @package moodlecore
+ * @subpackage questionengine
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+/**
+ * This class controls the loading and saving of question engine data to and from
+ * the database.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_engine_data_mapper {
+    /**
+     * Store an entire {@link question_usage_by_activity} in the database,
+     * including all the question_attempts that comprise it.
+     * @param question_usage_by_activity $quba the usage to store.
+     */
+    public function insert_questions_usage_by_activity(question_usage_by_activity $quba) {
+        $record = new stdClass;
+        $record->contextid = $quba->get_owning_context()->id;
+        $record->component = addslashes($quba->get_owning_component());
+        $record->preferredbehaviour = addslashes($quba->get_preferred_behaviour());
+
+        $newid = insert_record('question_usages', $record);
+        if (!$newid) {
+            throw new Exception('Failed to save questions_usage_by_activity.');
+        }
+        $quba->set_id_from_database($newid);
+
+        foreach ($quba->get_attempt_iterator() as $qa) {
+            $this->insert_question_attempt($qa);
+        }
+    }
+
+    /**
+     * Store an entire {@link question_attempt} in the database,
+     * including all the question_attempt_steps that comprise it.
+     * @param question_attempt $qa the question attempt to store.
+     */
+    public function insert_question_attempt(question_attempt $qa) {
+        $record = new stdClass;
+        $record->questionusageid = $qa->get_usage_id();
+        $record->slot = $qa->get_slot();
+        $record->behaviour = addslashes($qa->get_behaviour_name());
+        $record->questionid = $qa->get_question()->id;
+        $record->maxmark = $qa->get_max_mark();
+        $record->minfraction = $qa->get_min_fraction();
+        $record->flagged = $qa->is_flagged();
+        $record->questionsummary = addslashes($qa->get_question_summary());
+        $record->rightanswer = addslashes($qa->get_right_answer_summary());
+        $record->responsesummary = addslashes($qa->get_response_summary());
+        $record->timemodified = time();
+        $record->id = insert_record('question_attempts', $record);
+        if (!$record->id) {
+            throw new Exception('Failed to save question_attempt ' . $qa->get_slot());
+        }
+
+        foreach ($qa->get_step_iterator() as $seq => $step) {
+            $this->insert_question_attempt_step($step, $record->id, $seq);
+        }
+    }
+
+    /**
+     * Store a {@link question_attempt_step} in the database.
+     * @param question_attempt_step $qa the step to store.
+     */
+    public function insert_question_attempt_step(question_attempt_step $step,
+            $questionattemptid, $seq) {
+        $record = new stdClass;
+        $record->questionattemptid = $questionattemptid;
+        $record->sequencenumber = $seq;
+        $record->state = addslashes('' . $step->get_state());
+        $record->fraction = $step->get_fraction();
+        $record->timecreated = $step->get_timecreated();
+        $record->userid = $step->get_user_id();
+
+        $record->id = insert_record('question_attempt_steps', $record);
+        if (!$record->id) {
+            throw new Exception('Failed to save question_attempt_step' . $seq .
+                    ' for question attempt id ' . $questionattemptid);
+        }
+
+        foreach ($step->get_all_data() as $name => $value) {
+            $data = new stdClass;
+            $data->attemptstepid = $record->id;
+            $data->name = addslashes($name);
+            $data->value = addslashes($value);
+            insert_record('question_attempt_step_data', $data, false);
+        }
+    }
+
+    /**
+     * Load a {@link question_attempt_step} from the database.
+     * @param integer $stepid the id of the step to load.
+     * @param question_attempt_step the step that was loaded.
+     */
+    public function load_question_attempt_step($stepid) {
+        global $CFG;
+        $records = get_records_sql("
+SELECT
+    COALESCE(qasd.id, -1 * qas.id) AS id,
+    qas.id AS attemptstepid,
+    qas.questionattemptid,
+    qas.sequencenumber,
+    qas.state,
+    qas.fraction,
+    qas.timecreated,
+    qas.userid,
+    qasd.name,
+    qasd.value
+
+FROM {$CFG->prefix}question_attempt_steps qas
+LEFT JOIN {$CFG->prefix}question_attempt_step_data qasd ON qasd.attemptstepid = qas.id
+
+WHERE
+    qas.id = $stepid
+        ");
+
+        if (!$records) {
+            throw new Exception('Failed to load question_attempt_step ' . $stepid);
+        }
+
+        return question_attempt_step::load_from_records($records, $stepid);
+    }
+
+    /**
+     * Load a {@link question_attempt} from the database, including all its
+     * steps.
+     * @param integer $questionattemptid the id of the question attempt to load.
+     * @param question_attempt the question attempt that was loaded.
+     */
+    public function load_question_attempt($questionattemptid) {
+        global $CFG;
+        $records = get_records_sql("
+SELECT
+    COALESCE(qasd.id, -1 * qas.id) AS id,
+    quba.preferredbehaviour,
+    qa.id AS questionattemptid,
+    qa.questionusageid,
+    qa.slot,
+    qa.behaviour,
+    qa.questionid,
+    qa.maxmark,
+    qa.minfraction,
+    qa.flagged,
+    qa.questionsummary,
+    qa.rightanswer,
+    qa.responsesummary,
+    qa.timemodified,
+    qas.id AS attemptstepid,
+    qas.sequencenumber,
+    qas.state,
+    qas.fraction,
+    qas.timecreated,
+    qas.userid,
+    qasd.name,
+    qasd.value
+
+FROM {$CFG->prefix}question_attempts qa
+JOIN {$CFG->prefix}question_usages quba ON quba.id = qa.questionusageid
+LEFT JOIN {$CFG->prefix}question_attempt_steps qas ON qas.questionattemptid = qa.id
+LEFT JOIN {$CFG->prefix}question_attempt_step_data qasd ON qasd.attemptstepid = qas.id
+
+WHERE
+    qa.id = $questionattemptid
+
+ORDER BY
+    qas.sequencenumber
+        ");
+
+        if (!$records) {
+            throw new Exception('Failed to load question_attempt ' . $questionattemptid);
+        }
+
+        $record = current($records);
+        return question_attempt::load_from_records($records, $questionattemptid,
+                new question_usage_null_observer(), $record->preferredbehaviour);
+    }
+
+    /**
+     * Load a {@link question_usage_by_activity} from the database, including
+     * all its {@link question_attempt}s and all their steps.
+     * @param integer $qubaid the id of the usage to load.
+     * @param question_usage_by_activity the usage that was loaded.
+     */
+    public function load_questions_usage_by_activity($qubaid) {
+        global $CFG;
+        $records = get_records_sql("
+SELECT
+    COALESCE(qasd.id, -1 * qas.id) AS id,
+    quba.id AS qubaid,
+    quba.contextid,
+    quba.component,
+    quba.preferredbehaviour,
+    qa.id AS questionattemptid,
+    qa.questionusageid,
+    qa.slot,
+    qa.behaviour,
+    qa.questionid,
+    qa.maxmark,
+    qa.minfraction,
+    qa.flagged,
+    qa.questionsummary,
+    qa.rightanswer,
+    qa.responsesummary,
+    qa.timemodified,
+    qas.id AS attemptstepid,
+    qas.sequencenumber,
+    qas.state,
+    qas.fraction,
+    qas.timecreated,
+    qas.userid,
+    qasd.name,
+    qasd.value
+
+FROM {$CFG->prefix}question_usages quba
+LEFT JOIN {$CFG->prefix}question_attempts qa ON qa.questionusageid = quba.id
+LEFT JOIN {$CFG->prefix}question_attempt_steps qas ON qas.questionattemptid = qa.id
+LEFT JOIN {$CFG->prefix}question_attempt_step_data qasd ON qasd.attemptstepid = qas.id
+
+WHERE
+    quba.id = $qubaid
+
+ORDER BY
+    qa.slot,
+    qas.sequencenumber
+    ");
+
+        if (!$records) {
+            throw new Exception('Failed to load questions_usage_by_activity ' . $qubaid);
+        }
+
+        return question_usage_by_activity::load_from_records($records, $qubaid);
+    }
+
+    /**
+     * Load information about the latest state of each question from the database.
+     *
+     * @param qubaid_condition $qubaids used to restrict which usages are included
+     * in the query. See {@link qubaid_condition}.
+     * @param array $slots A list of slots for the questions you want to konw about.
+     * @return array of records. See the SQL in this function to see the fields available.
+     */
+    public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots) {
+        global $CFG;
+
+        list($slottest, $params) = get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000');
+
+        $records = get_records_sql("
+SELECT
+    qas.id,
+    qa.id AS questionattemptid,
+    qa.questionusageid,
+    qa.slot,
+    qa.behaviour,
+    qa.questionid,
+    qa.maxmark,
+    qa.minfraction,
+    qa.flagged,
+    qa.questionsummary,
+    qa.rightanswer,
+    qa.responsesummary,
+    qa.timemodified,
+    qas.id AS attemptstepid,
+    qas.sequencenumber,
+    qas.state,
+    qas.fraction,
+    qas.timecreated,
+    qas.userid
+
+FROM {$qubaids->from_question_attempts('qa')}
+JOIN {$CFG->prefix}question_attempt_steps qas ON
+        qas.id = {$this->latest_step_for_qa_subquery()}
+
+WHERE
+    {$qubaids->where()} AND
+    qa.slot $slottest
+        ");
+
+        if (!$records) {
+            $records = array();
+        }
+
+        return $records;
+    }
+
+    /**
+     * Load summary information about the state of each question in a group of attempts.
+     * This is used by the quiz manual grading report, to show how many attempts
+     * at each question need to be graded.
+     *
+     * @param qubaid_condition $qubaids used to restrict which usages are included
+     * in the query. See {@link qubaid_condition}.
+     * @param array $slots A list of slots for the questions you want to konw about.
+     * @return array The array keys are slot,qestionid. The values are objects with
+     * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
+     * $manuallygraded and $all.
+     */
+    public function load_questions_usages_question_state_summary(qubaid_condition $qubaids, $slots) {
+        global $CFG;
+
+        list($slottest, $params) = get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000');
+
+        $rs = get_recordset_sql("
+SELECT
+    qa.slot,
+    qa.questionid,
+    q.name,
+    CASE qas.state
+        {$this->full_states_to_summary_state_sql()}
+    END AS summarystate,
+    COUNT(1) AS numattempts
+
+FROM {$qubaids->from_question_attempts('qa')}
+JOIN {$CFG->prefix}question_attempt_steps qas ON
+        qas.id = {$this->latest_step_for_qa_subquery()}
+JOIN {$CFG->prefix}question q ON q.id = qa.questionid
+
+WHERE
+    {$qubaids->where()} AND
+    qa.slot $slottest
+
+GROUP BY
+    qa.slot,
+    qa.questionid,
+    q.name,
+    q.id,
+    summarystate
+
+ORDER BY 
+    qa.slot,
+    qa.questionid,
+    q.name,
+    q.id
+        ");
+
+        if (!$rs) {
+            throw new moodle_exception('errorloadingdata');
+        }
+
+        $results = array();
+        while ($row = rs_fetch_next_record($rs)) {
+            $index = $row->slot . ',' . $row->questionid;
+
+            if (!array_key_exists($index, $results)) {
+                $res = new stdClass;
+                $res->slot = $row->slot;
+                $res->questionid = $row->questionid;
+                $res->name = $row->name;
+                $res->inprogress = 0;
+                $res->needsgrading = 0;
+                $res->autograded = 0;
+                $res->manuallygraded = 0;
+                $res->all = 0;
+                $results[$index] = $res;
+            }
+
+            $results[$index]->{$row->summarystate} = $row->numattempts;
+            $results[$index]->all += $row->numattempts;
+        }
+        rs_close($rs);
+
+        return $results;
+    }
+
+    /**
+     * Get a list of usage ids where the question with slot $slot, and optionally
+     * also with question id $questionid, is in summary state $summarystate. Also
+     * return the total count of such states.
+     *
+     * Only a subset of the ids can be returned by using $orderby, $limitfrom and
+     * $limitnum. A special value 'random' can be passed as $orderby, in which case
+     * $limitfrom is ignored.
+     *
+     * @param qubaid_condition $qubaids used to restrict which usages are included
+     * in the query. See {@link qubaid_condition}.
+     * @param integer $slot The slot for the questions you want to konw about.
+     * @param integer $questionid (optional) Only return attempts that were of this specific question.
+     * @param string $summarystate the summary state of interest, or 'all'.
+     * @param string $orderby the column to order by.
+     * @param integer $limitfrom implements paging of the results.
+     *      Ignored if $orderby = random or $limitnum is null.
+     * @param integer $limitnum implements paging of the results. null = all.
+     * @return array with two elements, an array of usage ids, and a count of the total number.
+     */
+    public function load_questions_usages_where_question_in_state(
+            qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
+            $orderby = 'random', $limitfrom = 0, $limitnum = null) {
+        global $CFG;
+
+        $extrawhere = '';
+        if ($questionid) {
+            $extrawhere .= ' AND qa.questionid = ' . $questionid;
+        }
+        if ($summarystate != 'all') {
+            $test = $this->in_summary_state_test($summarystate);
+            $extrawhere .= ' AND qas.state ' . $test;
+        }
+
+        if ($orderby == 'random') {
+            $sqlorderby = '';
+        } else if ($orderby) {
+            $sqlorderby = 'ORDER BY ' . $orderby;
+        } else {
+            $sqlorderby = '';
+        }
+
+        // We always want the total count, as well as the partcular list of ids,
+        // based on the paging and sort order. Becuase the list of ids is never
+        // going to be too rediculously long. My worst-case scenario is
+        // 10,000 students in the coures, each doing 5 quiz attempts. That
+        // is a 50,000 element int => int array, which PHP seems to use 5MB
+        // memeory to store on a 64 bit server.
+        $qubaids = get_records_sql_menu("
+SELECT
+    qa.questionusageid,
+    1
+
+FROM {$qubaids->from_question_attempts('qa')}
+JOIN {$CFG->prefix}question_attempt_steps qas ON
+        qas.id = {$this->latest_step_for_qa_subquery()}
+JOIN {$CFG->prefix}question q ON q.id = qa.questionid
+
+WHERE
+    {$qubaids->where()} AND
+    qa.slot = $slot
+    $extrawhere
+
+$sqlorderby
+        ");
+
+        $qubaids = array_keys($qubaids);
+        $count = count($qubaids);
+
+        if ($orderby == 'random') {
+            shuffle($qubaids);
+            $limitfrom = 0;
+        }
+
+        if (!is_null($limitnum)) {
+            $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
+        }
+
+        return array($qubaids, $count);
+    }
+
+    /**
+     * Load a {@link question_usage_by_activity} from the database, including
+     * all its {@link question_attempt}s and all their steps.
+     * @param qubaid_condition $qubaids used to restrict which usages are included
+     * in the query. See {@link qubaid_condition}.
+     * @param array $slots if null, load info for all quesitions, otherwise only
+     * load the averages for the specified questions.
+     */
+    public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
+        global $CFG;
+
+        if (!empty($slots)) {
+            list($slottest, $params) = get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000');
+            $slotwhere = " AND qa.slot $slottest";
+        } else {
+            $slotwhere = '';
+        }
+
+        list($statetest) = get_in_or_equal(array(
+                question_state::$gaveup,
+                question_state::$gradedwrong,
+                question_state::$gradedpartial,
+                question_state::$gradedright,
+                question_state::$mangaveup,
+                question_state::$mangrwrong,
+                question_state::$mangrpartial,
+                question_state::$mangrright));
+
+        $records = get_records_sql("
+SELECT
+    qa.slot,
+    AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
+    COUNT(1) AS numaveraged
+
+FROM {$qubaids->from_question_attempts('qa')}
+JOIN {$CFG->prefix}question_attempt_steps qas ON
+        qas.id = {$this->latest_step_for_qa_subquery()}
+
+WHERE
+    {$qubaids->where()}
+    $slotwhere
+    AND qas.state $statetest
+
+GROUP BY qa.slot
+
+ORDER BY qa.slot
+        ");
+
+        return $records;
+    }
+
+    /**
+     * Load a {@link question_attempt} from the database, including all its
+     * steps.
+     * @param integer $questionid the question to load all the attempts fors.
+     * @param qubaid_condition $qubaids used to restrict which usages are included
+     * in the query. See {@link qubaid_condition}.
+     * @return array of question_attempts.
+     */
+    public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
+        global $CFG;
+        $records = get_records_sql("
+SELECT
+    COALESCE(qasd.id, -1 * qas.id) AS id,
+    quba.preferredbehaviour,
+    qa.id AS questionattemptid,
+    qa.questionusageid,
+    qa.slot,
+    qa.behaviour,
+    qa.questionid,
+    qa.maxmark,
+    qa.minfraction,
+    qa.flagged,
+    qa.questionsummary,
+    qa.rightanswer,
+    qa.responsesummary,
+    qa.timemodified,
+    qas.id AS attemptstepid,
+    qas.sequencenumber,
+    qas.state,
+    qas.fraction,
+    qas.timecreated,
+    qas.userid,
+    qasd.name,
+    qasd.value
+
+FROM {$qubaids->from_question_attempts('qa')}
+JOIN {$CFG->prefix}question_usages quba ON quba.id = qa.questionusageid
+LEFT JOIN {$CFG->prefix}question_attempt_steps qas ON qas.questionattemptid = qa.id
+LEFT JOIN {$CFG->prefix}question_attempt_step_data qasd ON qasd.attemptstepid = qas.id
+
+WHERE
+    {$qubaids->where()} AND
+    qa.questionid = $questionid
+
+ORDER BY
+    quba.id,
+    qa.id,
+    qas.sequencenumber
+        ");
+
+        if (!$records) {
+            return array();
+        }
+
+        $questionattempts = array();
+        $record = current($records);
+        while ($record) {
+            $questionattempts[$record->questionattemptid] =
+                    question_attempt::load_from_records($records,
+                    $record->questionattemptid, new question_usage_null_observer(),
+                    $record->preferredbehaviour);
+            $record = current($records);
+        }
+        return $questionattempts;
+    }
+
+    /**
+     * Update a question_usages row to refect any changes in a usage (but not
+     * any of its question_attempts.
+     * @param question_usage_by_activity $quba the usage that has changed.
+     */
+    public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
+        $record = new stdClass;
+        $record->id = $quba->get_id();
+        $record->contextid = $quba->get_owning_context()->id;
+        $record->component = addslashes($quba->get_owning_component());
+        $record->preferredbehaviour = addslashes($quba->get_preferred_behaviour());
+
+        if (!update_record('question_usages', $record)) {
+            throw new Exception('Failed to update question_usage_by_activity ' . $record->id);
+        }
+    }
+
+    /**
+     * Update a question_attempts row to refect any changes in a question_attempt
+     * (but not any of its steps).
+     * @param question_attempt $qa the question attempt that has changed.
+     */
+    public function update_question_attempt(question_attempt $qa) {
+        $record = new stdClass;
+        $record->id = $qa->get_database_id();
+        $record->maxmark = $qa->get_max_mark();
+        $record->minfraction = $qa->get_min_fraction();
+        $record->flagged = $qa->is_flagged();
+        $record->questionsummary = addslashes($qa->get_question_summary());
+        $record->rightanswer = addslashes($qa->get_right_answer_summary());
+        $record->responsesummary = addslashes($qa->get_response_summary());
+        $record->timemodified = time();
+
+        if (!update_record('question_attempts', $record)) {
+            throw new Exception('Failed to update question_attempt ' . $record->id);
+        }
+    }
+
+    /**
+     * Delete a question_usage_by_activity and all its associated
+     * {@link question_attempts} and {@link question_attempt_steps} from the
+     * database.
+     * @param string $where a where clause. Becuase of MySQL limitations, you
+     *      must refer to {$CFG->prefix}question_usages.id in full like that.
+     */
+    public function delete_questions_usage_by_activities($where) {
+        global $CFG;
+        delete_records_select('question_attempt_step_data', "attemptstepid IN (
+                SELECT qas.id
+                FROM {$CFG->prefix}question_attempts qa
+                JOIN {$CFG->prefix}question_attempt_steps qas ON qas.questionattemptid = qa.id
+                JOIN {$CFG->prefix}question_usages ON qa.questionusageid = {$CFG->prefix}question_usages.id
+                WHERE $where)");
+        delete_records_select('question_attempt_steps', "questionattemptid IN (
+                SELECT qa.id
+                FROM {$CFG->prefix}question_attempts qa
+                JOIN {$CFG->prefix}question_usages ON qa.questionusageid = {$CFG->prefix}question_usages.id
+                WHERE $where)");
+        delete_records_select('question_attempts', "questionusageid IN (
+                SELECT id
+                FROM {$CFG->prefix}question_usages
+                WHERE $where)");
+        delete_records_select('question_usages', $where);
+    }
+
+    /**
+     * Delete all the steps for a question attempt.
+     * @param integer $qaids question_attempt id.
+     */
+    public function delete_steps_for_question_attempts($qaids) {
+        global $CFG;
+        if (empty($qaids)) {
+            return;
+        }
+        list($test, $params) = get_in_or_equal($qaids);
+        delete_records_select('question_attempt_step_data', "attemptstepid IN (
+                SELECT qas.id
+                FROM {$CFG->prefix}question_attempt_steps qas
+                WHERE questionattemptid $test)");
+        delete_records_select('question_attempt_steps', 'questionattemptid ' . $test);
+    }
+
+    /**
+     * Delete all the previews for a given question.
+     * @param integer $questionid question id.
+     */
+    public function delete_previews($questionid) {
+        global $CFG;
+        $previews = get_records_sql_menu("
+                SELECT DISTINCT quba.id, 1
+                FROM {$CFG->prefix}question_usages quba
+                JOIN {$CFG->prefix}question_attempts qa ON qa.questionusageid = quba.id
+                WHERE quba.component = 'core_question_preview' AND
+                    qa.questionid = '$questionid'");
+        if (empty($previews)) {
+            return;
+        }
+        $this->delete_questions_usage_by_activities(
+                "{$CFG->prefix}question_usages.id IN (" .
+                implode(',', array_keys($previews)) . ')');
+    }
+
+    /**
+     * Update the flagged state of a question in the database.
+     * @param integer $qubaid the question usage id.
+     * @param integer $questionid the question id.
+     * @param integer $sessionid the question_attempt id.
+     * @param boolean $newstate the new state of the flag. true = flagged.
+     */
+    public function update_question_attempt_flag($qubaid, $questionid, $qaid, $newstate) {
+        if (!record_exists('question_attempts', 'id', $qaid, 
+                'questionusageid', $qubaid, 'questionid', $questionid)) {
+            throw new Exception('invalid ids');
+        }
+
+        if (!set_field('question_attempts', 'flagged', $newstate, 'id', $qaid)) {
+            throw new Exception('flag update failed');
+        }
+    }
+
+    /**
+     * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
+     * column to a summary state. Use this like
+     * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
+     * @param string SQL fragment.
+     */
+    protected function full_states_to_summary_state_sql() {
+        $sql = '';
+        foreach (question_state::get_all() as $state) {
+            $sql .= "WHEN '$state' THEN '{$state->get_summary_state()}'\n";
+        }
+        return $sql;
+    }
+
+    /**
+     * Get the SQL needed to test that question_attempt_steps.state is in a
+     * state corresponding to $summarystate.
+     * @param string $summarystate one of
+     * inprogress, needsgrading, manuallygraded or autograded
+     * @param boolean $equal if false, do a NOT IN test. Default true.
+     * @return string SQL fragment.
+     */
+    public function in_summary_state_test($summarystate, $equal = true) {
+        $states = question_state::get_all_for_summary_state($summarystate);
+        list($sql, $params) = get_in_or_equal($states, SQL_PARAMS_QM, 'param0000', $equal);
+        return $sql;
+    }
+
+    /**
+     * Change the maxmark for the question_attempt with number in usage $slot
+     * for all the specified question_attempts.
+     * @param qubaid_condition $qubaids Selects which usages are updated.
+     * @param integer $slot the number is usage to affect.
+     * @param number $newmaxmark the new max mark to set.
+     */
+    public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
+        set_field_select('question_attempts', 'maxmark', $newmaxmark,
+                "questionusageid {$qubaids->usage_id_in()} AND slot = $slot");
+    }
+
+    /**
+     * Return a subquery that computes the sum of the marks for all the questions
+     * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
+     * parameter.
+     *
+     * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
+     * this method.
+     *
+     * @param string $qubaid SQL fragment that controls which usage is summed.
+     * This might be the name of a column in the outer query.
+     * @return string SQL code for the subquery.
+     */
+    public function sum_usage_marks_subquery($qubaid) {
+        global $CFG;
+        return "SELECT SUM(qa.maxmark * qas.fraction)
+            FROM {$CFG->prefix}question_attempts qa
+            JOIN (
+                SELECT summarks_qa.id AS questionattemptid, MAX(summarks_qas.id) AS latestid
+                FROM {$CFG->prefix}question_attempt_steps summarks_qas
+                JOIN {$CFG->prefix}question_attempts summarks_qa ON summarks_qa.id = summarks_qas.questionattemptid
+                WHERE summarks_qa.questionusageid = $qubaid
+                GROUP BY summarks_qa.id
+            ) lateststepid ON lateststepid.questionattemptid = qa.id
+            JOIN {$CFG->prefix}question_attempt_steps qas ON qas.id = lateststepid.latestid
+            WHERE qa.questionusageid = $qubaid
+            HAVING COUNT(CASE WHEN qas.state = 'needsgrading' THEN 1 ELSE NULL END) = 0";
+    }
+
+    public function question_attempt_latest_state_view($alias) {
+        global $CFG;
+        return "(
+                SELECT
+                    {$alias}qa.id AS questionattemptid,
+                    {$alias}qa.questionusageid,
+                    {$alias}qa.slot,
+                    {$alias}qa.behaviour,
+                    {$alias}qa.questionid,
+                    {$alias}qa.maxmark,
+                    {$alias}qa.minfraction,
+                    {$alias}qa.flagged,
+                    {$alias}qa.questionsummary,
+                    {$alias}qa.rightanswer,
+                    {$alias}qa.responsesummary,
+                    {$alias}qa.timemodified,
+                    {$alias}qas.id AS attemptstepid,
+                    {$alias}qas.sequencenumber,
+                    {$alias}qas.state,
+                    {$alias}qas.fraction,
+                    {$alias}qas.timecreated,
+                    {$alias}qas.userid
+
+                FROM {$CFG->prefix}question_attempts {$alias}qa
+                JOIN {$CFG->prefix}question_attempt_steps {$alias}qas ON
+                        {$alias}qas.id = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
+            ) $alias";
+    }
+
+    protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
+        global $CFG;
+        return "(
+                SELECT MAX(id)
+                FROM {$CFG->prefix}question_attempt_steps
+                WHERE questionattemptid = $questionattemptid
+            )";
+    }
+
+    /**
+     * @param array $questionids of question ids.
+     * @return boolean whether any of these questions are being used by the question engine.
+     */
+    public static function questions_in_use(array $questionids) {
+        return record_exists_select('question_attempts', 'questionid IN (' .
+                implode(',', $questionids) . ')');
+    }
+}
+
+/**
+ * Implementation of the unit of work pattern for the question engine.
+ *
+ * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
+ * changes to a {@link question_usage_by_activity}, and its constituent parts,
+ * so that the changes can be saved to the database when {@link save()} is called.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_engine_unit_of_work implements question_usage_observer {
+    /** @var question_usage_by_activity the usage being tracked. */
+    protected $quba;
+
+    /** @var boolean whether any of the fields of the usage have been changed. */
+    protected $modified = false;
+
+    /**
+     * @var array list of number in usage => {@link question_attempt}s that
+     * were already in the usage, and which have been modified.
+     */
+    protected $attemptsmodified = array();
+
+    /**
+     * @var array list of number in usage => {@link question_attempt}s that
+     * have been added to the usage.
+     */
+    protected $attemptsadded = array();
+
+    /**
+     * @var array list of question attempt ids to delete the steps for, before
+     * inserting new steps.
+     */
+    protected $attemptstodeletestepsfor = array();
+
+    /**
+     * @var array list of array(question_attempt_step, question_attempt id, seq number)
+     * of steps that have been added to question attempts in this usage.
+     */
+    protected $stepsadded = array();
+
+    /**
+     * Constructor.
+     * @param question_usage_by_activity $quba the usage to track.
+     */
+    public function __construct(question_usage_by_activity $quba) {
+        $this->quba = $quba;
+    }
+
+    public function notify_modified() {
+        $this->modified = true;
+    }
+
+    public function notify_attempt_modified(question_attempt $qa) {
+        $no = $qa->get_slot();
+        if (!array_key_exists($no, $this->attemptsadded)) {
+            $this->attemptsmodified[$no] = $qa;
+        }
+    }
+
+    public function notify_attempt_added(question_attempt $qa) {
+        $this->attemptsadded[$qa->get_slot()] = $qa;
+    }
+
+    public function notify_delete_attempt_steps(question_attempt $qa) {
+
+        if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
+            return;
+        }
+
+        $qaid = $qa->get_database_id();
+        foreach ($this->stepsadded as $key => $stepinfo) {
+            if ($stepinfo[1] == $qaid) {
+                unset($this->stepsadded[$key]);
+            }
+        }
+
+        $this->attemptstodeletestepsfor[$qaid] = 1;
+    }
+
+    public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
+        if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
+            return;
+        }
+        $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
+    }
+
+    /**
+     * Write all the changes we have recorded to the database.
+     * @param question_engine_data_mapper $dm the mapper to use to update the database.
+     */
+    public function save(question_engine_data_mapper $dm) {
+        $dm->delete_steps_for_question_attempts(array_keys($this->attemptstodeletestepsfor));
+        foreach ($this->stepsadded as $stepinfo) {
+            list($step, $questionattemptid, $seq) = $stepinfo;
+            $dm->insert_question_attempt_step($step, $questionattemptid, $seq);
+        }
+        foreach ($this->attemptsadded as $qa) {
+            $dm->insert_question_attempt($qa);
+        }
+        foreach ($this->attemptsmodified as $qa) {
+            $dm->update_question_attempt($qa);
+        }
+        if ($this->modified) {
+            $dm->update_questions_usage_by_activity($this->quba);
+        }
+    }
+}
+
+
+/**
+ * This class represents a restriction on the set of question_usage ids to include
+ * in a larger database query. Depending of the how you are going to restrict the
+ * list of usages, construct an appropriate subclass.
+ *
+ * If $qubaids is an instance of this class, example usage might be
+ *
+ * SELECT qa.id, qa.maxmark
+ * FROM $qubaids->from_question_attempts('qa')
+ * WHERE $qubaids->where() AND qa.slot = 1
+ *
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class qubaid_condition {
+
+    /**
+     * @return string the SQL that needs to go in the FROM clause when trying
+     * to select records from the 'question_attempts' table based on the
+     * qubaid_condition.
+     */
+    public abstract function from_question_attempts($alias);
+
+    /** @return string the SQL that needs to go in the where clause. */
+    public abstract function where();
+
+    /**
+     * @return the params needed by a query that uses
+     * {@link from_question_attempts()} and {@link where()}.
+     */
+    public abstract function from_where_params();
+
+    /**
+     * @return string SQL that can use used in a WHERE qubaid IN (...) query.
+     * This method returns the "IN (...)" part.
+     */
+    public abstract function usage_id_in();
+
+    /**
+     * @return the params needed by a query that uses {@link usage_id_in()}.
+     */
+    public abstract function usage_id_in_params();
+}
+
+
+/**
+ * This class represents a restriction on the set of question_usage ids to include
+ * in a larger database query based on an explicit list of ids.
+ *
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qubaid_list extends qubaid_condition {
+    /** @var array of ids. */
+    protected $qubaids;
+    protected $columntotest = null;
+    protected $params;
+
+    /**
+     * Constructor.
+     * @param array $qubaids of question usage ids.
+     */
+    public function __construct(array $qubaids) {
+        $this->qubaids = $qubaids;
+    }
+
+    public function from_question_attempts($alias) {
+        global $CFG;
+        $this->columntotest = $alias . '.questionusageid';
+        return "{$CFG->prefix}question_attempts $alias";
+    }
+
+    public function where() {
+        global $DB;
+
+        if (is_null($this->columntotest)) {
+            throw new coding_exception('Must call another method that before where().');
+        }
+        if (empty($this->qubaids)) {
+            return '1 = 0';
+        }
+        list($where, $this->params) = $DB->get_in_or_equal($this->qubaids, SQL_PARAMS_NAMED, 'qubaid0000');
+
+        return "{$this->columntotest} {$this->usage_id_in()}";
+    }
+
+    public function from_where_params() {
+        return $this->params;
+    }
+
+    public function usage_id_in() {
+        global $DB;
+
+        if (empty($this->qubaids)) {
+            return '= 0';
+        }
+        list($where, $this->params) = $DB->get_in_or_equal($this->qubaids, SQL_PARAMS_NAMED, 'qubaid0000');
+        return $where;
+    }
+
+    public function usage_id_in_params() {
+        return $this->params;
+    }
+}
+
+
+/**
+ * This class represents a restriction on the set of question_usage ids to include
+ * in a larger database query based on JOINing to some other tables.
+ *
+ * The general form of the query is something like
+ *
+ * SELECT qa.id, qa.maxmark
+ * FROM $from
+ * JOIN {$CFG->prefix}question_attempts qa ON qa.questionusageid = $usageidcolumn
+ * WHERE $where AND qa.slot = 1
+ *
+ * where $from, $usageidcolumn and $where are the arguments to the constructor.
+ *
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class qubaid_join extends qubaid_condition {
+    public $from;
+    public $usageidcolumn;
+    public $where;
+    public $params;
+
+    /**
+     * Constructor. The meaning of the arguments is explained in the class comment.
+     * @param string $from SQL fragemnt to go in the FROM clause.
+     * @param string $usageidcolumn the column in $from that should be
+     * made equal to the usageid column in the JOIN clause.
+     * @param string $where SQL fragment to go in the where clause.
+     */
+    public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
+        $this->from = $from;
+        $this->usageidcolumn = $usageidcolumn;
+        $this->params = $params;
+        if (empty($where)) {
+            $where = '1 = 1';
+        }
+        $this->where = $where;
+    }
+
+    public function from_question_attempts($alias) {
+        global $CFG;
+        return "$this->from
+                JOIN {$CFG->prefix}question_attempts {$alias} ON " .
+                        "{$alias}.questionusageid = $this->usageidcolumn";
+    }
+
+    public function where() {
+        return $this->where;
+    }
+
+    public function from_where_params() {
+        return $this->params;
+    }
+
+    public function usage_id_in() {
+        return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)";
+    }
+
+    public function usage_id_in_params() {
+        return $this->params;
+    }
+}
diff --git a/question/engine/lib.php b/question/engine/lib.php
new file mode 100644 (file)
index 0000000..f3a9e00
--- /dev/null
@@ -0,0 +1,2877 @@
+<?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 core classes of the Moodle question engine.
+ *
+ * @package moodlecore
+ * @subpackage questionengine
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+
+require_once(dirname(__FILE__) . '/states.php');
+require_once(dirname(__FILE__) . '/datalib.php');
+require_once(dirname(__FILE__) . '/renderer.php');
+require_once(dirname(__FILE__) . '/bank.php');
+require_once(dirname(__FILE__) . '/../type/questiontype.php');
+require_once(dirname(__FILE__) . '/../type/questionbase.php');
+require_once(dirname(__FILE__) . '/../type/rendererbase.php');
+require_once(dirname(__FILE__) . '/../behaviour/behaviourbase.php');
+require_once(dirname(__FILE__) . '/../behaviour/rendererbase.php');
+require_once($CFG->libdir . '/questionlib.php');
+
+
+/**
+ * This static class provides access to the other question engine classes.
+ *
+ * It provides functions for managing question behaviours), and for
+ * creating, loading, saving and deleting {@link question_usage_by_activity}s,
+ * which is the main class that is used by other code that wants to use questions.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class question_engine {
+    /** @var array behaviour name => 1. Records which behaviours have been loaded. */
+    private static $loadedbehaviours = array();
+
+    /**
+     * Create a new {@link question_usage_by_activity}. The usage is
+     * created in memory. If you want it to persist, you will need to call
+     * {@link save_questions_usage_by_activity()}.
+     *
+     * @param string $component the plugin creating this attempt. For example mod_quiz.
+     * @param object $context the context this usage belongs to.
+     * @return question_usage_by_activity the newly created object.
+     */
+    public static function make_questions_usage_by_activity($component, $context) {
+        return new question_usage_by_activity($component, $context);
+    }
+
+    /**
+     * Load a {@link question_usage_by_activity} from the database, based on its id.
+     * @param integer $qubaid the id of the usage to load.
+     * @return question_usage_by_activity loaded from the database.
+     */
+    public static function load_questions_usage_by_activity($qubaid) {
+        $dm = new question_engine_data_mapper();
+        return $dm->load_questions_usage_by_activity($qubaid);
+    }
+
+    /**
+     * Save a {@link question_usage_by_activity} to the database. This works either
+     * 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.
+     */
+    public static function save_questions_usage_by_activity(question_usage_by_activity $quba) {
+        $dm = new question_engine_data_mapper();
+        $observer = $quba->get_observer();
+        if ($observer instanceof question_engine_unit_of_work) {
+            $observer->save($dm);
+        } else {
+            $dm->insert_questions_usage_by_activity($quba);
+        }
+    }
+
+    /**
+     * Delete a {@link question_usage_by_activity} from the database, based on its id.
+     * @param integer $qubaid the id of the usage to delete.
+     */
+    public static function delete_questions_usage_by_activity($qubaid) {
+        global $CFG;
+        self::delete_questions_usage_by_activities($CFG->prefix . 'question_usages.id = ' . $qubaid);
+    }
+
+    /**
+     * Delete a {@link question_usage_by_activity} from the database, based on its id.
+     * @param integer $qubaid the id of the usage to delete.
+     */
+    public static function delete_questions_usage_by_activities($where) {
+        $dm = new question_engine_data_mapper();
+        $dm->delete_questions_usage_by_activities($where);
+    }
+
+    /**
+     * Change the maxmark for the question_attempt with number in usage $slot
+     * for all the specified question_attempts.
+     * @param qubaid_condition $qubaids Selects which usages are updated.
+     * @param integer $slot the number is usage to affect.
+     * @param number $newmaxmark the new max mark to set.
+     */
+    public static function set_max_mark_in_attempts(qubaid_condition $qubaids,
+            $slot, $newmaxmark) {
+        $dm = new question_engine_data_mapper();
+        $dm->set_max_mark_in_attempts($qubaids, $slot, $newmaxmark);
+    }
+
+    /**
+     * @param array $questionids of question ids.
+     * @return boolean whether any of these questions are being used by the question engine.
+     */
+    public static function questions_in_use(array $questionids) {
+        $dm = new question_engine_data_mapper();
+        return $dm->questions_in_use($questionids);
+    }
+
+    /**
+     * Create an archetypal behaviour for a particular question attempt.
+     * Used by {@link question_definition::make_behaviour()}.
+     *
+     * @param string $preferredbehaviour the type of model required.
+     * @param question_attempt $qa the question attempt the model will process.
+     * @return question_behaviour an instance of appropriate behaviour class.
+     */
+    public static function make_archetypal_behaviour($preferredbehaviour, question_attempt $qa) {
+        question_engine::load_behaviour_class($preferredbehaviour);
+        $class = 'qbehaviour_' . $preferredbehaviour;
+        if (!constant($class . '::IS_ARCHETYPAL')) {
+            throw new Exception('The requested behaviour is not actually an archetypal one.');
+        }
+        return new $class($qa, $preferredbehaviour);
+    }
+
+    /**
+     * @param string $behaviour the name of a behaviour.
+     * @return array of {@link question_display_options} field names, that are
+     * not relevant to this behaviour before a 'finish' action.
+     */
+    public static function get_behaviour_unused_display_options($behaviour) {
+        self::load_behaviour_class($behaviour);
+        $class = 'qbehaviour_' . $behaviour;
+        if (!method_exists($class, 'get_unused_display_options')) {
+            return question_behaviour::get_unused_display_options();
+        }
+        return call_user_func(array($class, 'get_unused_display_options'));
+    }
+
+    /**
+     * Create an behaviour for a particular type. If that type cannot be
+     * found, return an instance of qbehaviour_missing.
+     *
+     * Normally you should use {@link make_archetypal_behaviour()}, or
+     * call the constructor of a particular model class directly. This method
+     * is only intended for use by {@link question_attempt::load_from_records()}.
+     *
+     * @param string $behaviour the type of model to create.
+     * @param question_attempt $qa the question attempt the model will process.
+     * @param string $preferredbehaviour the preferred behaviour for the containing usage.
+     * @return question_behaviour an instance of appropriate behaviour class.
+     */
+    public static function make_behaviour($behaviour, question_attempt $qa, $preferredbehaviour) {
+        try {
+            self::load_behaviour_class($behaviour);
+        } catch (Exception $e) {
+            question_engine::load_behaviour_class('missing');
+            return new qbehaviour_missing($qa, $preferredbehaviour);
+        }
+        $class = 'qbehaviour_' . $behaviour;
+        return new $class($qa, $preferredbehaviour);
+    }
+
+    /**
+     * Load the behaviour class(es) belonging to a particular model. That is,
+     * include_once('/question/behaviour/' . $behaviour . '/behaviour.php'), with a bit
+     * of checking.
+     * @param string $qtypename the question type name. For example 'multichoice' or 'shortanswer'.
+     */
+    public static function load_behaviour_class($behaviour) {
+        global $CFG;
+        if (isset(self::$loadedbehaviours[$behaviour])) {
+            return;
+        }
+        $file = $CFG->dirroot . '/question/behaviour/' . $behaviour . '/behaviour.php';
+        if (!is_readable($file)) {
+            throw new Exception('Unknown question behaviour ' . $behaviour);
+        }
+        include_once($file);
+        self::$loadedbehaviours[$behaviour] = 1;
+    }
+
+    /**
+     * Return an array where the keys are the internal names of the archetypal
+     * behaviours, and the values are a human-readable name. An
+     * archetypal behaviour is one that is suitable to pass the name of to
+     * {@link question_usage_by_activity::set_preferred_behaviour()}.
+     *
+     * @return array model name => lang string for this behaviour name.
+     */
+    public static function get_archetypal_behaviours() {
+        $archetypes = array();
+        $behaviours = get_list_of_plugins('question/behaviour');
+        foreach ($behaviours as $path) {
+            $behaviour = basename($path);
+            self::load_behaviour_class($behaviour);
+            $plugin = 'qbehaviour_' . $behaviour;
+            if (constant($plugin . '::IS_ARCHETYPAL')) {
+                $archetypes[$behaviour] = self::get_behaviour_name($behaviour);
+            }
+        }
+        asort($archetypes, SORT_LOCALE_STRING);
+        return $archetypes;
+    }
+
+    /**
+     * Return an array where the keys are the internal names of the behaviours
+     * in preferred order and the values are a human-readable name.
+     *
+     * @param array $archetypes, array of behaviours
+     * @param string $questionbehavioursorder, a comma separated list of behaviour names
+     * @param string $questionbehavioursdisabled, a comma separated list of behaviour names
+     * @param string $currentbahaviour, current behaviour name
+     * @return array model name => lang string for this behaviour name.
+     */
+    public static function sort_behaviours($archetypes, $questionbehavioursorder,
+            $questionbehavioursdisabled, $currentbahaviour) {
+        $behaviourorder = array();
+        $behaviourdisabled = array();
+
+        // Get disabled behaviours
+        if ($questionbehavioursdisabled) {
+            $behaviourdisabledtemp = preg_split('/[\s,;]+/', $questionbehavioursdisabled);
+        } else {
+            $behaviourdisabledtemp = array();
+        }
+
+        if ($questionbehavioursorder) {
+            $behaviourordertemp = preg_split('/[\s,;]+/', $questionbehavioursorder);
+        } else {
+            $behaviourordertemp = array();
+        }
+
+        foreach ($behaviourdisabledtemp as $key) {
+            if (array_key_exists($key, $archetypes)) {
+                // Do not disable the current behaviour
+                if ($key != $currentbahaviour) {
+                    $behaviourdisabled[$key] = $archetypes[$key];
+                }
+            }
+        }
+
+        // Get behaviours in preferred order
+        foreach ($behaviourordertemp as $key) {
+            if (array_key_exists($key, $archetypes)) {
+                $behaviourorder[$key] = $archetypes[$key];
+            }
+        }
+        // Get the rest of behaviours and sort them alphabetically
+        $leftover = array_diff_key($archetypes, $behaviourdisabled, $behaviourorder);
+        asort($leftover, SORT_LOCALE_STRING);
+
+        // Set up the final order to be displayed
+        $finalorder = $behaviourorder + $leftover;
+        return $finalorder;
+    }
+
+    /**
+     * Return an array where the keys are the internal names of the behaviours
+     * in preferred order and the values are a human-readable name.
+     *
+     * @param string $currentbahaviour
+     * @return array model name => lang string for this behaviour name.
+     */
+    public static function get_behaviour_options($currentbahaviour) {
+        global $CFG;
+        $archetypes = self::get_archetypal_behaviours();
+
+        // If no admin setting return all behavious
+        if (!$CFG->questionbehavioursdisabled && !$CFG->questionbehavioursorder) {
+            return $archetypes;
+        }
+
+        return self::sort_behaviours($archetypes, $CFG->questionbehavioursorder,
+                $CFG->questionbehavioursdisabled, $currentbahaviour);
+    }
+
+    /**
+     * Get the translated name of an behaviour, for display in the UI.
+     * @param string $behaviour the internal name of the model.
+     * @return string name from the current language pack.
+     */
+    public static function get_behaviour_name($behaviour) {
+        return get_string($behaviour, 'qbehaviour_' . $behaviour);
+    }
+
+    /**
+     * Returns the valid choices for the number of decimal places for showing
+     * question marks. For use in the user interface.
+     * @return array suitable for passing to {@link choose_from_menu()} or similar.
+     */
+    public static function get_dp_options() {
+        return question_display_options::get_dp_options();
+    }
+
+    public static function initialise_js() {
+        return question_flags::initialise_js();
+    }
+}
+
+
+/**
+ * This class contains all the options that controls how a question is displayed.
+ *
+ * Normally, what will happen is that the calling code will set up some display
+ * options to indicate what sort of question display it wants, and then before the
+ * question is rendered, the behaviour will be given a chance to modify the
+ * display options, so that, for example, A question that is finished will only
+ * be shown read-only, and a question that has not been submitted will not have
+ * any sort of feedback displayed.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_display_options {
+    /**#@+ @var integer named constants for the values that most of the options take. */
+    const HIDDEN = 0;
+    const VISIBLE = 1;
+    const EDITABLE = 2;
+    /**#@-*/
+
+    /**#@+ @var integer named constants for the {@link $marks} option. */
+    const MAX_ONLY = 1;
+    const MARK_AND_MAX = 2;
+    /**#@-*/
+
+    /**
+     * @var integer maximum value for the {@link $markpd} option. This is
+     * effectively set by the database structure, which uses NUMBER(12,7) columns
+     * for question marks/fractions.
+     */
+    const MAX_DP = 7;
+
+    /**
+     * @var boolean whether the question should be displayed as a read-only review,
+     * or in an active state where you can change the answer.
+     */
+    public $readonly = false;
+
+    /**
+     * @var boolean whether the question type should output hidden form fields
+     * to reset any incorrect parts of the resonse to blank.
+     */
+    public $clearwrong = false;
+
+    /**
+     * Should the student have what they got right and wrong clearly indicated.
+     * This includes the green/red hilighting of the bits of their response,
+     * whether the one-line summary of the current state of the question says
+     * correct/incorrect or just answered.
+     * @var integer {@link question_display_options::HIDDEN} or
+     * {@link question_display_options::VISIBLE}
+     */
+    public $correctness = self::VISIBLE;
+
+    /**
+     * The the mark and/or the maximum available mark for this question be visible?
+     * @var integer {@link question_display_options::HIDDEN},
+     * {@link question_display_options::MAX_ONLY} or {@link question_display_options::MARK_AND_MAX}
+     */
+    public $marks = self::MARK_AND_MAX;
+
+    /** @var number of decimal places to use when formatting marks for output. */
+    public $markdp = 2;
+
+    /**
+     * Should the flag this question UI element be visible, and if so, should the
+     * flag state be changable?
+     * @var integer {@link question_display_options::HIDDEN},
+     * {@link question_display_options::VISIBLE} or {@link question_display_options::EDITABLE}
+     */
+    public $flags = self::VISIBLE;
+
+    /**
+     * Should the specific feedback be visible.
+     * @var integer {@link question_display_options::HIDDEN} or
+     * {@link question_display_options::VISIBLE}
+     */
+    public $feedback = self::VISIBLE;
+
+    /**
+     * For questions with a number of sub-parts (like matching, or
+     * multiple-choice, multiple-reponse) display the number of sub-parts that
+     * were correct.
+     * @var integer {@link question_display_options::HIDDEN} or
+     * {@link question_display_options::VISIBLE}
+     */
+    public $numpartscorrect = self::VISIBLE;
+
+    /**
+     * Should the general feedback be visible?
+     * @var integer {@link question_display_options::HIDDEN} or
+     * {@link question_display_options::VISIBLE}
+     */
+    public $generalfeedback = self::VISIBLE;
+
+    /**
+     * Should the automatically generated display of what the correct answer is
+     * be visible?
+     * @var integer {@link question_display_options::HIDDEN} or
+     * {@link question_display_options::VISIBLE}
+     */
+    public $rightanswer = self::VISIBLE;
+
+    /**
+     * Should the manually added marker's comment be visible. Should the link for
+     * adding/editing the comment be there.
+     * @var integer {@link question_display_options::HIDDEN},
+     * {@link question_display_options::VISIBLE}, or {@link question_display_options::EDITABLE}.
+     * Editable means that form fields are displayed inline.
+     */
+    public $manualcomment = self::VISIBLE;
+
+    /**
+     * Should we show a 'Make comment or override grade' link?
+     * @var string base URL for the edit comment script, which will be shown if
+     * $manualcomment = self::VISIBLE.
+     */
+    public $manualcommentlink = null;
+
+    /**
+     * Used in places like the question history table, to show a link to review
+     * this question in a certain state. If blank, a link is not shown.
+     * @var string base URL for a review question script.
+     */
+    public $questionreviewlink = null;
+
+    /**
+     * Should the history of previous question states table be visible?
+     * @var integer {@link question_display_options::HIDDEN} or
+     * {@link question_display_options::VISIBLE}
+     */
+    public $history = self::HIDDEN;
+
+    /**
+     * Set all the feedback-related fields {@link $feedback}, {@link generalfeedback},
+     * {@link rightanswer} and {@link manualcomment} to
+     * {@link question_display_options::HIDDEN}.
+     */
+    public function hide_all_feedback() {
+        $this->feedback = self::HIDDEN;
+        $this->numpartscorrect = self::HIDDEN;
+        $this->generalfeedback = self::HIDDEN;
+        $this->rightanswer = self::HIDDEN;
+        $this->manualcomment = self::HIDDEN;
+        $this->correctness = self::HIDDEN;
+    }
+
+    /**
+     * Returns the valid choices for the number of decimal places for showing
+     * question marks. For use in the user interface.
+     *
+     * Calling code should probably use {@link question_engine::get_dp_options()}
+     * rather than calling this method directly.
+     *
+     * @return array suitable for passing to {@link choose_from_menu()} or similar.
+     */
+    public static function get_dp_options() {
+        $options = array();
+        for ($i = 0; $i <= self::MAX_DP; $i += 1) {
+            $options[$i] = $i;
+        }
+        return $options;
+    }
+}
+
+
+/**
+ * Contains the logic for handling question flags.
+ *
+ * @copyright 2010 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class question_flags {
+    /**
+     * Get the checksum that validates that a toggle request is valid.
+     * @param integer $qubaid the question usage id.
+     * @param integer $questionid the question id.
+     * @param integer $sessionid the question_attempt id.
+     * @param object $user the user. If null, defaults to $USER.
+     * @return string that needs to be sent to question/toggleflag.php for it to work.
+     */
+    protected static function get_toggle_checksum($qubaid, $questionid, $qaid, $user = null) {
+        if (is_null($user)) {
+            global $USER;
+            $user = $USER;
+        }
+        return md5($qubaid . "_" . $user->secret . "_" . $questionid . "_" . $qaid);
+    }
+
+    /**
+     * Get the postdata that needs to be sent to question/toggleflag.php to change the flag state.
+     * You need to append &newstate=0/1 to this.
+     * @return the post data to send.
+     */
+    public static function get_postdata(question_attempt $qa) {
+        $qaid = $qa->get_database_id();
+        $qubaid = $qa->get_usage_id();
+        $qid = $qa->get_question()->id;
+        $checksum = self::get_toggle_checksum($qubaid, $qid, $qaid);
+        return "qaid=$qaid&qubaid=$qubaid&qid=$qid&checksum=$checksum&sesskey=" . sesskey();
+    }
+
+    /**
+     * If the request seems valid, update the flag state of a question attempt.
+     * Throws exceptions if this is not a valid update request.
+     * @param integer $qubaid the question usage id.
+     * @param integer $questionid the question id.
+     * @param integer $sessionid the question_attempt id.
+     * @param string $checksum checksum, as computed by {@link get_toggle_checksum()}
+     *      corresponding to the last three arguments.
+     * @param boolean $newstate the new state of the flag. true = flagged.
+     */
+    public static function update_flag($qubaid, $questionid, $qaid, $checksum, $newstate) {
+        // Check the checksum - it is very hard to know who a question session belongs
+        // to, so we require that checksum parameter is matches an md5 hash of the 
+        // three ids and the users username. Since we are only updating a flag, that
+        // probably makes it sufficiently difficult for malicious users to toggle
+        // other users flags.
+        if ($checksum != question_flags::get_toggle_checksum($qubaid, $questionid, $qaid)) {
+            throw new Exception('checksum failure');
+        }
+
+        $dm = new question_engine_data_mapper();
+        $dm->update_question_attempt_flag($qubaid, $questionid, $qaid, $newstate);
+    }
+
+    public static function initialise_js() {
+        global $CFG;
+
+        require_js(array('yui_yahoo','yui_dom','yui_event','yui_connection'));
+        require_js($CFG->wwwroot . '/question/qengine.js');
+
+        $config = array(
+            'actionurl' => $CFG->wwwroot . '/question/toggleflag.php',
+            'flagicon' => $CFG->pixpath . '/i/flagged.png',
+            'unflagicon' => $CFG->pixpath . '/i/unflagged.png',
+            'flagtooltip' => get_string('clicktoflag', 'question'),
+            'unflagtooltip' => get_string('clicktounflag', 'question'),
+            'flaggedalt' => get_string('flagged', 'question'),
+            'unflaggedalt' => get_string('notflagged', 'question'),
+        );
+        return print_js_config($config, 'qengine_config', true);
+    }
+}
+
+
+class question_out_of_sequence_exception extends moodle_exception {
+    function __construct($qubaid, $slot, $postdata) {
+        if ($postdata == null) {
+            $postdata = data_submitted();
+        }
+        parent::__construct('submissionoutofsequence', 'question', '', null,
+                "QUBAid: $qubaid, slot: $slot, post data: " . print_r($postdata, true));
+    }
+}
+
+
+/**
+ * This class keeps track of a group of questions that are being attempted,
+ * and which state, and so on, each one is currently in.
+ *
+ * A quiz attempt or a lesson attempt could use an instance of this class to
+ * keep track of all the questions in the attempt and process student submissions.
+ * It is basically a collection of {@question_attempt} objects.
+ *
+ * The questions being attempted as part of this usage are identified by an integer
+ * that is passed into many of the methods as $slot. ($question->id is not
+ * used so that the same question can be used more than once in an attempt.)
+ *
+ * Normally, calling code should be able to do everything it needs to be calling
+ * methods of this class. You should not normally need to get individual
+ * {@question_attempt} objects and play around with their inner workind, in code
+ * that it outside the quetsion engine.
+ *
+ * Instances of this class correspond to rows in the question_usages table.
+ *
+ * @copyright 2009 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_usage_by_activity {
+    /**
+     * @var integer|string the id for this usage. If this usage was loaded from
+     * the database, then this is the database id. Otherwise a unique random
+     * string is used.
+     */
+    protected $id = null;
+
+    /**
+     * @var string name of an archetypal behaviour, that should be used
+     * by questions in this usage if possible.
+     */
+    protected $preferredbehaviour = null;
+
+    /** @var object the context this usage belongs to. */
+    protected $context;