From 93a543938c551d1ac67a1477184c1562abd90984 Mon Sep 17 00:00:00 2001 From: Jean-Michel Vedrine Date: Fri, 10 May 2013 09:51:53 +0200 Subject: [PATCH] MDL-27414 Upgrade the randomsamatch qtype to the new question engine Added XML import/export Different randomsamatch qtypes in the same quiz can now pick the same shortanswer question. Images are not preserved if shortanswer question is deleted after being used in a randomsamatch attempt. --- question/type/match/renderer.php | 22 +- .../type/randomsamatch/backup/moodle1/lib.php | 71 +++ ...ackup_qtype_randomsamatch_plugin.class.php | 8 +- ...store_qtype_randomsamatch_plugin.class.php | 45 +- question/type/randomsamatch/db/install.xml | 14 +- question/type/randomsamatch/db/upgrade.php | 190 ++++++++ question/type/randomsamatch/db/upgradelib.php | 243 ++++++++++ .../randomsamatch/edit_randomsamatch_form.php | 30 +- .../lang/en/qtype_randomsamatch.php | 6 + question/type/randomsamatch/lib.php | 47 ++ question/type/randomsamatch/question.php | 140 ++++++ question/type/randomsamatch/questiontype.php | 314 ++++++------- question/type/randomsamatch/renderer.php | 43 ++ question/type/randomsamatch/tests/helper.php | 139 ++++++ .../randomsamatch/tests/question_test.php | 153 ++++++ .../tests/upgradelibnewqe_test.php | 406 ++++++++++++++++ .../randomsamatch/tests/walkthrough_test.php | 435 ++++++++++++++++++ question/type/randomsamatch/version.php | 13 +- 18 files changed, 2125 insertions(+), 194 deletions(-) create mode 100644 question/type/randomsamatch/backup/moodle1/lib.php create mode 100644 question/type/randomsamatch/db/upgrade.php create mode 100644 question/type/randomsamatch/db/upgradelib.php create mode 100644 question/type/randomsamatch/lib.php create mode 100644 question/type/randomsamatch/question.php create mode 100644 question/type/randomsamatch/renderer.php create mode 100644 question/type/randomsamatch/tests/helper.php create mode 100644 question/type/randomsamatch/tests/question_test.php create mode 100644 question/type/randomsamatch/tests/upgradelibnewqe_test.php create mode 100644 question/type/randomsamatch/tests/walkthrough_test.php diff --git a/question/type/match/renderer.php b/question/type/match/renderer.php index 5bc2f8282f3..09c2c7a7e08 100644 --- a/question/type/match/renderer.php +++ b/question/type/match/renderer.php @@ -58,9 +58,7 @@ class qtype_match_renderer extends qtype_with_combined_feedback_renderer { $result .= html_writer::start_tag('tr', array('class' => 'r' . $parity)); $fieldname = 'sub' . $key; - $result .= html_writer::tag('td', $question->format_text( - $question->stems[$stemid], $question->stemformat[$stemid], - $qa, 'qtype_match', 'subquestion', $stemid), + $result .= html_writer::tag('td', $this->format_stem_text($qa, $stemid), array('class' => 'text')); $classes = 'control'; @@ -109,6 +107,20 @@ class qtype_match_renderer extends qtype_with_combined_feedback_renderer { return $this->combined_feedback($qa); } + /** + * Format each question stem. Overwritten by randomsamatch renderer. + * + * @param question_attempt $qa + * @param integer $stemid stem index + * @return string + */ + public function format_stem_text($qa, $stemid) { + $question = $qa->get_question(); + return $question->format_text( + $question->stems[$stemid], $question->stemformat[$stemid], + $qa, 'qtype_match', 'subquestion', $stemid); + } + protected function format_choices($question) { $choices = array(); foreach ($question->get_choice_order() as $key => $choiceid) { @@ -125,9 +137,7 @@ class qtype_match_renderer extends qtype_with_combined_feedback_renderer { $choices = $this->format_choices($question); $right = array(); foreach ($stemorder as $key => $stemid) { - $right[] = $question->format_text($question->stems[$stemid], - $question->stemformat[$stemid], $qa, - 'qtype_match', 'subquestion', $stemid) . ' – ' . + $right[] = $this->format_stem_text($qa, $stemid) . ' – ' . $choices[$question->get_right_choice_for($stemid)]; } diff --git a/question/type/randomsamatch/backup/moodle1/lib.php b/question/type/randomsamatch/backup/moodle1/lib.php new file mode 100644 index 00000000000..b6fba4de2f9 --- /dev/null +++ b/question/type/randomsamatch/backup/moodle1/lib.php @@ -0,0 +1,71 @@ +. + +/** + * Serve question type files + * + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Random shortanswer matching question type conversion handler. + * + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ +class moodle1_qtype_randomsamatch_handler extends moodle1_qtype_handler { + + /** + * Returns the list of paths within one that this qtype needs to have included + * in the grouped question structure + * + * @return array of strings + */ + public function get_question_subpaths() { + return array( + 'RANDOMSAMATCH', + ); + } + + /** + * Appends the randomsamatch specific information to the question. + * + * @param array $data grouped question data + * @param array $raw grouped raw QUESTION data + */ + public function process_question(array $data, array $raw) { + + // Convert match options. + if (isset($data['randomsamatch'])) { + $randomsamatch = $data['randomsamatch'][0]; + } else { + $randomsamatch = array('choose' => 4); + } + $randomsamatch['id'] = $this->converter->get_nextid(); + $randomsamatch['subcats'] = 1; + $randomsamatch['correctfeedback'] = ''; + $randomsamatch['correctfeedbackformat'] = FORMAT_HTML; + $randomsamatch['partiallycorrectfeedback'] = ''; + $randomsamatch['partiallycorrectfeedbackformat'] = FORMAT_HTML; + $randomsamatch['incorrectfeedback'] = ''; + $randomsamatch['incorrectfeedbackformat'] = FORMAT_HTML; + $this->write_xml('randomsamatch', $randomsamatch, array('/randomsamatch/id')); + } +} diff --git a/question/type/randomsamatch/backup/moodle2/backup_qtype_randomsamatch_plugin.class.php b/question/type/randomsamatch/backup/moodle2/backup_qtype_randomsamatch_plugin.class.php index aaa0acfccf9..2ac91917217 100644 --- a/question/type/randomsamatch/backup/moodle2/backup_qtype_randomsamatch_plugin.class.php +++ b/question/type/randomsamatch/backup/moodle2/backup_qtype_randomsamatch_plugin.class.php @@ -49,14 +49,16 @@ class backup_qtype_randomsamatch_plugin extends backup_qtype_plugin { // Now create the qtype own structures. $randomsamatch = new backup_nested_element('randomsamatch', array('id'), array( - 'choose')); + 'choose', 'subcats', 'correctfeedback', 'correctfeedbackformat', + 'partiallycorrectfeedback', 'partiallycorrectfeedbackformat', + 'incorrectfeedback', 'incorrectfeedbackformat', 'shownumcorrect')); // Now the own qtype tree. $pluginwrapper->add_child($randomsamatch); // Set source to populate the data. - $randomsamatch->set_source_table('question_randomsamatch', - array('question' => backup::VAR_PARENTID)); + $randomsamatch->set_source_table('qtype_randomsamatch_options', + array('questionid' => backup::VAR_PARENTID)); return $plugin; } diff --git a/question/type/randomsamatch/backup/moodle2/restore_qtype_randomsamatch_plugin.class.php b/question/type/randomsamatch/backup/moodle2/restore_qtype_randomsamatch_plugin.class.php index 4e3961c8b24..a86e0e3b528 100644 --- a/question/type/randomsamatch/backup/moodle2/restore_qtype_randomsamatch_plugin.class.php +++ b/question/type/randomsamatch/backup/moodle2/restore_qtype_randomsamatch_plugin.class.php @@ -41,7 +41,7 @@ class restore_qtype_randomsamatch_plugin extends restore_qtype_plugin { $paths = array(); - // Add own qtype stuff + // Add own qtype stuff. $elename = 'randomsamatch'; $elepath = $this->get_pathfor('/randomsamatch'); $paths[] = new restore_path_element($elename, $elepath); @@ -64,14 +64,34 @@ class restore_qtype_randomsamatch_plugin extends restore_qtype_plugin { $questioncreated = $this->get_mappingid('question_created', $oldquestionid) ? true : false; // If the question has been created by restore, we need to create its - // question_randomsamatch too. + // qtype_randomsamatch_options too. if ($questioncreated) { + // Fill in some field that were added in 2.1, and so which may be missing + // from backups made in older versions of Moodle. + if (!isset($data->subcats)) { + $data->subcats = 1; + } + if (!isset($data->correctfeedback)) { + $data->correctfeedback = ''; + $data->correctfeedbackformat = FORMAT_HTML; + } + if (!isset($data->partiallycorrectfeedback)) { + $data->partiallycorrectfeedback = ''; + $data->partiallycorrectfeedbackformat = FORMAT_HTML; + } + if (!isset($data->incorrectfeedback)) { + $data->incorrectfeedback = ''; + $data->incorrectfeedbackformat = FORMAT_HTML; + } + if (!isset($data->shownumcorrect)) { + $data->shownumcorrect = 0; + } // Adjust some columns. - $data->question = $newquestionid; + $data->questionid = $newquestionid; // Insert record. - $newitemid = $DB->insert_record('question_randomsamatch', $data); + $newitemid = $DB->insert_record('qtype_randomsamatch_options', $data); // Create mapping. - $this->set_mapping('question_randomsamatch', $oldid, $newitemid); + $this->set_mapping('qtype_randomsamatch_options', $oldid, $newitemid); } } @@ -82,7 +102,7 @@ class restore_qtype_randomsamatch_plugin extends restore_qtype_plugin { * answer is one comma separated list of hypen separated pairs * containing question->id and question_answers->id */ - public function recode_state_answer($state) { + public function recode_legacy_state_answer($state) { $answer = $state->answer; $resultarr = array(); foreach (explode(',', $answer) as $pair) { @@ -95,4 +115,17 @@ class restore_qtype_randomsamatch_plugin extends restore_qtype_plugin { } return implode(',', $resultarr); } + + /** + * Return the contents of this qtype to be processed by the links decoder. + */ + public static function define_decode_contents() { + + $contents = array(); + + $fields = array('correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'); + $contents[] = new restore_decode_content('qtype_randomsamatch_options', $fields, 'qtype_randomsamatch_options'); + + return $contents; + } } diff --git a/question/type/randomsamatch/db/install.xml b/question/type/randomsamatch/db/install.xml index 682de290c98..b3f917cd76d 100644 --- a/question/type/randomsamatch/db/install.xml +++ b/question/type/randomsamatch/db/install.xml @@ -4,15 +4,23 @@ xsi:noNamespaceSchemaLocation="../../../../lib/xmldb/xmldb.xsd" > - +
- + + + + + + + + + - +
diff --git a/question/type/randomsamatch/db/upgrade.php b/question/type/randomsamatch/db/upgrade.php new file mode 100644 index 00000000000..fc0e1b35d77 --- /dev/null +++ b/question/type/randomsamatch/db/upgrade.php @@ -0,0 +1,190 @@ +. + +/** + * Matching question type upgrade code. + * + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + + +/** + * Upgrade code for the random short answer matching question type. + * @param int $oldversion the version we are upgrading from. + */ +function xmldb_qtype_randomsamatch_upgrade($oldversion) { + global $CFG, $DB; + + $dbman = $DB->get_manager(); + + // Moodle v2.2.0 release upgrade line. + // Put any upgrade step following this. + + // Moodle v2.3.0 release upgrade line. + // Put any upgrade step following this. + + // Moodle v2.4.0 release upgrade line. + // Put any upgrade step following this. + + if ($oldversion < 2013110501) { + + // Define table question_randomsamatch to be renamed to qtype_randomsamatch_options. + $table = new xmldb_table('question_randomsamatch'); + + // Launch rename table for qtype_randomsamatch_options. + if ($dbman->table_exists($table)) { + $dbman->rename_table($table, 'qtype_randomsamatch_options'); + } + + // Record that qtype_randomsamatch savepoint was reached. + upgrade_plugin_savepoint(true, 2013110501, 'qtype', 'randomsamatch'); + } + + if ($oldversion < 2013110502) { + + // Define key question (foreign) to be dropped form qtype_randomsamatch_options. + $table = new xmldb_table('qtype_randomsamatch_options'); + $field = new xmldb_field('question', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'id'); + + if ($dbman->field_exists($table, $field)) { + // Launch drop key question. + $key = new xmldb_key('question', XMLDB_KEY_FOREIGN, array('question'), 'question', array('id')); + $dbman->drop_key($table, $key); + + // Launch rename field question. + $dbman->rename_field($table, $field, 'questionid'); + + $key = new xmldb_key('questionid', XMLDB_KEY_FOREIGN_UNIQUE, array('questionid'), 'question', array('id')); + // Launch add key questionid. + $dbman->add_key($table, $key); + } + + // Record that qtype_randomsamatch savepoint was reached. + upgrade_plugin_savepoint(true, 2013110502, 'qtype', 'randomsamatch'); + } + + if ($oldversion < 2013110503) { + + // Add subcats field. + $table = new xmldb_table('qtype_randomsamatch_options'); + + // Define field subcats to be added to qtype_randomsamatch_options. + $field = new xmldb_field('subcats', XMLDB_TYPE_INTEGER, 2, null, + XMLDB_NOTNULL, null, '1', 'choose'); + + // Conditionally launch add field subcats. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Add combined feedback fields. + // Define field correctfeedback to be added to qtype_randomsamatch_options. + $field = new xmldb_field('correctfeedback', XMLDB_TYPE_TEXT, 'small', null, + null, null, null, 'subcats'); + + // Conditionally launch add field correctfeedback. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + + // Now fill it with ''. + $DB->set_field('qtype_randomsamatch_options', 'correctfeedback', ''); + + // Now add the not null constraint. + $field = new xmldb_field('correctfeedback', XMLDB_TYPE_TEXT, 'small', null, + XMLDB_NOTNULL, null, null, 'subcats'); + $dbman->change_field_notnull($table, $field); + } + + // Define field correctfeedbackformat to be added to qtype_randomsamatch_options. + $field = new xmldb_field('correctfeedbackformat', XMLDB_TYPE_INTEGER, '2', null, + XMLDB_NOTNULL, null, '0', 'correctfeedback'); + + // Conditionally launch add field correctfeedbackformat. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field partiallycorrectfeedback to be added to qtype_randomsamatch_options. + $field = new xmldb_field('partiallycorrectfeedback', XMLDB_TYPE_TEXT, 'small', null, + null, null, null, 'correctfeedbackformat'); + + // Conditionally launch add field partiallycorrectfeedback. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + + // Now fill it with ''. + $DB->set_field('qtype_randomsamatch_options', 'partiallycorrectfeedback', ''); + + // Now add the not null constraint. + $field = new xmldb_field('partiallycorrectfeedback', XMLDB_TYPE_TEXT, 'small', null, + XMLDB_NOTNULL, null, null, 'correctfeedbackformat'); + $dbman->change_field_notnull($table, $field); + } + + // Define field partiallycorrectfeedbackformat to be added to qtype_randomsamatch_options. + $field = new xmldb_field('partiallycorrectfeedbackformat', XMLDB_TYPE_INTEGER, '2', null, + XMLDB_NOTNULL, null, '0', 'partiallycorrectfeedback'); + + // Conditionally launch add field partiallycorrectfeedbackformat. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field incorrectfeedback to be added to qtype_randomsamatch_options. + $field = new xmldb_field('incorrectfeedback', XMLDB_TYPE_TEXT, 'small', null, + null, null, null, 'partiallycorrectfeedbackformat'); + + // Conditionally launch add field incorrectfeedback. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + + // Now fill it with ''. + $DB->set_field('qtype_randomsamatch_options', 'incorrectfeedback', ''); + + // Now add the not null constraint. + $field = new xmldb_field('incorrectfeedback', XMLDB_TYPE_TEXT, 'small', null, + XMLDB_NOTNULL, null, null, 'partiallycorrectfeedbackformat'); + $dbman->change_field_notnull($table, $field); + } + + // Define field incorrectfeedbackformat to be added to qtype_randomsamatch_options. + $field = new xmldb_field('incorrectfeedbackformat', XMLDB_TYPE_INTEGER, '2', null, + XMLDB_NOTNULL, null, '0', 'incorrectfeedback'); + + // Conditionally launch add field incorrectfeedbackformat. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field shownumcorrect to be added to qtype_randomsamatch_options. + $field = new xmldb_field('shownumcorrect', XMLDB_TYPE_INTEGER, '2', null, + XMLDB_NOTNULL, null, '0', 'incorrectfeedbackformat'); + + // Conditionally launch add field shownumcorrect. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Record that qtype_randomsamatch savepoint was reached. + upgrade_plugin_savepoint(true, 2013110503, 'qtype', 'randomsamatch'); + } + return true; +} diff --git a/question/type/randomsamatch/db/upgradelib.php b/question/type/randomsamatch/db/upgradelib.php new file mode 100644 index 00000000000..f896c058efb --- /dev/null +++ b/question/type/randomsamatch/db/upgradelib.php @@ -0,0 +1,243 @@ +. + +/** + * Upgrade library code for the randomsamatch question type. + * + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + + +/** + * Class for converting attempt data for randomsamatch questions when upgrading + * attempts to the new question engine. + * + * This class is used by the code in question/engine/upgrade/upgradelib.php. + * + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_randomsamatch_qe2_attempt_updater extends question_qtype_attempt_updater { + /** @var array of question stems. */ + protected $stems; + /** @var array of question stems format. */ + protected $stemformat; + /** @var array of choices that can be matched to each stem. */ + protected $choices; + /** @var array index of the right choice for each stem. */ + protected $right; + /** @var array id of the right answer for each stem (used by {@link lookup_choice}). */ + protected $rightanswerid; + /** @var array shuffled stem indexes. */ + protected $stemorder; + /** @var array shuffled choice indexes. */ + protected $choiceorder; + /** @var array flipped version of the choiceorder array. */ + protected $flippedchoiceorder; + + public function question_summary() { + return ''; // Done later, after we know which shortanswer questions are used. + } + + public function right_answer() { + return ''; // Done later, after we know which shortanswer questions are used. + } + + /** + * Explode the answer saved as a string in state + * + * @param string $answer comma separated list of dash separated pairs + * @return array + */ + protected function explode_answer($answer) { + if (!$answer) { + return array(); + } + $bits = explode(',', $answer); + $selections = array(); + foreach ($bits as $bit) { + list($stem, $choice) = explode('-', $bit); + $selections[$stem] = $choice; + } + return $selections; + } + + protected function make_summary($pairs) { + $bits = array(); + foreach ($pairs as $stem => $answer) { + $bits[] = $stem . ' -> ' . $answer; + } + return implode('; ', $bits); + } + + /** + * Find the index corresponding to a choice + * + * @param integer $choice + * @return integer + */ + protected function lookup_choice($choice) { + if (array_key_exists($choice, $this->choices)) { + // Easy case: choice is a key in the choices array. + return $choice; + } else { + // But choice can also be the id of a shortanser correct answer + // without been a key of the choices array, in that case we need + // to first find the shortanswer id, then find the choices index + // associated to it. + $questionid = array_search($choice, $this->rightanswerid); + if ($questionid) { + return $this->right[$questionid]; + } + } + return null; + } + + public function response_summary($state) { + $choices = $this->explode_answer($state->answer); + if (empty($choices)) { + return null; + } + + $pairs = array(); + foreach ($choices as $stemid => $choicekey) { + if (array_key_exists($stemid, $this->stems) && $choices[$stemid]) { + $choiceid = $this->lookup_choice($choicekey); + if ($choiceid) { + $pairs[$this->stems[$stemid]] = $this->choices[$choiceid]; + } else { + $this->logger->log_assumption("Dealing with a place where the + student selected a choice that was later deleted for + randomsamatch question {$this->question->id}"); + $pairs[$this->stems[$stemid]] = '[CHOICE THAT WAS LATER DELETED]'; + } + } + } + + if ($pairs) { + return $this->make_summary($pairs); + } else { + return ''; + } + } + + public function was_answered($state) { + $choices = $this->explode_answer($state->answer); + foreach ($choices as $choice) { + if ($choice) { + return true; + } + } + return false; + } + + public function set_first_step_data_elements($state, &$data) { + $this->stems = array(); + $this->stemformat = array(); + $this->choices = array(); + $this->right = array(); + $this->rightanswer = array(); + $choices = $this->explode_answer($state->answer); + $this->stemorder = array(); + foreach ($choices as $key => $notused) { + $this->stemorder[] = $key; + } + $wrappedquestions = array(); + // TODO test what happen when some questions are missing. + foreach ($this->stemorder as $questionid) { + $wrappedquestions[] = $this->load_question($questionid); + } + foreach ($wrappedquestions as $wrappedquestion) { + + // We only take into account the first correct answer. + $foundcorrect = false; + foreach ($wrappedquestion->options->answers as $answer) { + if ($foundcorrect || $answer->fraction != 1.0) { + unset($wrappedquestion->options->answers[$answer->id]); + } else if (!$foundcorrect) { + $foundcorrect = true; + // Store right answer id, so we can use it later in lookup_choice. + $this->rightanswerid[$wrappedquestion->id] = $answer->id; + $key = array_search($answer->answer, $this->choices); + if ($key === false) { + $key = $answer->id; + $this->choices[$key] = $answer->answer; + $data['_choice_' . $key] = $answer->answer; + } + $this->stems[$wrappedquestion->id] = $wrappedquestion->questiontext; + $this->stemformat[$wrappedquestion->id] = $wrappedquestion->questiontextformat; + $this->right[$wrappedquestion->id] = $key; + $this->rightanswer[$wrappedquestion->id] = $answer->answer; + + $data['_stem_' . $wrappedquestion->id] = $wrappedquestion->questiontext; + $data['_stemformat_' . $wrappedquestion->id] = $wrappedquestion->questiontextformat; + $data['_right_' . $wrappedquestion->id] = $key; + + } + } + } + $this->choiceorder = array_keys($this->choices); + // We don't shuffle the choices as that seems unnecessary for old upgraded attempts. + $this->flippedchoiceorder = array_combine( + array_values($this->choiceorder), array_keys($this->choiceorder)); + + $data['_stemorder'] = implode(',', $this->stemorder); + $data['_choiceorder'] = implode(',', $this->choiceorder); + + $this->updater->qa->questionsummary = $this->to_text($this->question->questiontext) . ' {' . + implode('; ', $this->stems) . '} -> {' . implode('; ', $this->choices) . '}'; + + $answer = array(); + foreach ($this->stems as $key => $stem) { + $answer[$stem] = $this->choices[$this->right[$key]]; + } + $this->updater->qa->rightanswer = $this->make_summary($answer); + } + + public function supply_missing_first_step_data(&$data) { + throw new coding_exception('qtype_randomsamatch_updater::supply_missing_first_step_data ' . + 'not tested'); + $data['_stemorder'] = array(); + $data['_choiceorder'] = array(); + } + + public function set_data_elements_for_step($state, &$data) { + $choices = $this->explode_answer($state->answer); + + foreach ($this->stemorder as $i => $key) { + if (empty($choices[$key])) { + $data['sub' . $i] = 0; + continue; + } + $choice = $this->lookup_choice($choices[$key]); + + if (array_key_exists($choice, $this->flippedchoiceorder)) { + $data['sub' . $i] = $this->flippedchoiceorder[$choice] + 1; + } else { + $data['sub' . $i] = 0; + } + } + } + + public function load_question($questionid) { + return $this->qeupdater->load_question($questionid); + } +} diff --git a/question/type/randomsamatch/edit_randomsamatch_form.php b/question/type/randomsamatch/edit_randomsamatch_form.php index 6a41cacca93..2d255a61d77 100644 --- a/question/type/randomsamatch/edit_randomsamatch_form.php +++ b/question/type/randomsamatch/edit_randomsamatch_form.php @@ -17,8 +17,7 @@ /** * Defines the editing form for the randomsamatch question type. * - * @package qtype - * @subpackage randomsamatch + * @package qtype_randomsamatch * @copyright 2007 Jamie Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ @@ -41,20 +40,37 @@ class qtype_randomsamatch_edit_form extends question_edit_form { } $mform->addElement('select', 'choose', - get_string('randomsamatchnumber', 'quiz'), $questionstoselect); + get_string('randomsamatchnumber', 'qtype_randomsamatch'), $questionstoselect); $mform->setType('feedback', PARAM_RAW); + $mform->addElement('advcheckbox', 'subcats', + get_string('subcats', 'qtype_randomsamatch'), null, null, array(0, 1)); + $mform->addHelpButton('subcats', 'subcats', 'qtype_randomsamatch'); + $mform->setDefault('subcats', 1); + $mform->addElement('hidden', 'fraction', 0); $mform->setType('fraction', PARAM_RAW); + + $this->add_combined_feedback_fields(true); + $this->add_interactive_settings(true, true); } protected function data_preprocessing($question) { + $question = parent::data_preprocessing($question); + $question = $this->data_preprocessing_combined_feedback($question, true); + $question = $this->data_preprocessing_hints($question, true, true); + + if (!empty($question->options)) { + $question->choose = $question->options->choose; + $question->subcats = $question->options->subcats; + } + if (empty($question->name)) { - $question->name = get_string('randomsamatch', 'quiz'); + $question->name = get_string('randomsamatch', 'qtype_randomsamatch'); } if (empty($question->questiontext)) { - $question->questiontext = get_string('randomsamatchintro', 'quiz'); + $question->questiontext = get_string('randomsamatchintro', 'qtype_randomsamatch'); } return $question; } @@ -66,12 +82,14 @@ class qtype_randomsamatch_edit_form extends question_edit_form { public function validation($data, $files) { global $DB; $errors = parent::validation($data, $files); + if (isset($data->categorymoveto)) { list($category) = explode(',', $data['categorymoveto']); } else { list($category) = explode(',', $data['category']); } - $saquestions = question_bank::get_qtype('randomsamatch')->get_sa_candidates($category); + $saquestions = question_bank::get_qtype('randomsamatch')->get_available_saquestions_from_category( + $category, $data['subcats']); $numberavailable = count($saquestions); if ($saquestions === false) { $a = new stdClass(); diff --git a/question/type/randomsamatch/lang/en/qtype_randomsamatch.php b/question/type/randomsamatch/lang/en/qtype_randomsamatch.php index 78758858694..82848d94c43 100644 --- a/question/type/randomsamatch/lang/en/qtype_randomsamatch.php +++ b/question/type/randomsamatch/lang/en/qtype_randomsamatch.php @@ -23,6 +23,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['insufficientoptions'] = 'Insufficient selection options are available for this question, therefore it is not available in this quiz. Please inform your teacher.'; $string['nosaincategory'] = 'There are no short answer questions in the category that you chose \'{$a->catname}\'. Choose a different category, make some questions in this category.'; $string['notenoughsaincategory'] = 'There is/are only {$a->nosaquestions} short answer questions in the category that you chose \'{$a->catname}\'. Choose a different category, make some more questions in this category or reduce the amount of questions you\'ve selected.'; $string['pluginname'] = 'Random short-answer matching'; @@ -31,3 +32,8 @@ $string['pluginname_link'] = 'question/type/randomsamatch'; $string['pluginnameadding'] = 'Adding a Random short-answer matching question'; $string['pluginnameediting'] = 'Editing a Random short-answer matching question'; $string['pluginnamesummary'] = 'Like a Matching question, but created randomly from the short answer questions in a particular category.'; +$string['randomsamatchnumber'] = 'Number of questions to select'; +$string['randomsamatch'] = 'Random short-answer matching'; +$string['randomsamatchintro'] = 'For each of the following questions, select the matching answer from the menu.'; +$string['subcats'] = 'Include subcategories'; +$string['subcats_help'] = 'If checked, questions will be choosen from subcategories too.'; diff --git a/question/type/randomsamatch/lib.php b/question/type/randomsamatch/lib.php new file mode 100644 index 00000000000..110a4fd1f1e --- /dev/null +++ b/question/type/randomsamatch/lib.php @@ -0,0 +1,47 @@ +. + +/** + * Serve question type files + * + * @since 2.0 + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + + +/** + * Checks file access for random short answer matching questions. + * @package qtype_randomsamatch + * @category files + * @param stdClass $course course object + * @param stdClass $cm course module object + * @param stdClass $context context object + * @param string $filearea file area + * @param array $args extra arguments + * @param bool $forcedownload whether or not force download + * @param array $options additional options affecting the file serving + * @return bool + */ +function qtype_randomsamatch_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options=array()) { + global $DB, $CFG; + require_once($CFG->libdir . '/questionlib.php'); + question_pluginfile($course, $context, 'qtype_randomsamatch', $filearea, $args, $forcedownload, $options); +} diff --git a/question/type/randomsamatch/question.php b/question/type/randomsamatch/question.php new file mode 100644 index 00000000000..f773b9efd9e --- /dev/null +++ b/question/type/randomsamatch/question.php @@ -0,0 +1,140 @@ +. + +/** + * Matching question definition class. + * + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/question/type/match/question.php'); + +/** + * Represents a randomsamatch question. + * + * @copyright 22013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_randomsamatch_question extends qtype_match_question { + /** @var qtype_randomsamatch_question_loader helper for loading the shortanswer questions. */ + public $questionsloader; + + public function start_attempt(question_attempt_step $step, $variant) { + $saquestions = $this->questionsloader->load_questions(); + foreach ($saquestions as $wrappedquestion) { + // Store and save stem text and format. + $this->stems[$wrappedquestion->id] = $wrappedquestion->questiontext; + $this->stemformat[$wrappedquestion->id] = $wrappedquestion->questiontextformat; + $step->set_qt_var('_stem_' . $wrappedquestion->id, $this->stems[$wrappedquestion->id]); + $step->set_qt_var('_stemformat_' . $wrappedquestion->id, $this->stemformat[$wrappedquestion->id]); + + // Find, store and save right choice id. + $key = $this->find_right_answer($wrappedquestion); + $this->right[$wrappedquestion->id] = $key; + $step->set_qt_var('_right_' . $wrappedquestion->id, $key); + // No need to save saquestions, it will be saved by parent class in _stemorder. + } + + // Save all the choices. + foreach ($this->choices as $key => $answer) { + $step->set_qt_var('_choice_' . $key, $answer); + } + + parent::start_attempt($step, $variant); + } + + /** + * Find the corresponding choice id of the first correct answer of a shortanswer question. + * choice is added to the randomsamatch question if it doesn't already exist. + * @param object $wrappedquestion short answer question. + * @return int correct choice id. + */ + public function find_right_answer($wrappedquestion) { + // We only take into account *one* (the first) correct answer. + while ($answer = array_shift($wrappedquestion->answers)) { + if (!question_state::graded_state_for_fraction( + $answer->fraction)->is_incorrect()) { + // Store this answer as a choice, only if this is a new one. + $key = array_search($answer->answer, $this->choices); + if ($key === false) { + $key = $answer->id; + $this->choices[$key] = $answer->answer; + } + return $key; + } + } + // We should never get there. + throw new coding_exception('shortanswerquestionwithoutrightanswer', $wrappedquestion->id); + + } + + public function apply_attempt_state(question_attempt_step $step) { + $saquestions = explode(',', $step->get_qt_var('_stemorder')); + foreach ($saquestions as $questionid) { + $this->stems[$questionid] = $step->get_qt_var('_stem_' . $questionid); + $this->stemformat[$questionid] = $step->get_qt_var('_stemformat_' . $questionid); + $key = $step->get_qt_var('_right_' . $questionid); + $this->right[$questionid] = $key; + $this->choices[$key] = $step->get_qt_var('_choice_' . $key); + } + parent::apply_attempt_state($step); + } +} + +/** + * This class is responsible for loading the questions that a question needs from the database. + * + * @copyright 2013 Jean-Michel vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_randomsamatch_question_loader { + /** @var array hold available shortanswers questionid to choose from. */ + protected $availablequestions; + /** @var int how many questions to load. */ + protected $choose; + + /** + * Constructor + * @param array $availablequestions array of available question ids. + * @param int $choose how many questions to load. + */ + public function __construct($availablequestions, $choose) { + $this->availablequestions = $availablequestions; + $this->choose = $choose; + } + + /** + * Choose and load the desired number of questions. + * @return array of short answer questions. + */ + public function load_questions() { + if ($this->choose > count($this->availablequestions)) { + throw new coding_exception('notenoughtshortanswerquestions'); + } + + $questionids = draw_rand_array($this->availablequestions, $this->choose); + $questions = array(); + foreach ($questionids as $questionid) { + $questions[] = question_bank::load_question($questionid); + } + return $questions; + } +} diff --git a/question/type/randomsamatch/questiontype.php b/question/type/randomsamatch/questiontype.php index b54bac147e5..c05c26300bc 100644 --- a/question/type/randomsamatch/questiontype.php +++ b/question/type/randomsamatch/questiontype.php @@ -17,8 +17,7 @@ /** * Question type class for the randomsamatch question type. * - * @package qtype - * @subpackage randomsamatch + * @package qtype_randomsamatch * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -26,6 +25,8 @@ defined('MOODLE_INTERNAL') || die(); +require_once($CFG->dirroot . '/question/type/questionbase.php'); +require_once($CFG->dirroot . '/question/type/numerical/question.php'); /** * The randomsamatch question type class. @@ -37,34 +38,30 @@ defined('MOODLE_INTERNAL') || die(); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_randomsamatch extends question_type { + /** + * Cache of available shortanswer question ids from a particular category. + * @var array two-dimensional array. The first key is a category id, the + * second key is wether subcategories should be included. + */ + private $availablesaquestionsbycategory = array(); const MAX_SUBQUESTIONS = 10; - public function requires_qtypes() { - return array('shortanswer', 'match'); - } - public function is_usable_by_random() { return false; } public function get_question_options($question) { global $DB; - $question->options = $DB->get_record('question_randomsamatch', - array('question' => $question->id), '*', MUST_EXIST); + parent::get_question_options($question); + $question->options = $DB->get_record('qtype_randomsamatch_options', + array('questionid' => $question->id)); - // This could be included as a flag in the database. It's already - // supported by the code. - // Recurse subcategories: 0 = no recursion, 1 = recursion . - $question->options->subcats = 1; return true; } public function save_question_options($question) { global $DB; - $options = new stdClass(); - $options->question = $question->id; - $options->choose = $question->choose; if (2 > $question->choose) { $result = new stdClass(); @@ -72,174 +69,93 @@ class qtype_randomsamatch extends question_type { return $result; } - if ($existing = $DB->get_record('question_randomsamatch', - array('question' => $options->question))) { - $options->id = $existing->id; - $DB->update_record('question_randomsamatch', $options); - } else { - $DB->insert_record('question_randomsamatch', $options); + $context = $question->context; + + // Save the question options. + $options = $DB->get_record('qtype_randomsamatch_options', array('questionid' => $question->id)); + if (!$options) { + $options = new stdClass(); + $options->questionid = $question->id; + $options->correctfeedback = ''; + $options->partiallycorrectfeedback = ''; + $options->incorrectfeedback = ''; + $options->id = $DB->insert_record('qtype_randomsamatch_options', $options); } + + $options->choose = $question->choose; + $options->subcats = $question->subcats; + $options = $this->save_combined_feedback_helper($options, $question, $context, true); + $DB->update_record('qtype_randomsamatch_options', $options); + + $this->save_hints($question, true); + return true; } + protected function make_hint($hint) { + return question_hint_with_parts::load_from_record($hint); + } + public function delete_question($questionid, $contextid) { global $DB; - $DB->delete_records('question_randomsamatch', array('question' => $questionid)); + $DB->delete_records('qtype_randomsamatch_options', array('questionid' => $questionid)); parent::delete_question($questionid, $contextid); } - public function create_session_and_responses(&$question, &$state, $cmoptions, $attempt) { - // Choose a random shortanswer question from the category: - // We need to make sure that no question is used more than once in the - // quiz. Therfore the following need to be excluded: - // 1. All questions that are explicitly assigned to the quiz - // 2. All random questions - // 3. All questions that are already chosen by an other random question. - global $QTYPES, $OUTPUT, $USER; - if (!isset($cmoptions->questionsinuse)) { - $cmoptions->questionsinuse = $cmoptions->questions; - } + public function move_files($questionid, $oldcontextid, $newcontextid) { + parent::move_files($questionid, $oldcontextid, $newcontextid); - if ($question->options->subcats) { - // Recurse into subcategories. - $categorylist = question_categorylist($question->category); - } else { - $categorylist = array($question->category); - } - - $saquestions = $this->get_sa_candidates($categorylist, $cmoptions->questionsinuse); - - $count = count($saquestions); - $wanted = $question->options->choose; - - if ($count < $wanted) { - $question->questiontext = "Insufficient selection options are - available for this question, therefore it is not available in this - quiz. Please inform your teacher."; - // Treat this as a description from this point on. - $question->qtype = 'description'; - return true; - } - - $saquestions = - draw_rand_array($saquestions, $question->options->choose); // From bug 1889. - - foreach ($saquestions as $key => $wrappedquestion) { - if (!$QTYPES[$wrappedquestion->qtype] - ->get_question_options($wrappedquestion)) { - return false; - } - - // Now we overwrite the $question->options->answers field to only - // *one* (the first) correct answer. This loop can be deleted to - // take all answers into account (i.e. put them all into the - // drop-down menu. - $foundcorrect = false; - foreach ($wrappedquestion->options->answers as $answer) { - if ($foundcorrect || $answer->fraction != 1.0) { - unset($wrappedquestion->options->answers[$answer->id]); - } else if (!$foundcorrect) { - $foundcorrect = true; - } - } - - if (!$QTYPES[$wrappedquestion->qtype] - ->create_session_and_responses($wrappedquestion, $state, $cmoptions, - $attempt)) { - return false; - } - $wrappedquestion->name_prefix = $question->name_prefix; - $wrappedquestion->maxgrade = $question->maxgrade; - $cmoptions->questionsinuse .= ",$wrappedquestion->id"; - $state->options->subquestions[$key] = clone($wrappedquestion); - } - - // Shuffle the answers (Do this always because this is a random question type). - $subquestionids = array_values(array_map(create_function('$val', - 'return $val->id;'), $state->options->subquestions)); - $subquestionids = swapshuffle($subquestionids); - - // Create empty responses. - foreach ($subquestionids as $val) { - $state->responses[$val] = ''; - } - return true; + $this->move_files_in_combined_feedback($questionid, $oldcontextid, $newcontextid); + $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); } - function restore_session_and_responses(&$question, &$state) { - global $DB; - global $QTYPES, $OUTPUT; - static $wrappedquestions = array(); - if (empty($state->responses[''])) { - $question->questiontext = "Insufficient selection options are - available for this question, therefore it is not available in this - quiz. Please inform your teacher."; - // Treat this as a description from this point on. - $question->qtype = 'description'; - } else { - $responses = explode(',', $state->responses['']); - $responses = array_map(create_function('$val', - 'return explode("-", $val);'), $responses); + protected function delete_files($questionid, $contextid) { + parent::delete_files($questionid, $contextid); - // Restore the previous responses. - $state->responses = array(); - foreach ($responses as $response) { - $wqid = $response[0]; - $state->responses[$wqid] = $response[1]; - if (!isset($wrappedquestions[$wqid])) { - if (!$wrappedquestions[$wqid] = $DB->get_record('question', array('id' => $wqid))) { - echo $OUTPUT->notification("Couldn't get question (id=$wqid)!"); - return false; - } - if (!$QTYPES[$wrappedquestions[$wqid]->qtype] - ->get_question_options($wrappedquestions[$wqid])) { - echo $OUTPUT->notification("Couldn't get question options (id=$response[0])!"); - return false; - } - - // Now we overwrite the $question->options->answers field to only - // *one* (the first) correct answer. This loop can be deleted to - // take all answers into account (i.e. put them all into the - // drop-down menu. - $foundcorrect = false; - foreach ($wrappedquestions[$wqid]->options->answers as $answer) { - if ($foundcorrect || $answer->fraction != 1.0) { - unset($wrappedquestions[$wqid]->options->answers[$answer->id]); - } else if (!$foundcorrect) { - $foundcorrect = true; - } - } - } - $wrappedquestion = clone($wrappedquestions[$wqid]); - - if (!$QTYPES[$wrappedquestion->qtype] - ->restore_session_and_responses($wrappedquestion, $state)) { - echo $OUTPUT->notification("Couldn't restore session of question (id=$response[0])!"); - return false; - } - $wrappedquestion->name_prefix = $question->name_prefix; - $wrappedquestion->maxgrade = $question->maxgrade; - - $state->options->subquestions[$wrappedquestion->id] = - clone($wrappedquestion); - } - } - return true; + $this->delete_files_in_combined_feedback($questionid, $contextid); + $this->delete_files_in_hints($questionid, $contextid); } - public function get_sa_candidates($categorylist, $questionsinuse = 0) { - global $DB; - list ($usql, $params) = $DB->get_in_or_equal($categorylist); - list ($ques_usql, $ques_params) = $DB->get_in_or_equal(explode(',', $questionsinuse), - SQL_PARAMS_QM, null, false); - $params = array_merge($params, $ques_params); - return $DB->get_records_select('question', - "qtype = 'shortanswer' " . - "AND category $usql " . - "AND parent = '0' " . - "AND hidden = '0'" . - "AND id $ques_usql", $params); + protected function initialise_question_instance(question_definition $question, $questiondata) { + parent::initialise_question_instance($question, $questiondata); + $availablesaquestions = $this->get_available_saquestions_from_category( + $question->category, $questiondata->options->subcats); + $question->shufflestems = false; + $question->stems = array(); + $question->choices = array(); + $question->right = array(); + $this->initialise_combined_feedback($question, $questiondata); + $question->questionsloader = new qtype_randomsamatch_question_loader( + $availablesaquestions, $questiondata->options->choose); + } + + public function can_analyse_responses() { + return false; + } + + /** + * Get all the usable shortanswer questions from a particular question category. + * + * @param integer $categoryid the id of a question category. + * @param bool $subcategories whether to include questions from subcategories. + * @return array of question records. + */ + public function get_available_saquestions_from_category($categoryid, $subcategories) { + if (isset($this->availablesaquestionsbycategory[$categoryid][$subcategories])) { + return $this->availablesaquestionsbycategory[$categoryid][$subcategories]; + } + + if ($subcategories) { + $categoryids = question_categorylist($categoryid); + } else { + $categoryids = array($categoryid); + } + + $questionids = question_bank::get_finder()->get_questions_from_categories( + $categoryids, "qtype = 'shortanswer'"); + $this->availablesaquestionsbycategory[$categoryid][$subcategories] = $questionids; + return $questionids; } /** @@ -251,4 +167,68 @@ class qtype_randomsamatch extends question_type { public function get_random_guess_score($question) { return 1/$question->options->choose; } + + /** + * Defines the table which extends the question table. This allows the base questiontype + * to automatically save, backup and restore the extra fields. + * + * @return an array with the table name (first) and then the column names (apart from id and questionid) + */ + public function extra_question_fields() { + return array('qtype_randomsamatch_options', + 'choose', // Number of shortanswer questions to choose. + 'subcats', // Questions can be choosen from subcategories. + ); + } + + /** + * Imports the question from Moodle XML format. + * + * @param array $xml structure containing the XML data + * @param object $fromform question object to fill: ignored by this function (assumed to be null) + * @param qformat_xml $format format class exporting the question + * @param object $extra extra information (not required for importing this question in this format) + * @return object question object + */ + public function import_from_xml($xml, $fromform, qformat_xml $format, $extra=null) { + // Return if data type is not our own one. + if (!isset($xml['@']['type']) || $xml['@']['type'] != $this->name()) { + return false; + } + + // Import the common question headers and set the corresponding field. + $fromform = $format->import_headers($xml); + $fromform->qtype = $this->name(); + $format->import_combined_feedback($fromform, $xml, true); + $format->import_hints($fromform, $xml, true); + + $extras = $this->extra_question_fields(); + array_shift($extras); + foreach ($extras as $extra) { + $fromform->$extra = $format->getpath($xml, array('#', $extra, 0, '#'), '', true); + } + + return $fromform; + } + + /** + * Exports the question to Moodle XML format. + * + * @param object $question question to be exported into XML format + * @param qformat_xml $format format class exporting the question + * @param object $extra extra information (not required for exporting this question in this format) + * @return string containing the question data in XML format + */ + public function export_to_xml($question, qformat_xml $format, $extra=null) { + $expout = ''; + $expout .= $format->write_combined_feedback($question->options, + $question->id, + $question->contextid); + $extraquestionfields = $this->extra_question_fields(); + array_shift($extraquestionfields); + foreach ($extraquestionfields as $extra) { + $expout .= " <$extra>" . $question->options->$extra . "\n"; + } + return $expout; + } } diff --git a/question/type/randomsamatch/renderer.php b/question/type/randomsamatch/renderer.php new file mode 100644 index 00000000000..28b803f8620 --- /dev/null +++ b/question/type/randomsamatch/renderer.php @@ -0,0 +1,43 @@ +. + +/** + * Matching question renderer class. + * + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/question/type/match/renderer.php'); + +/** + * Generates the output for randomsamatch questions. + * + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_randomsamatch_renderer extends qtype_match_renderer { + public function format_stem_text($qa, $stemid) { + $question = $qa->get_question(); + return $question->format_text( + $question->stems[$stemid], $question->stemformat[$stemid], + $qa, 'question', 'questiontext', $stemid); + } +} diff --git a/question/type/randomsamatch/tests/helper.php b/question/type/randomsamatch/tests/helper.php new file mode 100644 index 00000000000..7b26e051fb4 --- /dev/null +++ b/question/type/randomsamatch/tests/helper.php @@ -0,0 +1,139 @@ +. + +/** + * Test helpers for the randomsamatch question type. + * + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/type/randomsamatch/question.php'); +require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); + + +/** + * Test helper class for the randomsamatch question type. + * + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_randomsamatch_test_helper extends question_test_helper { + public function get_test_questions() { + return array('animals'); + } + + /** + * Makes a randomsamatch question similar to the match question returned + * by {@link make_a_matching_question}, but with no 'insect' distractor. + * @return qtype_randomsamatch_question + */ + public function make_randomsamatch_question_animals() { + question_bank::load_question_definition_classes('randomsamatch'); + $q = new qtype_randomsamatch_question(); + test_question_maker::initialise_a_question($q); + $q->name = 'Random shortanswer matching question'; + $q->questiontext = 'Classify the animals.'; + $q->generalfeedback = 'Frogs and toads are amphibians, the others are mammals.'; + $q->qtype = question_bank::get_qtype('randomsamatch'); + test_question_maker::set_standard_combined_feedback_fields($q); + $q->shufflestems = false; + $q->stems = array(); + $q->choices = array(); + $q->right = array(); + // Now we create 4 shortanswers question, + // but we just fill the needed fields. + question_bank::load_question_definition_classes('shortanswer'); + $sa1 = new qtype_shortanswer_question(); + test_question_maker::initialise_a_question($sa1); + $sa1->id = 25; + $sa1->questiontext = 'Dog'; + $sa1->answers = array( + 13 => new question_answer(13, 'Mammal', 1.0, 'Correct.', FORMAT_HTML), + 14 => new question_answer(14, 'Animal', 0.5, 'There is a betterresponse.', FORMAT_HTML), + 15 => new question_answer(15, '*', 0.0, 'That is a bad answer.', FORMAT_HTML), + ); + $sa1->qtype = question_bank::get_qtype('shortanswer'); + + $sa2 = new qtype_shortanswer_question(); + test_question_maker::initialise_a_question($sa2); + $sa2->id = 26; + $sa2->questiontext = 'Frog'; + $sa2->answers = array( + 16 => new question_answer(16, 'Amphibian', 1.0, 'Correct.', FORMAT_HTML), + 17 => new question_answer(17, 'A Prince', 1.0, 'Maybe.', FORMAT_HTML), + 18 => new question_answer(18, '*', 0.0, 'That is a bad answer.', FORMAT_HTML), + ); + $sa2->qtype = question_bank::get_qtype('shortanswer'); + + $sa3 = new qtype_shortanswer_question(); + test_question_maker::initialise_a_question($sa3); + $sa3->id = 27; + $sa3->questiontext = 'Toad'; + $sa3->answers = array( + 19 => new question_answer(19, 'Amphibian', 1.0, 'Correct.', FORMAT_HTML), + 20 => new question_answer(20, '*', 0.0, 'That is a bad answer.', FORMAT_HTML), + ); + $sa3->qtype = question_bank::get_qtype('shortanswer'); + + $sa4 = new qtype_shortanswer_question(); + test_question_maker::initialise_a_question($sa4); + $sa4->id = 28; + $sa4->questiontext = 'Cat'; + $sa4->answers = array( + 21 => new question_answer(21, 'Mammal', 1.0, 'Correct.', FORMAT_HTML), + ); + $sa4->qtype = question_bank::get_qtype('shortanswer'); + $q->questionsloader = new qtype_randomsamatch_test_question_loader(array(), 4, array($sa1, $sa2, $sa3, $sa4)); + return $q; + } +} + +/** + * Test implementation of {@link qtype_randomsamatch_question_loader}. Gets the questions + * from an array passed to the constructor, rather than querying the database. + * + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_randomsamatch_test_question_loader extends qtype_randomsamatch_question_loader { + /** @var array hold available shortanswers questions to choose from. */ + protected $questions; + + /** + * Constructor + * @param array $availablequestions not used for tests. + * @param int $choose how many questions to load (not used here). + * @param array $questions array of questions to use. + */ + public function __construct($availablequestions, $choose, $questions) { + parent::__construct($availablequestions, $choose); + $this->questions = $questions; + } + + /** + * Just return the shortanswers questions passed to the constructor. + * @return array of short answer questions. + */ + public function load_questions() { + return $this->questions; + } +} diff --git a/question/type/randomsamatch/tests/question_test.php b/question/type/randomsamatch/tests/question_test.php new file mode 100644 index 00000000000..cbe8a8e099e --- /dev/null +++ b/question/type/randomsamatch/tests/question_test.php @@ -0,0 +1,153 @@ +. + +/** + * Unit tests for the radom shortanswer matching question definition classes. + * + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); + + +/** + * Unit tests for the random shortanswer matching question definition class. + * + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_randomsamatch_question_test extends advanced_testcase { + + public function test_get_expected_data() { + $question = test_question_maker::make_question('randomsamatch'); + $question->start_attempt(new question_attempt_step(), 1); + + $this->assertEquals(array('sub0' => PARAM_INT, 'sub1' => PARAM_INT, + 'sub2' => PARAM_INT, 'sub3' => PARAM_INT), $question->get_expected_data()); + } + + public function test_is_complete_response() { + $question = test_question_maker::make_question('randomsamatch'); + $question->start_attempt(new question_attempt_step(), 1); + + $this->assertFalse($question->is_complete_response(array())); + $this->assertFalse($question->is_complete_response( + array('sub0' => '1', 'sub1' => '1', 'sub2' => '1', 'sub3' => '0'))); + $this->assertFalse($question->is_complete_response(array('sub1' => '1'))); + $this->assertTrue($question->is_complete_response( + array('sub0' => '1', 'sub1' => '1', 'sub2' => '1', 'sub3' => '1'))); + } + + public function test_is_gradable_response() { + $question = test_question_maker::make_question('randomsamatch'); + $question->start_attempt(new question_attempt_step(), 1); + + $this->assertFalse($question->is_gradable_response(array())); + $this->assertFalse($question->is_gradable_response( + array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); + $this->assertTrue($question->is_gradable_response( + array('sub0' => '1', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); + $this->assertTrue($question->is_gradable_response(array('sub1' => '1'))); + $this->assertTrue($question->is_gradable_response( + array('sub0' => '1', 'sub1' => '1', 'sub2' => '3', 'sub3' => '1'))); + } + + public function test_is_same_response() { + $question = test_question_maker::make_question('randomsamatch'); + $question->start_attempt(new question_attempt_step(), 1); + + $this->assertTrue($question->is_same_response( + array(), + array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); + + $this->assertTrue($question->is_same_response( + array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'), + array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'))); + + $this->assertFalse($question->is_same_response( + array('sub0' => '0', 'sub1' => '0', 'sub2' => '0', 'sub3' => '0'), + array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); + + $this->assertTrue($question->is_same_response( + array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'), + array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); + + $this->assertFalse($question->is_same_response( + array('sub0' => '2', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'), + array('sub0' => '1', 'sub1' => '2', 'sub2' => '3', 'sub3' => '1'))); + } + + public function test_grading() { + $question = test_question_maker::make_question('randomsamatch'); + $question->start_attempt(new question_attempt_step(), 1); + + $choiceorder = $question->get_choice_order(); + $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); + $this->assertEquals(array(1, question_state::$gradedright), + $question->grade_response(array('sub0' => $orderforchoice[13], + 'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16], + 'sub3' => $orderforchoice[13]))); + $this->assertEquals(array(0.25, question_state::$gradedpartial), + $question->grade_response(array('sub0' => $orderforchoice[13]))); + $this->assertEquals(array(0, question_state::$gradedwrong), + $question->grade_response(array('sub0' => $orderforchoice[16], + 'sub1' => $orderforchoice[13], 'sub2' => $orderforchoice[13], + 'sub3' => $orderforchoice[16]))); + } + + public function test_get_correct_response() { + $question = test_question_maker::make_question('randomsamatch'); + $question->start_attempt(new question_attempt_step(), 1); + + $choiceorder = $question->get_choice_order(); + $orderforchoice = array_combine(array_values($choiceorder), array_keys($choiceorder)); + + $this->assertEquals(array('sub0' => $orderforchoice[13], 'sub1' => $orderforchoice[16], + 'sub2' => $orderforchoice[16], 'sub3' => $orderforchoice[13]), + $question->get_correct_response()); + } + + public function test_get_question_summary() { + $question = test_question_maker::make_question('randomsamatch'); + $question->start_attempt(new question_attempt_step(), 1); + $qsummary = $question->get_question_summary(); + $this->assertRegExp('/' . preg_quote($question->questiontext, '/') . '/', $qsummary); + foreach ($question->stems as $stem) { + $this->assertRegExp('/' . preg_quote($stem, '/') . '/', $qsummary); + } + foreach ($question->choices as $choice) { + $this->assertRegExp('/' . preg_quote($choice, '/') . '/', $qsummary); + } + } + + public function test_summarise_response() { + $question = test_question_maker::make_question('randomsamatch'); + $question->shufflestems = false; + $question->start_attempt(new question_attempt_step(), 1); + + $summary = $question->summarise_response(array('sub0' => 2, 'sub1' => 1)); + + $this->assertRegExp('/Dog -> \w+; Frog -> \w+/', $summary); + } + + +} diff --git a/question/type/randomsamatch/tests/upgradelibnewqe_test.php b/question/type/randomsamatch/tests/upgradelibnewqe_test.php new file mode 100644 index 00000000000..86f5bb103e5 --- /dev/null +++ b/question/type/randomsamatch/tests/upgradelibnewqe_test.php @@ -0,0 +1,406 @@ +. + +/** + * Tests of the upgrade to the new Moodle question engine for attempts at + * randomsamatch questions. + * + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/engine/upgrade/tests/helper.php'); + + +/** + * Testing the upgrade of randomsamatch question attempts. + * + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_randomsamatch_attempt_upgrader_test extends question_attempt_upgrader_test_base { + public function test_randomsamatch_deferredfeedback_qsession1() { + $quiz = (object) array( + 'id' => '1', + 'course' => '2', + 'name' => 'random short answer matching deferred quiz', + 'intro' => '

To test random shortanswer matching questions.

', + 'introformat' => '1', + 'timeopen' => '0', + 'timeclose' => '0', + 'attempts' => '0', + 'attemptonlast' => '0', + 'grademethod' => '1', + 'decimalpoints' => '2', + 'questiondecimalpoints' => '-1', + 'review' => '4459503', + 'questionsperpage' => '1', + 'shufflequestions' => '0', + 'shuffleanswers' => '1', + 'questions' => '5,0', + 'sumgrades' => '1.00000', + 'grade' => '100.00000', + 'timecreated' => '0', + 'timemodified' => '1368446711', + 'timelimit' => '0', + 'password' => '', + 'subnet' => '', + 'popup' => '0', + 'delay1' => '0', + 'delay2' => '0', + 'showuserpicture' => '0', + 'showblocks' => '0', + 'preferredbehaviour' => 'deferredfeedback', + ); + $attempt = (object) array( + 'id' => '1', + 'uniqueid' => '1', + 'quiz' => '1', + 'userid' => '3', + 'attempt' => '1', + 'sumgrades' => '0.66667', + 'timestart' => '1368446755', + 'timefinish' => '1368446789', + 'timemodified' => '1368446789', + 'layout' => '5,0', + 'preview' => '0', + 'needsupgradetonewqe' => 1, + ); + $question = (object) array( + 'id' => '5', + 'category' => '1', + 'parent' => '0', + 'name' => 'Random shortanswer matching question animals', + 'questiontext' => 'For each of the following questions, select the matching answer from the menu.', + 'questiontextformat' => '1', + 'generalfeedback' => '', + 'generalfeedbackformat' => '1', + 'penalty' => '0.1000000', + 'qtype' => 'randomsamatch', + 'length' => '1', + 'stamp' => 'localhost+130513115611+72Efbk', + 'version' => 'localhost+130513115611+0REXHW', + 'hidden' => '0', + 'timecreated' => '1368446171', + 'timemodified' => '1368446171', + 'createdby' => '2', + 'modifiedby' => '2', + 'maxmark' => '1.0000000', + 'options' => (object) array( + 'id' => '1', + 'question' => '5', + 'choose' => '3', + 'subcats' => 1, + ), + 'defaultmark' => '1.0000000', + ); + $qsession = (object) array( + 'id' => '1', + 'attemptid' => '1', + 'questionid' => '5', + 'newest' => '3', + 'newgraded' => '3', + 'sumpenalty' => '0.1000000', + 'manualcomment' => '', + 'manualcommentformat' => '1', + 'flagged' => '0', + ); + $qstates = array( + 1 => (object) array( + 'id' => '1', + 'attempt' => '1', + 'question' => '5', + 'seq_number' => '0', + 'answer' => '2-0,3-0,6-0', + 'timestamp' => '1368446755', + 'event' => '0', + 'grade' => '0.0000000', + 'raw_grade' => '0.0000000', + 'penalty' => '0.0000000', + ), + 2 => (object) array( + 'id' => '2', + 'attempt' => '1', + 'question' => '5', + 'seq_number' => '1', + 'answer' => '2-3,3-5,6-3', + 'timestamp' => '1368446783', + 'event' => '2', + 'grade' => '0.0000000', + 'raw_grade' => '0.6666667', + 'penalty' => '0.1000000', + ), + 3 => (object) array( + 'id' => '3', + 'attempt' => '1', + 'question' => '5', + 'seq_number' => '2', + 'answer' => '2-3,3-5,6-3', + 'timestamp' => '1368446783', + 'event' => '6', + 'grade' => '0.6666667', + 'raw_grade' => '0.6666667', + 'penalty' => '0.1000000', + ), + ); + $sa1 = (object) array( + 'id' => '2', + 'category' => '1', + 'parent' => '0', + 'name' => 'animal 1', + 'questiontext' => 'Dog', + 'questiontextformat' => '1', + 'defaultmark' => '1', + 'penalty' => '0.1', + 'qtype' => 'shortanswer', + 'length' => '1', + 'stamp' => 'localhost+090227173002+mbdE0X', + 'version' => 'localhost+090304190917+xAB5Nf', + 'hidden' => '0', + 'generalfeedback' => '', + 'generalfeedbackformat' => '1', + 'timecreated' => '1235755802', + 'timemodified' => '1236193757', + 'createdby' => '25299', + 'modifiedby' => '25299', + 'unlimited' => '0', + 'options' => (object) array( + 'id' => '15211', + 'question' => '2', + 'layout' => '0', + 'answers' => array( + 7 => (object) array( + 'question' => '2', + 'answer' => 'Amphibian', + 'fraction' => '0', + 'feedback' => '', + 'id' => 7, + ), + 3 => (object) array( + 'question' => '2', + 'answer' => 'Mammal', + 'fraction' => '1', + 'feedback' => '', + 'id' => 3, + ), + 22 => (object) array( + 'question' => '2', + 'answer' => '*', + 'fraction' => '0', + 'feedback' => '', + 'id' => 22, + ), + ), + 'single' => '1', + 'shuffleanswers' => '1', + 'correctfeedback' => 'Your answer is correct. Well done.', + 'partiallycorrectfeedback' => '', + 'incorrectfeedback' => 'Your answer is incorrect. The correct answer is: Mammal.', + 'answernumbering' => 'abc', + ), + ); + + $sa2 = (object) array( + 'id' => '3', + 'category' => '1', + 'parent' => '0', + 'name' => 'animal 2', + 'questiontext' => 'Frog', + 'questiontextformat' => '1', + 'defaultmark' => '1', + 'penalty' => '0.1', + 'qtype' => 'shortanswer', + 'length' => '1', + 'stamp' => 'localhost+090227173002+mbdE0X', + 'version' => 'localhost+090304190917+xAB5Nf', + 'hidden' => '0', + 'generalfeedback' => '', + 'generalfeedbackformat' => '1', + 'timecreated' => '1235755802', + 'timemodified' => '1236193757', + 'createdby' => '25299', + 'modifiedby' => '25299', + 'unlimited' => '0', + 'options' => (object) array( + 'id' => '15214', + 'question' => '3', + 'layout' => '0', + 'answers' => array( + 5 => (object) array( + 'question' => '3', + 'answer' => 'Amphibian', + 'fraction' => '1', + 'feedback' => '', + 'id' => 5, + ), + 11 => (object) array( + 'question' => '3', + 'answer' => 'Mammal', + 'fraction' => '0', + 'feedback' => '', + 'id' => 11, + ), + 27 => (object) array( + 'question' => '3', + 'answer' => '*', + 'fraction' => '0', + 'feedback' => '', + 'id' => 27, + ), + ), + 'single' => '1', + 'shuffleanswers' => '1', + 'correctfeedback' => 'Your answer is correct. Well done.', + 'partiallycorrectfeedback' => '', + 'incorrectfeedback' => 'Your answer is incorrect. The correct answer is: Mammal.', + 'answernumbering' => 'abc', + ), + ); + + $sa3 = (object) array( + 'id' => '6', + 'category' => '1', + 'parent' => '0', + 'name' => 'animal 3', + 'questiontext' => 'Toad', + 'questiontextformat' => '1', + 'defaultmark' => '1', + 'penalty' => '0.1', + 'qtype' => 'shortanswer', + 'length' => '1', + 'stamp' => 'localhost+090227173002+mbdE0X', + 'version' => 'localhost+090304190917+xAB5Nf', + 'hidden' => '0', + 'generalfeedback' => '', + 'generalfeedbackformat' => '1', + 'timecreated' => '1235755802', + 'timemodified' => '1236193757', + 'createdby' => '25299', + 'modifiedby' => '25299', + 'unlimited' => '0', + 'options' => (object) array( + 'id' => '4578', + 'question' => '6', + 'layout' => '0', + 'answers' => array( + 9 => (object) array( + 'question' => '6', + 'answer' => 'Amphibian', + 'fraction' => '1', + 'feedback' => '', + 'id' => 9, + ), + 18 => (object) array( + 'question' => '6', + 'answer' => 'Mammal', + 'fraction' => '0', + 'feedback' => '', + 'id' => 18, + ), + 32 => (object) array( + 'question' => '6', + 'answer' => '*', + 'fraction' => '0', + 'feedback' => '', + 'id' => 32, + ), + ), + 'single' => '1', + 'shuffleanswers' => '1', + 'correctfeedback' => 'Your answer is correct. Well done.', + 'partiallycorrectfeedback' => '', + 'incorrectfeedback' => 'Your answer is incorrect. The correct answer is: Mammal.', + 'answernumbering' => 'abc', + ), + ); + + $this->loader->put_question_in_cache($sa2); + $this->loader->put_question_in_cache($sa1); + $this->loader->put_question_in_cache($sa3); + $qa = $this->updater->convert_question_attempt($quiz, $attempt, $question, $qsession, $qstates); + + $expectedqa = (object) array( + 'behaviour' => 'deferredfeedback', + 'questionid' => 5, + 'variant' => 1, + 'maxmark' => 1.0000000, + 'minfraction' => 0, + 'maxfraction' => 1, + 'flagged' => 0, + 'questionsummary' => 'For each of the following questions, select the matching answer from the menu.{Dog;Frog;Toad}->{Mammal;Amphibian}', + 'rightanswer' => 'Dog -> Mammal; Frog -> Amphibian; Toad -> Amphibian', + 'responsesummary' => 'Dog->Mammal;Frog->Amphibian;Toad->Mammal', + 'timemodified' => 1368446783, + 'steps' => array( + 0 => (object) array( + 'sequencenumber' => 0, + 'state' => 'todo', + 'fraction' => null, + 'timecreated' => 1368446755, + 'userid' => 3, + 'data' => array( + '_choice_3' => 'Mammal', + '_stem_2' => 'Dog', + '_stemformat_2' => '1', + '_right_2' => 3, + '_choice_5' => 'Amphibian', + '_stem_3' => 'Frog', + '_stemformat_3' => '1', + '_right_3' => 5, + '_stem_6' => 'Toad', + '_stemformat_6' => '1', + '_right_6' => 5, + '_stemorder' => '2,3,6', + '_choiceorder' => '3,5', + ), + ), + 1 => (object) array( + 'sequencenumber' => 1, + 'state' => 'complete', + 'fraction' => null, + 'timecreated' => 1368446783, + 'userid' => 3, + 'data' => array( + 'sub0' => 1, + 'sub1' => 2, + 'sub2' => 1, + ), + ), + 2 => (object) array( + 'sequencenumber' => 2, + 'state' => 'gradedpartial', + 'fraction' => 0.6666667, + 'timecreated' => 1368446783, + 'userid' => 3, + 'data' => array( + 'sub0' => 1, + 'sub1' => 2, + 'sub2' => 1, + '-finish' => 1, + + ), + ), + ), + ); + + $this->compare_qas($expectedqa, $qa); + } +} \ No newline at end of file diff --git a/question/type/randomsamatch/tests/walkthrough_test.php b/question/type/randomsamatch/tests/walkthrough_test.php new file mode 100644 index 00000000000..07c26439c4c --- /dev/null +++ b/question/type/randomsamatch/tests/walkthrough_test.php @@ -0,0 +1,435 @@ +. + +/** + * This file contains tests that walks a question through the interactive + * behaviour. + * + * @package qtype_randomsamatch + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); + + +/** + * Unit tests for the randomsamatch question type. + * + * @copyright 2013 Jean-Michel Vedrine + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_randomsamatch_walkthrough_test extends qbehaviour_walkthrough_test_base { + + public function test_deferred_feedback_unanswered() { + + // Create a randomsamatch question. + $m = test_question_maker::make_question('randomsamatch'); + $this->start_attempt_at_question($m, 'deferredfeedback', 4); + + $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->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_does_not_contain_feedback_expectation()); + $this->check_step_count(1); + + // Save a blank response. + $this->process_submission(array('sub0' => '0', 'sub1' => '0', + 'sub2' => '0', 'sub3' => '0')); + + // Verify. + $this->check_current_state(question_state::$todo); + $this->check_current_mark(null); + $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_does_not_contain_feedback_expectation()); + $this->check_step_count(1); + + // 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_select_expectation('sub0', $choices, null, false), + $this->get_contains_select_expectation('sub1', $choices, null, false), + $this->get_contains_select_expectation('sub2', $choices, null, false), + $this->get_contains_select_expectation('sub3', $choices, null, false)); + } + + public function test_deferred_feedback_partial_answer() { + + // Create a randomsamatching question. + $m = test_question_maker::make_question('randomsamatch'); + $m->shufflestems = false; + $this->start_attempt_at_question($m, 'deferredfeedback', 4); + + $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->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_does_not_contain_feedback_expectation()); + + // Save a partial response. + $this->process_submission(array('sub0' => $orderforchoice[13], + 'sub1' => $orderforchoice[16], 'sub2' => '0', 'sub3' => '0')); + + // Verify. + $this->check_current_state(question_state::$invalid); + $this->check_current_mark(null); + $this->check_current_output( + $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], true), + $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], 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_does_not_contain_feedback_expectation()); + + // Finish the attempt. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedpartial); + $this->check_current_mark(2); + $this->check_current_output( + $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false), + $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false), + $this->get_contains_select_expectation('sub2', $choices, null, false), + $this->get_contains_select_expectation('sub3', $choices, null, false), + $this->get_contains_partcorrect_expectation()); + } + + public function test_interactive_correct_no_submit() { + + // Create a randomsamatching question. + $m = test_question_maker::make_question('randomsamatch'); + $m->hints = array( + new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false), + new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), + ); + $m->shufflestems = false; + $this->start_attempt_at_question($m, 'interactive', 4); + + $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->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_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 right answer. + $this->process_submission(array('sub0' => $orderforchoice[13], + 'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16], + 'sub3' => $orderforchoice[13])); + + // Finish the attempt without clicking check. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(4); + $this->check_current_output( + $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false), + $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false), + $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], false), + $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], false), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_correct_expectation(), + $this->get_no_hint_visible_expectation()); + } + + public function test_interactive_partial_no_submit() { + + // Create a randomsamatching question. + $m = test_question_maker::make_question('randomsamatch'); + $m->hints = array( + new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false), + new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), + ); + $m->shufflestems = false; + $this->start_attempt_at_question($m, 'interactive', 4); + + $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->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_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 right answer. + $this->process_submission(array('sub0' => $orderforchoice[13], + 'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[13], + 'sub3' => '0')); + + // Finish the attempt without clicking check. + $this->quba->finish_all_questions(); + + // Verify. + $this->check_current_state(question_state::$gradedpartial); + $this->check_current_mark(2); + $this->check_current_output( + $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false), + $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false), + $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[13], false), + $this->get_contains_select_expectation('sub3', $choices, null, false), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_partcorrect_expectation(), + $this->get_no_hint_visible_expectation()); + } + + public function test_interactive_with_invalid() { + + // Create a randomsamatching question. + $m = test_question_maker::make_question('randomsamatch'); + $m->hints = array( + new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, false), + new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), + ); + $m->shufflestems = false; + $this->start_attempt_at_question($m, 'interactive', 4); + + $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->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_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(3), + $this->get_no_hint_visible_expectation()); + + // Try to submit an invalid answer. + $this->process_submission(array('sub0' => '0', + 'sub1' => '0', 'sub2' => '0', + 'sub3' => '0', '-submit' => '1')); + + // Verify. + $this->check_current_state(question_state::$invalid); + $this->check_current_mark(null); + $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_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_invalid_answer_expectation(), + $this->get_no_hint_visible_expectation()); + + // Now submit the right answer. + $this->process_submission(array('sub0' => $orderforchoice[13], + 'sub1' => $orderforchoice[16], 'sub2' => $orderforchoice[16], + 'sub3' => $orderforchoice[13], '-submit' => '1')); + + // Verify. + $this->check_current_state(question_state::$gradedright); + $this->check_current_mark(4); + $this->check_current_output( + $this->get_contains_select_expectation('sub0', $choices, $orderforchoice[13], false), + $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[16], false), + $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], false), + $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], false), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_correct_expectation(), + $this->get_no_hint_visible_expectation()); + } + + public function test_randomsamatch_clear_wrong() { + + // Create a randomsamatching question. + $m = test_question_maker::make_question('randomsamatch'); + $m->hints = array( + new question_hint_with_parts(11, 'This is the first hint.', FORMAT_HTML, false, true), + new question_hint_with_parts(12, 'This is the second hint.', FORMAT_HTML, true, true), + ); + $m->shufflestems = false; + $this->start_attempt_at_question($m, 'interactive', 4); + + $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->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_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(3), + $this->get_no_hint_visible_expectation()); + + // Submit a completely wrong response. + $this->process_submission(array('sub0' => $orderforchoice[16], + 'sub1' => $orderforchoice[13], 'sub2' => $orderforchoice[13], + 'sub3' => $orderforchoice[16], '-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[16], false), + $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[13], false), + $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[13], false), + $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[16], false), + $this->get_contains_hidden_expectation( + $this->quba->get_field_prefix($this->slot) . 'sub0', '0'), + $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', '0'), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_hint_expectation('This is the first hint.')); + + // Try again. + $this->process_submission(array('sub0' => 0, + 'sub1' => 0, 'sub2' => 0, + 'sub3' => 0, '-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, 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_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(2), + $this->get_no_hint_visible_expectation()); + + // Submit a partially wrong response. + $this->process_submission(array('sub0' => $orderforchoice[16], + 'sub1' => $orderforchoice[13], 'sub2' => $orderforchoice[16], + 'sub3' => $orderforchoice[13], '-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[16], false), + $this->get_contains_select_expectation('sub1', $choices, $orderforchoice[13], false), + $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], false), + $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], false), + $this->get_contains_hidden_expectation( + $this->quba->get_field_prefix($this->slot) . 'sub0', '0'), + $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', $orderforchoice[16]), + $this->get_contains_hidden_expectation( + $this->quba->get_field_prefix($this->slot) . 'sub3', $orderforchoice[13]), + $this->get_contains_submit_button_expectation(false), + $this->get_contains_hint_expectation('This is the second hint.')); + + // Try again. + $this->process_submission(array('sub0' => 0, + 'sub1' => 0, 'sub2' => $orderforchoice[16], + 'sub3' => $orderforchoice[13], '-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, null, true), + $this->get_contains_select_expectation('sub1', $choices, null, true), + $this->get_contains_select_expectation('sub2', $choices, $orderforchoice[16], true), + $this->get_contains_select_expectation('sub3', $choices, $orderforchoice[13], true), + $this->get_contains_submit_button_expectation(true), + $this->get_does_not_contain_feedback_expectation(), + $this->get_tries_remaining_expectation(1), + $this->get_no_hint_visible_expectation()); + } +} diff --git a/question/type/randomsamatch/version.php b/question/type/randomsamatch/version.php index 32b87269324..232acb1b8e1 100644 --- a/question/type/randomsamatch/version.php +++ b/question/type/randomsamatch/version.php @@ -17,14 +17,21 @@ /** * Version information for the randomsamatch question type. * - * @package qtype - * @subpackage randomsamatch + * @package qtype_randomsamatch * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2013110500; +$plugin->version = 2013110510; $plugin->requires = 2013110500; + $plugin->component = 'qtype_randomsamatch'; + +$plugin->dependencies = array( + 'qtype_match' => 2013110500, + 'qtype_shortanswer' => 2013110500, +); + +$plugin->maturity = MATURITY_STABLE;