diff --git a/.github/workflows/moodle-ci.yml b/.github/workflows/moodle-ci.yml index 962adeeffa8..9f6d9521235 100644 --- a/.github/workflows/moodle-ci.yml +++ b/.github/workflows/moodle-ci.yml @@ -8,7 +8,7 @@ jobs: services: postgres: - image: postgres:15 + image: postgres:16 env: POSTGRES_USER: 'postgres' POSTGRES_HOST_AUTH_METHOD: 'trust' @@ -126,6 +126,7 @@ jobs: moodle-plugin-ci add-plugin maths/moodle-qbehaviour_dfexplicitvaildate moodle-plugin-ci add-plugin maths/moodle-qbehaviour_dfcbmexplicitvaildate moodle-plugin-ci add-plugin maths/moodle-qbehaviour_adaptivemultipart + moodle-plugin-ci add-plugin maths/moodle-qbank_importasversion moodle-plugin-ci install --plugin ./plugin --db-host=127.0.0.1 @@ -158,6 +159,7 @@ jobs: moodle-plugin-ci add-plugin maths/moodle-qbehaviour_dfexplicitvaildate moodle-plugin-ci add-plugin maths/moodle-qbehaviour_dfcbmexplicitvaildate moodle-plugin-ci add-plugin maths/moodle-qbehaviour_adaptivemultipart + moodle-plugin-ci add-plugin maths/moodle-qbank_importasversion moodle-plugin-ci install --plugin ./plugin --db-host=127.0.0.1 diff --git a/api/controller/DiffController.php b/api/controller/DiffController.php index 0152f186604..e7467744e4d 100644 --- a/api/controller/DiffController.php +++ b/api/controller/DiffController.php @@ -24,10 +24,10 @@ namespace api\controller; defined('MOODLE_INTERNAL') || die(); -require_once(__DIR__ . '/../dtos/StackRenderResponse.php'); +require_once(__DIR__ . '/../dtos/StackDiffResponse.php'); require_once(__DIR__ . '/../util/StackQuestionLoader.php'); -use api\dtos\StackRenderResponse; +use api\dtos\StackDiffResponse; use api\util\StackQuestionLoader; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -39,11 +39,11 @@ public function __invoke(Request $request, Response $response, array $args): Res // TO-DO: Validate. $data = $request->getParsedBody(); - $renderresponse = new StackRenderResponse(); + $diffresponse = new StackDiffResponse(); $diff = StackQuestionLoader::detect_differences($data["questionDefinition"]); - $renderresponse->diff = $diff; + $diffresponse->diff = $diff; - $response->getBody()->write(json_encode($renderresponse)); + $response->getBody()->write(json_encode($diffresponse)); return $response->withHeader('Content-Type', 'application/json'); } } diff --git a/api/dtos/DiffResponse.php b/api/dtos/StackDiffResponse.php similarity index 100% rename from api/dtos/DiffResponse.php rename to api/dtos/StackDiffResponse.php diff --git a/api/questiondefaults.yml b/api/questiondefaults.yml index e02e4d6a643..deda08420ea 100644 --- a/api/questiondefaults.yml +++ b/api/questiondefaults.yml @@ -9,7 +9,7 @@ question: penalty: '0.1' hidden: '0' idnumber: '' - stackversion: '2025042500' + stackversion: '2025102200' questionvariables: 'ta1:1;' specificfeedback: '

[[feedback:prt1]]

' specificfeedbackformat: html diff --git a/api/util/StackQuestionLoader.php b/api/util/StackQuestionLoader.php index 206c08320a6..f20491cc2e9 100644 --- a/api/util/StackQuestionLoader.php +++ b/api/util/StackQuestionLoader.php @@ -121,7 +121,7 @@ public static function loadxml($xml, $includetests = false) { // Use (array) because isset($xmldata->question->defaultgrade) returns true if the element is empty and // empty() returns true if element is 0. Casting to array returns [] and [0] which return false and true respectively. $question->defaultmark = (array) $xmldata->question->defaultgrade ? (float) $xmldata->question->defaultgrade : - self::get_default('question', 'defaultgrade', 1.0); + self::get_default('question', 'defaultgrade', 1); $question->penalty = (array) $xmldata->question->penalty ? (float) $xmldata->question->penalty : self::get_default('question', 'penalty', 0.1); @@ -164,7 +164,7 @@ public static function loadxml($xml, $includetests = false) { self::get_default( 'question', 'prtcorrect', - get_string('defaultprtcorrectfeedback', 'qtype_stack', null) + get_config('qtype_stack', 'prtcorrect') ); $question->prtcorrectformat = self::get_default('question', 'prtcorrectformat', 'html'); } @@ -179,7 +179,7 @@ public static function loadxml($xml, $includetests = false) { self::get_default( 'question', 'prtpartiallycorrect', - get_string('defaultprtpartiallycorrectfeedback', 'qtype_stack', null) + get_config('qtype_stack', 'prtpartiallycorrect') ); $question->prtpartiallycorrectformat = self::get_default('question', 'prtpartiallycorrectformat', 'html'); @@ -195,7 +195,7 @@ public static function loadxml($xml, $includetests = false) { self::get_default( 'question', 'prtincorrect', - get_string('defaultprtincorrectfeedback', 'qtype_stack', null) + get_config('qtype_stack', 'prtincorrect') ); $question->prtincorrectformat = self::get_default('question', 'prtincorrectformat', 'html'); @@ -633,7 +633,7 @@ public static function yaml_to_xml($yamlstring) { // Name is a special case. Has text tag but no format. $name = (string) $xml->question->name ? (string) $xml->question->name : self::get_default('question', 'name', 'Default'); $xml->question->name = new SimpleXMLElement(''); - $xml->question->name->addChild('text', $name); + $xml->question->name->text = $name; return $xml; } diff --git a/doc/en/Developer/Development_history.md b/doc/en/Developer/Development_history.md index 5bac7756f12..b34c86531d3 100644 --- a/doc/en/Developer/Development_history.md +++ b/doc/en/Developer/Development_history.md @@ -2,6 +2,12 @@ For current and future plans, see [Development track](Development_track.md) and [Future plans](Future_plans.md). +## Version 4.12.0 + +- Validation of XML imports. XML files that fail validation will be marked as broken but still imported where possible. +- Editing of question XML within STACK from link in STACK dashboard. STACK now requires the importasversion plugin to make this possible. +- Much code tidying to comply with updated to Moodle Code Checker. + ## Version 4.11.0 Released October 2025. diff --git a/doc/en/Installation/index.md b/doc/en/Installation/index.md index 8dfd3c0db9c..55dbc3bf597 100644 --- a/doc/en/Installation/index.md +++ b/doc/en/Installation/index.md @@ -62,7 +62,7 @@ Please note Instructions for installing a more recent version of Maxima on CentOS 6 are available on the [Moodle forum](https://moodle.org/mod/forum/discuss.php?d=270956) (Oct 2014). -## 3. Add some additional question behaviours +## 3. Add some additional question behaviours and importasversion STACK requires these. @@ -77,12 +77,17 @@ Alternatively, get the code using git by running the following command in the to Alternatively, get the code using git by running the following command in the top level folder of your Moodle install: git clone https://github.com/maths/moodle-qbehaviour_dfcbmexplicitvaildate.git question/behaviour/dfcbmexplicitvaildate -2. Obtain adaptivemutlipart behaviour code. You can [download the zip file](https://github.com/maths/moodle-qbehaviour_adaptivemultipart/zipball/master), unzip it, and place it in the directory `moodle/question/behaviour/adaptivemultipart`. (You will need to rename the directory `moodle-qbehaviour_adaptivemultipart -> adaptivemultipart`.) +3. Obtain adaptivemutlipart behaviour code. You can [download the zip file](https://github.com/maths/moodle-qbehaviour_adaptivemultipart/zipball/master), unzip it, and place it in the directory `moodle/question/behaviour/adaptivemultipart`. (You will need to rename the directory `moodle-qbehaviour_adaptivemultipart -> adaptivemultipart`.) Alternatively, get the code using git by running the following command in the top level folder of your Moodle install: git clone https://github.com/maths/moodle-qbehaviour_adaptivemultipart.git question/behaviour/adaptivemultipart -3. Login to Moodle as the admin user and click on Notifications in the Site Administration panel. +4. Obtain importasversion code. You can [download the zip file](https://github.com/maths/moodle-qbank_importasversion/archive/refs/heads/main.zip), unzip it, and place it in the directory `moodle/question/bank/importasversion`. (You will need to rename the directory `moodle-qbehaviour_importasversion -> importasversion`.) + +Alternatively, get the code using git by running the following command in the top level folder of your Moodle install: + + git clone https://github.com/maths/moodle-qbank_importasversion.git question/bank/importasversion +5. Login to Moodle as the admin user and click on Notifications in the Site Administration panel. ## 4. Add the STACK question type @@ -164,6 +169,12 @@ If STACK is already installed, as described above, it can be updated via git, li cd .. cd adaptivemultipart/ git pull + cd .. + cd .. + cd bank/importasversion + git pull + +If upgrading from an older version of STACK, you may need to install the importasversion plugin as instructed in installation step 3. 2. Then login as admin in your moodle and update the database. diff --git a/lang/en/qtype_stack.php b/lang/en/qtype_stack.php index 87bf1a58ccc..94302d1d0fe 100644 --- a/lang/en/qtype_stack.php +++ b/lang/en/qtype_stack.php @@ -471,7 +471,7 @@ $string['settingcaspreparse_false'] = 'Do not preparse (not recommended)'; $string['settingdefaultinputoptions'] = 'Default input options'; $string['settingdefaultinputoptions_desc'] = 'Used when creating a new question, or adding a new input to an existing question.'; -$string['settingdefaultquestionoptions'] = 'Default input options'; +$string['settingdefaultquestionoptions'] = 'Default question options'; $string['settingdefaultquestionoptions_desc'] = 'Used when creating a new question.'; $string['settingmathsdisplay'] = 'Maths filter'; $string['settingmathsdisplay_mathjax'] = 'MathJax'; @@ -622,6 +622,8 @@ $string['exportthisquestion_help'] = 'This will create a Moodle XML export file containing just this one question. One example of when this is useful if you think this question demonstrates a bug in STACK that you would like to report to the developers.'; $string['tidyquestion'] = ' Tidy inputs and PRTs'; $string['sendgeneralfeedback'] = ' Send general feedback to the CAS'; +$string['editxml'] = ' Edit question XML'; +$string['reloadsavedXML'] = ' Reload saved version of question'; $string['seetodolist'] = ' Find [[todo]] blocks'; $string['seetodolist_desc'] = 'The purpose of this page is to find all questions containing [[todo]] blocks and to group them by any tags. Questions that have been marked as broken will also be found and displayed.'; $string['seetodolist_help'] = 'Clicking on the question name takes you to the dashboard. You can also preview the question.'; @@ -640,7 +642,7 @@ $string['splitsummary'] = 'Split summary'; $string['variants'] = 'Variants'; -$string['importwillfail'] = 'Import will fail.'; +$string['importwillfail'] = 'This question cannot be saved or imported in its current state.'; $string['noroots'] = 'The graph of this PRT has no roots. Does it have nodes?'; $string['structuralproblem'] = 'The PRT structure is malformed.'; $string['missingnextnode'] = 'The PRT structure is malformed. {$a->type} next node for PRT {$a->prt} node {$a->node} is invalid. It has been set to stop.'; @@ -666,6 +668,11 @@ $string['clearedthecache'] = 'CAS cached has been cleared.'; $string['clearingcachefiles'] = 'Clearing cached STACK plot files {$a->done}/{$a->total}'; $string['clearthecache'] = 'Clear the cache'; +$string['editxmlintro'] = 'You can edit the XML of the question here and then save it as a new version of the question. Validation failures will result in a a warning being displayed and the saved question will be marked as broken (i.e. the <isbroken> tag will be set to 1). Serious issues with node layout may prevent the question from being saved. Missing question parts or invalid values are likely to cause an error. In most cases, you should see an error message and receive a warning that the question has not been saved. You can then edit your changes and try again. With great power comes great responsibility, however. If you are not careful with your XML, you will encounter issues and obscure error messages that you are protected from when using the normal question edit form. Please save frequently and/ or edit your question in another application to avoid losing work.'; +$string['editxmltitle'] = 'Edit question XML'; +$string['editxmlquestion'] = 'Question XML'; +$string['editxmlbutton'] = 'Save as new version and continue editing'; +$string['xmldisplayerror'] = ' There was a problem displaying the XML.'; $string['healthcheck'] = 'STACK healthcheck'; $string['healthcheck_desc'] = 'The healthcheck script helps you verify that all aspects of STACK are working properly.'; $string['healthcheckcache_db'] = 'CAS results are being cached in the database.'; diff --git a/questiontestrun.php b/questiontestrun.php index cbf2b7bfd87..70f9870ae8f 100644 --- a/questiontestrun.php +++ b/questiontestrun.php @@ -91,6 +91,7 @@ $exportparams['id'] = $question->id; $questionbanklinkedit = new moodle_url('/question/type/stack/questioneditlatest.php', $editparams); +$questionxmllink = new moodle_url('/question/type/stack/questionxmledit.php', $editparams); $questionbanklink = new moodle_url('/question/edit.php', $qbankparams); $exportquestionlink = new moodle_url('/question/bank/exporttoxml/exportone.php', $exportparams); $exportquestionlink->param('sesskey', sesskey()); @@ -156,6 +157,11 @@ stack_string('seetodolist'), ['class' => 'nav-link'] ); +$links[] = html_writer::link( + $questionxmllink, + stack_string('editxml'), + ['class' => 'nav-link'] +); echo html_writer::tag('nav', implode(' ', $links), ['class' => 'nav']); flush(); diff --git a/questiontype.php b/questiontype.php index 7aeb2c6bcd5..2c004f1c808 100644 --- a/questiontype.php +++ b/questiontype.php @@ -126,7 +126,20 @@ public function save_question_options($fromform) { global $DB, $PAGE, $OUTPUT; $throwexceptions = true; switch ($PAGE->pagetype) { + case 'webservice-rest-server': + if (!$_REQUEST['wsfunction'] === 'qbank_gitsync_import_question') { + $result = null; + break; + } + $throwexceptions = false; + $result = new \StdClass(); + if (!empty($fromform->validationerrors)) { + $dashboardlink = new moodle_url('/question/type/stack/questiontestrun.php', ['questionid' => $fromform->id]); + $result->notice = $fromform->name . ': ' . $fromform->validationerrors . ' - ' . $dashboardlink; + } + break; case 'question-bank-importquestions-import': + case 'question-type-stack-questionxmledit': $throwexceptions = false; $result = new \StdClass(); if (!empty($fromform->validationerrors)) { @@ -136,16 +149,26 @@ public function save_question_options($fromform) { $dashboardlink, $fromform->name ) . '
' . $result->notice; - // If we send the notice back to Moodle the question import process will stop - // without importing any later questions in the file. - echo $OUTPUT->notification($result->notice); - unset($result->notice); + if ($PAGE->pagetype === 'question-bank-importquestions-import') { + // If we send the notice back to Moodle the question import process will stop + // without importing any later questions in the file. + echo $OUTPUT->notification($result->notice); + unset($result->notice); + } } break; case 'question-bank-importasversion-import': - // Ideally importasversion would handle notice/errors messages. - // That would allow us to show validation messages in Gitsync - // and when importing as new. + $throwexceptions = false; + $result = new \StdClass(); + if (!empty($fromform->validationerrors)) { + $result->notice = $fromform->validationerrors; + $dashboardlink = new moodle_url('/question/type/stack/questiontestrun.php', ['questionid' => $fromform->id]); + $result->notice = html_writer::link( + $dashboardlink, + $fromform->name + ) . '
' . $result->notice; + } + break; default: // Edit page and everything else should behave as before. $result = null; @@ -1759,7 +1782,6 @@ public function export_to_xml($questiondata, qformat_xml $format, $notused = nul // phpcs:ignore moodle.Commenting.MissingDocblock.Function public function import_from_xml($xml, $fromform, qformat_xml $format, $notused = null) { - global $OUTPUT, $PAGE; if (!isset($xml['@']['type']) || $xml['@']['type'] != $this->name()) { return false; } @@ -1767,67 +1789,116 @@ public function import_from_xml($xml, $fromform, qformat_xml $format, $notused = $fromform = $format->import_headers($xml); $fromform->qtype = $this->name(); + // Set defaults for standard question parts. + $fromform->name = ($fromform->name === '') ? 'Default' : $fromform->name; + if ($fromform->questiontext === '') { + // Default only applied if field does not exist. + $fromform->questiontext = $format->getpath($xml, [ + '#', 'questiontext', + 0, '#', 'text', 0, '#', + ], '

Default question

[[input:ans1]] [[validation:ans1]]

', true); + } + $fromform->stackversion = $format->getpath($xml, ['#', 'stackversion', 0, '#', 'text', 0, '#'], '', true); $fromform->questionvariables = $format->getpath($xml, [ '#', 'questionvariables', 0, '#', 'text', 0, '#', - ], '', true); - $fformat = $fromform->questiontextformat; + ], 'ta1:1;', true); + $fformat = FORMAT_HTML; if (isset($fromform->specificfeedbackformat)) { $fformat = $fromform->specificfeedbackformat; } - $fromform->specificfeedback = $this->import_xml_text($xml, 'specificfeedback', $format, $fformat); - $fformat = $fromform->questiontextformat; + $fromform->specificfeedback = $this->import_xml_text($xml, 'specificfeedback', $format, $fformat, '[[feedback:prt1]]'); + $fformat = FORMAT_HTML; if (isset($fromform->questionnoteformat)) { $fformat = $fromform->questionnoteformat; } - $fromform->questionnote = $this->import_xml_text($xml, 'questionnote', $format, $fformat); - $fformat = $fromform->questiontextformat; + $fromform->questionnote = $this->import_xml_text($xml, 'questionnote', $format, $fformat, '{@ta1@}'); + $fformat = FORMAT_HTML; if (isset($fromform->questiondescriptionformat)) { $fformat = $fromform->questiondescriptionformat; } $fromform->questiondescription = $this->import_xml_text($xml, 'questiondescription', $format, $fformat); - $fromform->questionsimplify = $format->getpath($xml, ['#', 'questionsimplify', 0, '#'], 1); - $fromform->assumepositive = $format->getpath($xml, ['#', 'assumepositive', 0, '#'], 0); - $fromform->assumereal = $format->getpath($xml, ['#', 'assumereal', 0, '#'], 0); + $fromform->questionsimplify + = $format->getpath($xml, ['#', 'questionsimplify', 0, '#'], get_config('qtype_stack', 'questionsimplify')); + $fromform->assumepositive + = $format->getpath($xml, ['#', 'assumepositive', 0, '#'], get_config('qtype_stack', 'assumepositive')); + $fromform->assumereal + = $format->getpath($xml, ['#', 'assumereal', 0, '#'], get_config('qtype_stack', 'assumereal')); $fromform->isbroken = $format->getpath($xml, ['#', 'isbroken', 0, '#'], 0); - $fformat = $fromform->questiontextformat; + $fformat = FORMAT_HTML; if (isset($fromform->prtcorrectformat)) { $fformat = $fromform->prtcorrectformat; } - $fromform->prtcorrect = $this->import_xml_text($xml, 'prtcorrect', $format, $fformat); - $fformat = $fromform->questiontextformat; + $fromform->prtcorrect + = $this->import_xml_text($xml, 'prtcorrect', $format, $fformat, get_config('qtype_stack', 'prtcorrect')); + $fformat = FORMAT_HTML; if (isset($fromform->prtpartiallycorrectformat)) { $fformat = $fromform->prtpartiallycorrectformat; } - $fromform->prtpartiallycorrect = $this->import_xml_text($xml, 'prtpartiallycorrect', $format, $fformat); - $fformat = $fromform->questiontextformat; + $fromform->prtpartiallycorrect = $this->import_xml_text( + $xml, + 'prtpartiallycorrect', + $format, + $fformat, + get_config('qtype_stack', 'prtpartiallycorrect') + ); + $fformat = FORMAT_HTML; if (isset($fromform->prtincorrectformat)) { $fformat = $fromform->prtincorrectformat; } - $fromform->prtincorrect = $this->import_xml_text($xml, 'prtincorrect', $format, $fformat); + $fromform->prtincorrect + = $this->import_xml_text($xml, 'prtincorrect', $format, $fformat, get_config('qtype_stack', 'prtincorrect')); $fromform->penalty = $format->getpath($xml, ['#', 'penalty', 0, '#'], 0.1); - $fromform->decimals = $format->getpath($xml, ['#', 'decimals', 0, '#'], '.'); - $fromform->scientificnotation = $format->getpath($xml, ['#', 'scientificnotation', 0, '#'], '*10'); - $fromform->multiplicationsign = $format->getpath($xml, ['#', 'multiplicationsign', 0, '#'], 'dot'); - $fromform->sqrtsign = $format->getpath($xml, ['#', 'sqrtsign', 0, '#'], 1); - $fromform->complexno = $format->getpath($xml, ['#', 'complexno', 0, '#'], 'i'); - $fromform->inversetrig = $format->getpath($xml, ['#', 'inversetrig', 0, '#'], 'cos-1'); - $fromform->logicsymbol = $format->getpath($xml, ['#', 'logicsymbol', 0, '#'], 'lang'); - $fromform->matrixparens = $format->getpath($xml, ['#', 'matrixparens', 0, '#'], '['); - $fromform->variantsselectionseed = $format->getpath($xml, ['#', 'variantsselectionseed', 0, '#'], 'i'); + $fromform->hidden = $format->getpath($xml, ['#', 'hidden', 0, '#'], 0); + $fromform->decimals + = $format->getpath($xml, ['#', 'decimals', 0, '#'], get_config('qtype_stack', 'decimals')); + $fromform->scientificnotation + = $format->getpath($xml, ['#', 'scientificnotation', 0, '#'], get_config('qtype_stack', 'scientificnotation')); + $fromform->multiplicationsign + = $format->getpath($xml, ['#', 'multiplicationsign', 0, '#'], get_config('qtype_stack', 'multiplicationsign')); + $fromform->sqrtsign + = $format->getpath($xml, ['#', 'sqrtsign', 0, '#'], get_config('qtype_stack', 'sqrtsign')); + $fromform->complexno + = $format->getpath($xml, ['#', 'complexno', 0, '#'], get_config('qtype_stack', 'complexno')); + $fromform->inversetrig + = $format->getpath($xml, ['#', 'inversetrig', 0, '#'], get_config('qtype_stack', 'inversetrig')); + $fromform->logicsymbol + = $format->getpath($xml, ['#', 'logicsymbol', 0, '#'], get_config('qtype_stack', 'logicsymbol')); + $fromform->matrixparens + = $format->getpath($xml, ['#', 'matrixparens', 0, '#'], get_config('qtype_stack', 'matrixparens')); + $fromform->variantsselectionseed = $format->getpath($xml, ['#', 'variantsselectionseed', 0, '#'], ''); $structurerepairs = ''; - if (isset($xml['#']['input'])) { + if (isset($xml['#']['input']) && count($xml['#']['input'])) { foreach ($xml['#']['input'] as $inputxml) { $this->import_xml_input($inputxml, $fromform, $format); } + } else { + if ($fromform->defaultmark) { + $defaultinput = []; + $defaultinput['#'] = ['name' => [0 => ['#' => 'ans1']], 'tans' => [0 => ['#' => 'ta1']]]; + $this->import_xml_input($defaultinput, $fromform, $format); + } } - if (isset($xml['#']['prt'])) { + if (isset($xml['#']['prt']) && count($xml['#']['prt'])) { foreach ($xml['#']['prt'] as $prtxml) { $structurerepairs .= $this->import_xml_prt($prtxml, $fromform, $format); } + } else { + if ($fromform->defaultmark) { + $defaultnode = [ + 'name' => [0 => ['#' => 0]], + 'sans' => [0 => ['#' => 'ans1']], + 'tans' => [0 => ['#' => 'ta1']], + 'trueanswernote' => [0 => ['#' => 'prt1-1-T']], + 'falseanswernote' => [0 => ['#' => 'prt1-1-F']], + ]; + $defaultprt = []; + $defaultprt['#'] = ['name' => [0 => ['#' => 'prt1']], 'node' => [['#' => $defaultnode]]]; + $this->import_xml_prt($defaultprt, $fromform, $format); + } } $format->import_hints( @@ -1866,7 +1937,7 @@ public function import_from_xml($xml, $fromform, qformat_xml $format, $notused = unset($errors['name']); $errortext = ''; foreach ($errors as $key => $error) { - $errortext .= $key . ': ' . $error . '
'; + $errortext .= $key . ': ' . $error . '
'; } if (isset($errors['structuralerror'])) { // Graph creation failed. If we import this question @@ -1891,12 +1962,13 @@ public function import_from_xml($xml, $fromform, qformat_xml $format, $notused = * @param array $xml the XML to extract the data from. * @param string $field the name of the sub-tag in the XML to load the data from. * @param qformat_xml $format the importer/exporter object. - * @param int $defaultformat Dfeault text format, if it is not given in the file. + * @param int $defaultformat Default text format, if it is not given in the file. + * @param string $default Default text value, if it is not given in the file. * @return array with fields text, format and files. */ - protected function import_xml_text($xml, $field, qformat_xml $format, $defaultformat) { + protected function import_xml_text($xml, $field, qformat_xml $format, $defaultformat, $default = '') { $text = []; - $text['text'] = $format->getpath($xml, ['#', $field, 0, '#', 'text', 0, '#'], '', true); + $text['text'] = $format->getpath($xml, ['#', $field, 0, '#', 'text', 0, '#'], $default, true); $text['format'] = $format->trans_format($format->getpath( $xml, ['#', $field, 0, '@', 'format'], @@ -1916,20 +1988,28 @@ protected function import_xml_text($xml, $field, qformat_xml $format, $defaultfo protected function import_xml_input($xml, $fromform, qformat_xml $format) { $name = $format->getpath($xml, ['#', 'name', 0, '#'], null, false, 'Missing input name in the XML.'); - $fromform->{$name . 'type'} = $format->getpath($xml, ['#', 'type', 0, '#'], ''); - $fromform->{$name . 'modelans'} = $format->getpath($xml, ['#', 'tans', 0, '#'], ''); - $fromform->{$name . 'boxsize'} = (int) $format->getpath($xml, ['#', 'boxsize', 0, '#'], 15); + $fromform->{$name . 'type'} = $format->getpath($xml, ['#', 'type', 0, '#'], 'algebraic'); + $fromform->{$name . 'modelans'} = $format->getpath($xml, ['#', 'tans', 0, '#'], 'ta1'); + $fromform->{$name . 'boxsize'} + = (int) $format->getpath($xml, ['#', 'boxsize', 0, '#'], get_config('qtype_stack', 'inputboxsize')); $fromform->{$name . 'strictsyntax'} = $format->getpath($xml, ['#', 'strictsyntax', 0, '#'], 1); - $fromform->{$name . 'insertstars'} = $format->getpath($xml, ['#', 'insertstars', 0, '#'], 0); + $fromform->{$name . 'insertstars'} + = $format->getpath($xml, ['#', 'insertstars', 0, '#'], get_config('qtype_stack', 'inputinsertstars')); $fromform->{$name . 'syntaxhint'} = $format->getpath($xml, ['#', 'syntaxhint', 0, '#'], ''); $fromform->{$name . 'syntaxattribute'} = $format->getpath($xml, ['#', 'syntaxattribute', 0, '#'], 0); - $fromform->{$name . 'forbidwords'} = $format->getpath($xml, ['#', 'forbidwords', 0, '#'], ''); + $fromform->{$name . 'forbidwords'} + = $format->getpath($xml, ['#', 'forbidwords', 0, '#'], get_config('qtype_stack', 'inputforbidwords')); $fromform->{$name . 'allowwords'} = $format->getpath($xml, ['#', 'allowwords', 0, '#'], ''); - $fromform->{$name . 'forbidfloat'} = $format->getpath($xml, ['#', 'forbidfloat', 0, '#'], 1); - $fromform->{$name . 'requirelowestterms'} = $format->getpath($xml, ['#', 'requirelowestterms', 0, '#'], 0); - $fromform->{$name . 'checkanswertype'} = $format->getpath($xml, ['#', 'checkanswertype', 0, '#'], 0); - $fromform->{$name . 'mustverify'} = $format->getpath($xml, ['#', 'mustverify', 0, '#'], 1); - $fromform->{$name . 'showvalidation'} = $format->getpath($xml, ['#', 'showvalidation', 0, '#'], 1); + $fromform->{$name . 'forbidfloat'} + = $format->getpath($xml, ['#', 'forbidfloat', 0, '#'], get_config('qtype_stack', 'inputforbidfloat')); + $fromform->{$name . 'requirelowestterms'} + = $format->getpath($xml, ['#', 'requirelowestterms', 0, '#'], get_config('qtype_stack', 'inputrequirelowestterms')); + $fromform->{$name . 'checkanswertype'} + = $format->getpath($xml, ['#', 'checkanswertype', 0, '#'], get_config('qtype_stack', 'inputcheckanswertype')); + $fromform->{$name . 'mustverify'} + = $format->getpath($xml, ['#', 'mustverify', 0, '#'], get_config('qtype_stack', 'inputmustverify')); + $fromform->{$name . 'showvalidation'} + = $format->getpath($xml, ['#', 'showvalidation', 0, '#'], get_config('qtype_stack', 'inputshowvalidation')); $fromform->{$name . 'options'} = $format->getpath($xml, ['#', 'options', 0, '#'], ''); } @@ -2002,12 +2082,12 @@ protected function import_xml_prt($xml, $fromform, qformat_xml $format) { * @param qformat_xml $format the importer/exporter object. */ protected function import_xml_prt_node($xml, $prtname, $fromform, qformat_xml $format) { - $name = $format->getpath($xml, ['#', 'name', 0, '#'], null, false, 'Missing PRT name in the XML.'); + $name = $format->getpath($xml, ['#', 'name', 0, '#'], null, false, 'Missing PRT node name in the XML.'); $fromform->{$prtname . 'description'}[$name] = $format->getpath($xml, ['#', 'description', 0, '#'], ''); - $fromform->{$prtname . 'answertest'}[$name] = $format->getpath($xml, ['#', 'answertest', 0, '#'], ''); - $fromform->{$prtname . 'sans'}[$name] = $format->getpath($xml, ['#', 'sans', 0, '#'], ''); - $fromform->{$prtname . 'tans'}[$name] = $format->getpath($xml, ['#', 'tans', 0, '#'], ''); + $fromform->{$prtname . 'answertest'}[$name] = $format->getpath($xml, ['#', 'answertest', 0, '#'], 'AlgEquiv'); + $fromform->{$prtname . 'sans'}[$name] = $format->getpath($xml, ['#', 'sans', 0, '#'], 'ans1'); + $fromform->{$prtname . 'tans'}[$name] = $format->getpath($xml, ['#', 'tans', 0, '#'], 'ta1'); $fromform->{$prtname . 'testoptions'}[$name] = $format->getpath($xml, ['#', 'testoptions', 0, '#'], ''); $fromform->{$prtname . 'quiet'}[$name] = $format->getpath($xml, ['#', 'quiet', 0, '#'], 0); $fromform->{$prtname . 'truescoremode'}[$name] = $format->getpath($xml, ['#', 'truescoremode', 0, '#'], '='); @@ -2017,17 +2097,16 @@ protected function import_xml_prt_node($xml, $prtname, $fromform, qformat_xml $f $fromform->{$prtname . 'trueanswernote'}[$name] = $format->getpath( $xml, ['#', 'trueanswernote', 0, '#'], - 1, '' ); $fromform->{$prtname . 'truefeedback'}[$name] = $this->import_xml_text( $xml, 'truefeedback', $format, - $fromform->questiontextformat + FORMAT_HTML ); $fromform->{$prtname . 'falsescoremode'}[$name] = $format->getpath($xml, ['#', 'falsescoremode', 0, '#'], '='); - $fromform->{$prtname . 'falsescore'}[$name] = $format->getpath($xml, ['#', 'falsescore', 0, '#'], 1); + $fromform->{$prtname . 'falsescore'}[$name] = $format->getpath($xml, ['#', 'falsescore', 0, '#'], 0); $fromform->{$prtname . 'falsepenalty'}[$name] = $format->getpath($xml, ['#', 'falsepenalty', 0, '#'], ''); $fromform->{$prtname . 'falsenextnode'}[$name] = $format->getpath($xml, ['#', 'falsenextnode', 0, '#'], -1); $fromform->{$prtname . 'falseanswernote'}[$name] = $format->getpath($xml, ['#', 'falseanswernote', 0, '#'], ''); @@ -2035,7 +2114,7 @@ protected function import_xml_prt_node($xml, $prtname, $fromform, qformat_xml $f $xml, 'falsefeedback', $format, - $fromform->questiontextformat + FORMAT_HTML ); } @@ -2055,8 +2134,8 @@ protected function import_xml_qtest($xml, qformat_xml $format) { } if (isset($xml['#']['testinput'])) { foreach ($xml['#']['testinput'] as $inputxml) { - $name = $format->getpath($inputxml, ['#', 'name', 0, '#'], ''); - $value = $format->getpath($inputxml, ['#', 'value', 0, '#'], ''); + $name = $format->getpath($inputxml, ['#', 'name', 0, '#'], 'ans1'); + $value = $format->getpath($inputxml, ['#', 'value', 0, '#'], 'ta1'); $inputs[$name] = $value; } } @@ -2065,10 +2144,10 @@ protected function import_xml_qtest($xml, qformat_xml $format) { if (isset($xml['#']['expected'])) { foreach ($xml['#']['expected'] as $expectedxml) { - $name = $format->getpath($expectedxml, ['#', 'name', 0, '#'], ''); + $name = $format->getpath($expectedxml, ['#', 'name', 0, '#'], 'prt1'); $expectedscore = $format->getpath($expectedxml, ['#', 'expectedscore', 0, '#'], ''); $expectedpenalty = $format->getpath($expectedxml, ['#', 'expectedpenalty', 0, '#'], ''); - $expectedanswernote = $format->getpath($expectedxml, ['#', 'expectedanswernote', 0, '#'], ''); + $expectedanswernote = $format->getpath($expectedxml, ['#', 'expectedanswernote', 0, '#'], '1-0-T'); $testcase->add_expected_result($name, new stack_potentialresponse_tree_state( 1, diff --git a/questionxmledit.php b/questionxmledit.php new file mode 100644 index 00000000000..03557c7d7d3 --- /dev/null +++ b/questionxmledit.php @@ -0,0 +1,162 @@ +. + +/** + * This script lets the user edit the question XML directly and attempt + * to import the XML as a new version. + * + * @package qtype_stack + * @copyright 2025 Universiy of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../../config.php'); +require_once(__DIR__ . '/locallib.php'); +require_once($CFG->libdir . '/questionlib.php'); +require_once($CFG->dirroot . '/question/format/xml/format.php'); +require_once(__DIR__ . '/questionxmlform.php'); +use core_question\local\bank\question_edit_contexts; +use qformat_xml; + +require_login(); + +// Get the parameters from the URL. +$questionid = required_param('id', PARAM_INT); + +[$qversion, $questionid] = get_latest_question_version($questionid); +$questiondata = question_bank::load_question_data($questionid); +if (!$questiondata) { + throw new stack_exception('questiondoesnotexist'); +} +$question = question_bank::load_question($questionid); +// Process any other URL parameters, and do require_login. +[$context, $seed, $urlparams] = qtype_stack_setup_question_test_page($question); + +// Check permissions. +question_require_capability_on($questiondata, 'edit'); +$editparams = $urlparams; +unset($editparams['questionid']); +unset($editparams['seed']); +$editparams['id'] = $question->id; +$questionediturl = new moodle_url('/question/bank/editquestion/question.php', $editparams); +$questioneditlatesturl = new moodle_url('/question/type/stack/questioneditlatest.php', $editparams); + +$PAGE->set_url('/question/type/stack/questionxmledit.php', $editparams); +$title = stack_string('editxmltitle'); +$PAGE->set_title($title); +$mform = new qtype_stack_question_xml_form( + $PAGE->url, + ['submitlabel' => stack_string('editxmlbutton'), 'xmlstring' => '', 'numberrows' => 5] +); +$qformat = new qformat_xml(); +$contexts = new question_edit_contexts($context); +$qformat->setCattofile(false); +$qformat->setContexttofile(false); +$qformat->setContextfromfile(false); +$qformat->setStoponerror(true); +$qformat->setCourse($COURSE); + +$errors = ''; +$notices = ''; +$warnings = ''; +$xmlstring = ''; + +if ($mform->is_cancelled()) { + unset($urlparams['testcase']); + $qtype = new qtype_stack(); + redirect($qtype->get_question_test_url($question)); +} else if ($fromform = $mform->get_data()) { + $importfile = make_request_directory() . "/importq.xml"; + file_put_contents($importfile, $fromform->questionxml); + try { + $result = \qbank_importasversion\importer::import_file($qformat, $question, $importfile); + $errors = $result->error ?? ''; + $notices = $result->notice ?? ''; + } catch (Exception $e) { + $errors = $e->getMessage(); + } + // The import process spits out the question description somewhere. Clean output to remove. + ob_clean(); + // Refresh data with newly saved question. + [$qversion, $questionid] = get_latest_question_version($questionid); + $question = question_bank::load_question($questionid); + $warnings = implode(' ', $question->validate_warnings(true)); + $questiondata = question_bank::load_question_data($questionid); +} + +if (!empty($errors)) { + // We've tried to save the question but failed. Show POSTed XML. + $xmlstring = $fromform->questionxml; +} else { + $qformat->setQuestions([$questiondata]); + if (!$qformat->exportpreprocess()) { + $notices .= stack_string('xmldisplayerror'); + } else { + if (!$xmlstring = $qformat->exportprocess(true)) { + $xmlstring = ''; + $notices .= stack_string('xmldisplayerror'); + } + } +} + +echo $OUTPUT->header(); +$links = []; +$qtype = new qtype_stack(); +$qtestlink = $qtype->get_question_test_url($question); +$links[] = html_writer::link($qtestlink, ' ' + . stack_string('runquestiontests'), ['class' => 'nav-link']); +$qpreviewlink = qbank_previewquestion\helper::question_preview_url($questionid, null, null, null, null, $context); +$links[] = html_writer::link( + $qpreviewlink, + ' ' . stack_string('questionpreview'), + ['class' => 'nav-link'] +); +$links[] = html_writer::link( + $questioneditlatesturl, + stack_string('editquestioninthequestionbank'), + ['class' => 'nav-link'] +); +$links[] = html_writer::link( + $PAGE->url, + stack_string('reloadsavedXML'), + ['class' => 'nav-link'] +); +echo html_writer::tag('nav', implode(' ', $links), ['class' => 'nav']); + +echo $OUTPUT->heading($title); +echo $OUTPUT->heading($question->name, 3); +echo html_writer::tag('p', stack_string('version') . ' ' . $qversion); + +$fout = ''; +if ($errors) { + $errors .= ' ' . stack_string('notsaved'); + $fout .= html_writer::tag('div', $errors, ['class' => 'alert alert-danger']); +} else if ($notices) { + $fout .= html_writer::tag('div', $notices, ['class' => 'alert alert-warning']); + $fout .= html_writer::tag('p', $warnings); +} +echo html_writer::tag('div', $fout); +$xmlstringlen = max(substr_count($xmlstring, "\n") + 3, 8); +// Redo form with the correct textarea size and display. +$mform = new qtype_stack_question_xml_form( + $PAGE->url, + ['submitlabel' => stack_string('editxmlbutton'), 'xmlstring' => '', 'numberrows' => $xmlstringlen] +); +$mform->setConstants(['questionxml' => $xmlstring]); +$mform->display(); + +echo html_writer::tag('p', stack_string('editxmlintro')); +echo $OUTPUT->footer(); diff --git a/questionxmlform.php b/questionxmlform.php new file mode 100644 index 00000000000..b52016537c3 --- /dev/null +++ b/questionxmlform.php @@ -0,0 +1,54 @@ +. + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/formslib.php'); + +/** + * This file defines the form for editing question XML. + * + * @package qtype_stack + * @copyright 2025 University of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qtype_stack_question_xml_form extends moodleform { + // phpcs:ignore moodle.Commenting.MissingDocblock.Function + protected function definition() { + + $mform = $this->_form; + + $mform->addElement( + 'textarea', + 'questionxml', + stack_string('editxmlquestion'), + ['rows' => $this->_customdata['numberrows'], 'cols' => 100] + ); + $mform->setType('questionxml', PARAM_RAW); + + // Submit buttons. + if (method_exists('MoodleQuickForm', 'set_sticky_footer')) { + $this->add_sticky_action_buttons(true, $this->_customdata['submitlabel']); + } else { + $this->add_action_buttons(true, $this->_customdata['submitlabel']); + } + } + + // phpcs:ignore moodle.Commenting.MissingDocblock.Function,moodle.NamingConventions.ValidFunctionName.LowercaseMethod + public function setConstants($data) { + $this->_form->setConstants($data); + } +} diff --git a/tests/api_stackquestionloader_test.php b/tests/api_stackquestionloader_test.php index 796916f4553..abdf30a9afe 100644 --- a/tests/api_stackquestionloader_test.php +++ b/tests/api_stackquestionloader_test.php @@ -50,16 +50,17 @@ public function test_question_loader(): void { $this->assertEquals('test_3_matrix', $question->name); $this->assertEquals('

Correct answer, well done.

', $question->prtcorrect); $this->assertEquals('html', $question->prtcorrectformat); - $this->assertEquals('-1', $question->prts['prt1']->get_nodes_summary()[0]->truenextnode); - $this->assertEquals('1-0-T ', $question->prts['prt1']->get_nodes_summary()[0]->trueanswernote); - $this->assertEquals(10, $question->prts['prt1']->get_nodes_summary()[0]->truescore); - $this->assertEquals('=', $question->prts['prt1']->get_nodes_summary()[0]->truescoremode); - $this->assertEquals('1', $question->prts['prt1']->get_nodes_summary()[0]->falsenextnode); - $this->assertEquals('1-0-F', $question->prts['prt1']->get_nodes_summary()[0]->falseanswernote); - $this->assertEquals(0, $question->prts['prt1']->get_nodes_summary()[0]->falsescore); - $this->assertEquals('=', $question->prts['prt1']->get_nodes_summary()[0]->falsescoremode); - $this->assertEquals(true, $question->prts['prt1']->get_nodes_summary()[0]->quiet); - $this->assertEquals('ATAlgEquiv(ans1,TA)', $question->prts['prt1']->get_nodes_summary()[0]->answertest); + $nodesummary = $question->prts['prt1']->get_nodes_summary()[0]; + $this->assertEquals('-1', $nodesummary->truenextnode); + $this->assertEquals('1-0-T ', $nodesummary->trueanswernote); + $this->assertEquals(10, $nodesummary->truescore); + $this->assertEquals('=', $nodesummary->truescoremode); + $this->assertEquals('1', $nodesummary->falsenextnode); + $this->assertEquals('1-0-F', $nodesummary->falseanswernote); + $this->assertEquals(0, $nodesummary->falsescore); + $this->assertEquals('=', $nodesummary->falsescoremode); + $this->assertEquals(true, $nodesummary->quiet); + $this->assertEquals('ATAlgEquiv(ans1,TA)', $nodesummary->answertest); $this->assertContains(86, $question->deployedseeds); $this->assertContains(219862533, $question->deployedseeds); $this->assertContains(1167893775, $question->deployedseeds); @@ -67,11 +68,10 @@ public function test_question_loader(): void { } public function test_question_loader_use_defaults(): void { - - global $CFG; $xml = stack_api_test_data::get_question_string('usedefaults'); $ql = new StackQuestionLoader(); $question = $ql->loadXML($xml)['question']; + $this->assertEquals($question->options->get_option('decimals'), get_config('qtype_stack', 'decimals')); $this->assertEquals( $question->options->get_option('scientificnotation'), @@ -140,18 +140,91 @@ public function test_question_loader_base_question(): void { $xml = stack_api_test_data::get_question_string('empty'); $question = StackQuestionLoader::loadXML($xml)['question']; $this->assertEquals('Default', $question->name); - $this->assertEquals('Correct answer, well done.', $question->prtcorrect); + $this->assertEquals( + '

Default question

[[input:ans1]] [[validation:ans1]]

', + $question->questiontext + ); + $this->assertEquals('html', $question->questiontextformat); + $this->assertEquals( + '', + $question->generalfeedback + ); + $this->assertEquals('html', $question->generalfeedbackformat); + $this->assertEquals( + ' Correct answer, well done.', + $question->prtcorrect + ); $this->assertEquals('html', $question->prtcorrectformat); - $this->assertEquals('-1', $question->prts['prt1']->get_nodes_summary()[0]->truenextnode); - $this->assertEquals('prt1-1-T', $question->prts['prt1']->get_nodes_summary()[0]->trueanswernote); - $this->assertEquals(1, $question->prts['prt1']->get_nodes_summary()[0]->truescore); - $this->assertEquals('=', $question->prts['prt1']->get_nodes_summary()[0]->truescoremode); - $this->assertEquals('-1', $question->prts['prt1']->get_nodes_summary()[0]->falsenextnode); - $this->assertEquals('prt1-1-F', $question->prts['prt1']->get_nodes_summary()[0]->falseanswernote); - $this->assertEquals(0, $question->prts['prt1']->get_nodes_summary()[0]->falsescore); - $this->assertEquals('=', $question->prts['prt1']->get_nodes_summary()[0]->falsescoremode); - $this->assertEquals(false, $question->prts['prt1']->get_nodes_summary()[0]->quiet); - $this->assertEquals('ATAlgEquiv(ans1,ta1)', $question->prts['prt1']->get_nodes_summary()[0]->answertest); + $this->assertEquals( + ' Your answer is partially correct.', + $question->prtpartiallycorrect + ); + $this->assertEquals('html', $question->prtpartiallycorrectformat); + $this->assertEquals( + ' Incorrect answer.', + $question->prtincorrect + ); + $this->assertEquals('html', $question->prtincorrectformat); + $this->assertEquals(1, $question->defaultmark); + $this->assertEquals(0.1, $question->penalty); + if (isset($question->hidden)) { + // Moodle > 4.1. + $this->assertEquals(0, $question->hidden); + } + $this->assertEquals( + \get_config('qtype_stack', 'stackversion'), + $question->stackversion + ); + $this->assertEquals( + 'ta1:1;', + $question->questionvariables + ); + $this->assertEquals( + '[[feedback:prt1]]', + $question->specificfeedback + ); + $this->assertEquals('html', $question->specificfeedbackformat); + $this->assertEquals( + '{@ta1@}', + $question->questionnote + ); + $this->assertEquals('html', $question->questionnoteformat); + $this->assertEquals( + '', + $question->questiondescription + ); + $this->assertEquals('html', $question->questiondescriptionformat); + + $this->assertEquals(\get_config('qtype_stack', 'decimals'), $question->options->get_option('decimals')); + $this->assertEquals(\get_config('qtype_stack', 'scientificnotation'), $question->options->get_option('scientificnotation')); + $this->assertEquals(\get_config('qtype_stack', 'assumepositive'), $question->options->get_option('assumepos')); + $this->assertEquals(\get_config('qtype_stack', 'assumereal'), $question->options->get_option('assumereal')); + $this->assertEquals(\get_config('qtype_stack', 'multiplicationsign'), $question->options->get_option('multiplicationsign')); + $this->assertEquals(\get_config('qtype_stack', 'sqrtsign'), $question->options->get_option('sqrtsign')); + $this->assertEquals(\get_config('qtype_stack', 'complexno'), $question->options->get_option('complexno')); + $this->assertEquals(\get_config('qtype_stack', 'logicsymbol'), $question->options->get_option('logicsymbol')); + $this->assertEquals(\get_config('qtype_stack', 'inversetrig'), $question->options->get_option('inversetrig')); + $this->assertEquals(\get_config('qtype_stack', 'matrixparens'), $question->options->get_option('matrixparens')); + $this->assertEquals(\get_config('qtype_stack', 'questionsimplify'), $question->options->get_option('simplify')); + $this->assertEquals(0, $question->isbroken); + + $this->assertEquals(1, $question->prts['prt1']->get_value()); + $this->assertEquals(1, $question->prts['prt1']->get_feedbackstyle()); + $this->assertEquals('', $question->prts['prt1']->get_feedbackvariables_keyvals()); + + $nodesummary = $question->prts['prt1']->get_nodes_summary()[0]; + $this->assertEquals('', $nodesummary->description); + $this->assertEquals('ATAlgEquiv(ans1,ta1)', $nodesummary->answertest); + $this->assertEquals(0, $nodesummary->quiet); + $this->assertEquals('-1', $nodesummary->truenextnode); + $this->assertEquals('prt1-1-T', $nodesummary->trueanswernote); + $this->assertEquals(1, $nodesummary->truescore); + $this->assertEquals('=', $nodesummary->truescoremode); + $this->assertEquals('-1', $nodesummary->falsenextnode); + $this->assertEquals('prt1-1-F', $nodesummary->falseanswernote); + $this->assertEquals(0, $nodesummary->falsescore); + $this->assertEquals('=', $nodesummary->falsescoremode); + $this->assertEquals(0, $nodesummary->quiet); $this->assertEquals($question->inputs['ans1']->get_parameter('mustVerify'), get_config('qtype_stack', 'inputmustverify')); $this->assertEquals( $question->inputs['ans1']->get_parameter('showValidation'), @@ -458,8 +531,8 @@ public function test_detect_difference_yml(): void { "value: '2'\n autosimplify: '1'\n feedbackstyle: '1'\n " . "node:\n - name: '0'\n answertest: AlgEquiv\n " . "sans: ans1\n tans: ta1\n quiet: '1'\n - name: prt2\n " . - "value: '1.0000001'\n autosimplify: '1'\n feedbackstyle: '1'\n node:\n - name: '0'\n " . " - answertest: AlgEquiv\n sans: ans2\n tans: ta2\n quiet: '0'\n falsescore: '1'\n" . + "value: '1.0000001'\n autosimplify: '1'\n feedbackstyle: '1'\n node:\n - name: '0'\n " . + "answertest: AlgEquiv\n sans: ans2\n tans: ta2\n quiet: '0'\n falsescore: '1'\n" . "deployedseed:\n - '1'\n - '2'\n - '3'\nqtest:\n - testcase: '1'\n description: 'A test'\n " . "testinput:\n - name: ans1\n - name: ans2\n value: ta2\n expected:\n - name: prt1" . "\n expectedscore: '1.0000000'\n expectedpenalty: '0.0000000'\n " . diff --git a/tests/behat/editxml.feature b/tests/behat/editxml.feature new file mode 100644 index 00000000000..e7397aaa472 --- /dev/null +++ b/tests/behat/editxml.feature @@ -0,0 +1,164 @@ +@qtype @qtype_stack +Feature: Test editing XML of a question. + In order easilt edit PRTs + As an teacher + I need to be able to edit their XML. + + Background: + Given I set up STACK using the PHPUnit configuration + And the following "users" exist: + | username | + | teacher | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher | C1 | editingteacher | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "questions" exist: + | questioncategory | qtype | name | template | + | Test questions | stack | Simple STACK question | test1 | + + @javascript + Scenario: Update XML with bad XML - requires importasversion update + When I am on the "Course 1" "core_question > course question bank" page logged in as "teacher" + And I choose "STACK question dashboard" action for "Simple STACK question" in the question bank + And I follow "Edit question XML" + And I set the following fields to these values: + | Question XML | Broken question | + And I press "id_submitbutton" + Then I should see "QUESTION WAS NOT SAVED" + And I should see "Version 1" + And the following fields match these values: + | Question XML | Broken question | + And I set the field "Question XML" to multiline: + """ + + + + + Simple STACK question + + + Find + \[ \int {@p@} d{@v@}\] + [[input:ans1]] + [[validation:ans1]] + + + We can either do this question by inspection (i.e. spot the answer) + or in a more formal manner by using the substitution + \[ u = ({@v@}-{@a@}).\] + Then, since $\frac{d}{d{@v@}}u=1$ we have + \[ \int {@p@} d{@v@} = \int u^{@n@} du = \frac{u^{@n+1@}}{@n+1@}+c = {@ta@}+c.\] + + 4 + -1 + 0 + + + 2025092900 + + + n : rand(5)+3; a : rand(5)+3; v : x; p : (v-a)^n; ta : (x-7)^4/4; ta1 : ta + + + [[feedback:PotResTree_1]] + + + {@p@}, {@ta@}. + + + This is a basic test question. + + 1 + 0 + 0 + + Correct answer, well done! + + + Your answer is partially correct! + + + Incorrect answer :-( + + . + *10 + dot + 1 + i + cos-1 + lang + [ + 0 + + + ans1 + algebraic + ta+c + 20 + 1 + 0 + + 0 + int, [[BASIC-ALGEBRA]] + popup, boo, Sin + 1 + 0 + 0 + 1 + 1 + + + + PotResTree_1 + 1.0000000 + 1 + 1 + + sa:subst(x=-x,ans1)+ans1 + + + 0 + Anti-derivative test + Int + ans1+0 + ta + x + 0 + = + 1 + + -1 + PotResTree_1-1-T + + + + = + 0 + + -1 + PotResTree_1-1-F + + + + + + + ]]> + + + Hint 2

]]>
+
+
+ +
+""" + And I press "id_submitbutton" + And I should see "Version 2" + And I should see "The penalty must be a numeric value between 0 and 1" + And I should see "The question has been marked as broken" diff --git a/tests/questiontype_test.php b/tests/questiontype_test.php index a996db4db92..3a6e8c96231 100644 --- a/tests/questiontype_test.php +++ b/tests/questiontype_test.php @@ -662,4 +662,121 @@ public function test_get_prt_names_from_question_duplicate_split(): void { '[[feedback:prt1]]' )); } + + public function test_import_xml_empty_fragment(): void { + $xml = ' + + Default + + '; + if (class_exists('\core\xml_parser') && method_exists('\core\xml_parser', 'parse')) { + $parser = new \core\xml_parser(); + $xmldata = $parser->parse($xml); + } else { + $xmldata = xmlize($xml); + } + + $importer = new qformat_xml(); + $question = $importer->try_importing_using_qtypes($xmldata['question'], null, null, 'stack'); + $this->assertEquals('Default', $question->name); + $this->assertEquals( + '

Default question

[[input:ans1]] [[validation:ans1]]

', + $question->questiontext + ); + $this->assertEquals(FORMAT_HTML, $question->questiontextformat); + $this->assertEquals( + '', + $question->generalfeedback + ); + $this->assertEquals(FORMAT_HTML, $question->generalfeedbackformat); + $this->assertEquals( + ' Correct answer, well done.', + $question->prtcorrect['text'] + ); + $this->assertEquals(FORMAT_HTML, $question->prtpartiallycorrect['format']); + $this->assertEquals( + ' Your answer is partially correct.', + $question->prtpartiallycorrect['text'] + ); + $this->assertEquals(FORMAT_HTML, $question->prtincorrect['format']); + $this->assertEquals( + ' Incorrect answer.', + $question->prtincorrect['text'] + ); + $this->assertEquals(FORMAT_HTML, $question->prtcorrect['format']); + $this->assertEquals(1, $question->defaultmark); + $this->assertEquals(0.1, $question->penalty); + $this->assertEquals(0, $question->hidden); + $this->assertEquals( + \get_config('qtype_stack', 'stackversion'), + $question->stackversion + ); + $this->assertEquals( + 'ta1:1;', + $question->questionvariables + ); + $this->assertEquals( + '[[feedback:prt1]]', + $question->specificfeedback['text'] + ); + $this->assertEquals(FORMAT_HTML, $question->specificfeedback['format']); + $this->assertEquals( + '{@ta1@}', + $question->questionnote['text'] + ); + $this->assertEquals(FORMAT_HTML, $question->questionnote['format']); + $this->assertEquals( + '', + $question->questiondescription['text'] + ); + $this->assertEquals(FORMAT_HTML, $question->questiondescription['format']); + + $this->assertEquals(\get_config('qtype_stack', 'decimals'), $question->decimals); + $this->assertEquals(\get_config('qtype_stack', 'scientificnotation'), $question->scientificnotation); + $this->assertEquals(\get_config('qtype_stack', 'assumepositive'), $question->assumepositive); + $this->assertEquals(\get_config('qtype_stack', 'assumereal'), $question->assumereal); + $this->assertEquals(\get_config('qtype_stack', 'multiplicationsign'), $question->multiplicationsign); + $this->assertEquals(\get_config('qtype_stack', 'sqrtsign'), $question->sqrtsign); + $this->assertEquals(\get_config('qtype_stack', 'complexno'), $question->complexno); + $this->assertEquals(\get_config('qtype_stack', 'logicsymbol'), $question->logicsymbol); + $this->assertEquals(\get_config('qtype_stack', 'inversetrig'), $question->inversetrig); + $this->assertEquals(\get_config('qtype_stack', 'matrixparens'), $question->matrixparens); + $this->assertEquals(\get_config('qtype_stack', 'questionsimplify'), $question->questionsimplify); + $this->assertEquals(0, $question->isbroken); + + $this->assertEquals(1, $question->prt1value); + $this->assertEquals(1, $question->prt1autosimplify); + $this->assertEquals(1, $question->prt1feedbackstyle); + $this->assertEquals('', $question->prt1feedbackvariables); + + $this->assertEquals('', $question->prt1description[0]); + $this->assertEquals('AlgEquiv', $question->prt1answertest[0]); + $this->assertEquals('ans1', $question->prt1sans[0]); + $this->assertEquals('ta1', $question->prt1tans[0]); + $this->assertEquals(0, $question->prt1quiet[0]); + $this->assertEquals('', $question->prt1testoptions[0]); + $this->assertEquals('-1', $question->prt1truenextnode[0]); + $this->assertEquals('prt1-1-T', $question->prt1trueanswernote[0]); + $this->assertEquals(1, $question->prt1truescore[0]); + $this->assertEquals('', $question->prt1truepenalty[0]); + $this->assertEquals('=', $question->prt1truescoremode[0]); + $this->assertEquals('', $question->prt1truefeedback[0]['text']); + $this->assertEquals(FORMAT_HTML, $question->prt1truefeedback[0]['format']); + $this->assertEquals('=', $question->prt1truescoremode[0]); + $this->assertEquals('-1', $question->prt1falsenextnode[0]); + $this->assertEquals('prt1-1-F', $question->prt1falseanswernote[0]); + $this->assertEquals(0, $question->prt1falsescore[0]); + $this->assertEquals('=', $question->prt1falsescoremode[0]); + $this->assertEquals('', $question->prt1falsepenalty[0]); + $this->assertEquals('', $question->prt1falsefeedback[0]['text']); + $this->assertEquals(FORMAT_HTML, $question->prt1falsefeedback[0]['format']); + $this->assertEquals(\get_config('qtype_stack', 'inputmustverify'), $question->ans1mustverify); + $this->assertEquals(\get_config('qtype_stack', 'inputshowvalidation'), $question->ans1showvalidation); + $this->assertEquals(\get_config('qtype_stack', 'inputinsertstars'), $question->ans1insertstars); + $this->assertEquals(\get_config('qtype_stack', 'inputforbidfloat'), $question->ans1forbidfloat); + $this->assertEquals(\get_config('qtype_stack', 'inputrequirelowestterms'), $question->ans1requirelowestterms); + $this->assertEquals(\get_config('qtype_stack', 'inputcheckanswertype'), $question->ans1checkanswertype); + $this->assertEquals(\get_config('qtype_stack', 'inputforbidwords'), $question->ans1forbidwords); + $this->assertEquals(\get_config('qtype_stack', 'inputboxsize'), $question->ans1boxsize); + } } diff --git a/version.php b/version.php index 6f6b2b9c26d..4b5569e6bda 100644 --- a/version.php +++ b/version.php @@ -35,4 +35,6 @@ 'qbehaviour_adaptivemultipart' => 2020103000, 'qbehaviour_dfexplicitvaildate' => 2018080600, 'qbehaviour_dfcbmexplicitvaildate' => 2018080600, + // TO-DO - We will need an updated version of this. + 'qbank_importasversion' => 2025041400, ];