Skip to content
Snippets Groups Projects
Select Git revision
  • 1e19de0be3b7b1700cd2a03fd38608740d93d376
  • main default protected
2 results

plugin.js

Blame
  • questiontype.php 113.02 KiB
    <?php
    // This file is part of Stack - http://stack.maths.ed.ac.uk/
    //
    // Stack is free software: you can redistribute it and/or modify
    // it under the terms of the GNU General Public License as published by
    // the Free Software Foundation, either version 3 of the License, or
    // (at your option) any later version.
    //
    // Stack is distributed in the hope that it will be useful,
    // but WITHOUT ANY WARRANTY; without even the implied warranty of
    // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    // GNU General Public License for more details.
    //
    // You should have received a copy of the GNU General Public License
    // along with Stack.  If not, see <http://www.gnu.org/licenses/>.
    
    /**
     * Question type class for the Stack question type.
     *
     * @package    qtype_stack
     * @copyright  2012 The Open University
     * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
     */
    
    
    defined('MOODLE_INTERNAL') || die();
    
    require_once($CFG->libdir . '/questionlib.php');
    require_once(__DIR__ . '/stack/input/factory.class.php');
    require_once(__DIR__ . '/stack/answertest/controller.class.php');
    require_once(__DIR__ . '/stack/cas/keyval.class.php');
    require_once(__DIR__ . '/stack/cas/castext2/castext2_evaluatable.class.php');
    require_once(__DIR__ . '/stack/cas/castext2/castext2_static_replacer.class.php');
    require_once(__DIR__ . '/stack/questiontest.php');
    require_once(__DIR__ . '/stack/prt.class.php');
    require_once(__DIR__ . '/stack/potentialresponsetreestate.class.php');
    require_once(__DIR__ . '/stack/prt.class.php');
    require_once(__DIR__ . '/stack/graphlayout/graph.php');
    require_once(__DIR__ . '/lang/multilang.php');
    require_once(__DIR__ . '/stack/prt.class.php');
    
    
    /**
     * Stack question type class.
     *
     * @copyright  2012 The Open University
     * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
     */
    class qtype_stack extends question_type {
    
        /** @var int array key into the results of get_input_names_from_question_text for the count of input placeholders. */
        const INPUTS = 0;
        /** @var int array key into the results of get_input_names_from_question_text for the count of validation placeholders. */
        const VALIDATIONS = 1;
    
        /** @var int the CAS seed using during validation. */
        protected $seed = 1;
    
        /** @var stack_options the CAS options using during validation. */
        protected $options;
    
        /**
         * @var array prt name => stack_abstract_graph caches the result of
         * {@link get_prt_graph()}.
         */
        protected $prtgraph = array();
    
        public function save_question($question, $fromform) {
    
            if (!empty($fromform->fixdollars)) {
                $this->fix_dollars_in_form_data($fromform);
            }
    
            $fromform->penalty = stack_utils::fix_approximate_thirds($fromform->penalty);
    
            // This odd looking guard clause exists because moodle core test case generator
            // functions return a question without an id.
            $oldid = 0;
            if (property_exists($question, 'id')) {
                $oldid = $question->id;
            }
    
            $new = parent::save_question($question, $fromform);
            // Earlier than Moodle 4.0.
            if (stack_determine_moodle_version() < 400) {
                return $new;
            }
            $newid = $new->id;
    
            // Copy over test cases.
            $testcases = $this->load_question_tests($oldid);
            $this->save_question_tests($newid, $testcases);
    
            // Copy over deployed seeds.
            foreach ($this->get_question_deployed_seeds($oldid) as $seed) {
                $this->deploy_variant($newid, $seed);
            }
    
            return $new;
        }
    
        /**
         * Replace any $...$ and $$...$$ delimiters in the question text from the
         * form with the recommended delimiters.
         * @param object $fromform the data from the form.
         */
        protected function fix_dollars_in_form_data($fromform) {
            $questionfields = array('questiontext', 'generalfeedback', 'specificfeedback',
                    'prtcorrect', 'prtpartiallycorrect', 'prtincorrect');
            foreach ($questionfields as $field) {
                $fromform->{$field}['text'] = stack_maths::replace_dollars($fromform->{$field}['text']);
            }
            $fromform->questionnote = stack_maths::replace_dollars($fromform->questionnote);
    
            $prtnames = array_keys($this->get_prt_names_from_question($fromform->questiontext['text'],
                    $fromform->specificfeedback['text']));
            foreach ($prtnames as $prt) {
                foreach ($fromform->{$prt . 'truefeedback'} as &$feedback) {
                    $feedback['text'] = stack_maths::replace_dollars($feedback['text']);
                }
    
                foreach ($fromform->{$prt . 'falsefeedback'} as &$feedback) {
                    $feedback['text'] = stack_maths::replace_dollars($feedback['text']);
                }
            }
    
            foreach ($fromform->hint as &$hint) {
                $hint['text'] = stack_maths::replace_dollars($hint['text']);
            }
        }
    
        public function save_question_options($fromform) {
            global $DB;
            $context = $fromform->context;
    
            parent::save_question_options($fromform);
    
            $options = $DB->get_record('qtype_stack_options', array('questionid' => $fromform->id));
            if (!$options) {
                $options = new stdClass();
                $options->questionid = $fromform->id;
                $options->stackversion = '';
                $options->questionvariables = '';
                $options->questionnote = '';
                $options->specificfeedback = '';
                $options->prtcorrect = '';
                $options->prtpartiallycorrect = '';
                $options->prtincorrect = '';
                $options->stackversion = get_config('qtype_stack', 'version');
                $options->id = $DB->insert_record('qtype_stack_options', $options);
            }
    
            $options->stackversion              = $fromform->stackversion;
            $options->questionvariables         = $fromform->questionvariables;
            $options->specificfeedback          = $this->import_or_save_files($fromform->specificfeedback,
                        $context, 'qtype_stack', 'specificfeedback', $fromform->id);
            $options->specificfeedbackformat    = $fromform->specificfeedback['format'];
            $options->questionnote              = $fromform->questionnote;
            $options->questionsimplify          = $fromform->questionsimplify;
            $options->assumepositive            = $fromform->assumepositive;
            $options->assumereal                = $fromform->assumereal;
            $options->prtcorrect                = $this->import_or_save_files($fromform->prtcorrect,
                        $context, 'qtype_stack', 'prtcorrect', $fromform->id);
            $options->prtcorrectformat          = $fromform->prtcorrect['format'];
            $options->prtpartiallycorrect       = $this->import_or_save_files($fromform->prtpartiallycorrect,
                        $context, 'qtype_stack', 'prtpartiallycorrect', $fromform->id);
            $options->prtpartiallycorrectformat = $fromform->prtpartiallycorrect['format'];
            $options->prtincorrect              = $this->import_or_save_files($fromform->prtincorrect,
                        $context, 'qtype_stack', 'prtincorrect', $fromform->id);
            $options->prtincorrectformat        = $fromform->prtincorrect['format'];
            $options->multiplicationsign        = $fromform->multiplicationsign;
            $options->sqrtsign                  = $fromform->sqrtsign;
            $options->complexno                 = $fromform->complexno;
            $options->inversetrig               = $fromform->inversetrig;
            $options->logicsymbol               = $fromform->logicsymbol;
            $options->matrixparens              = $fromform->matrixparens;
            $options->variantsselectionseed     = $fromform->variantsselectionseed;
    
            // We will not have the values for this.
            $options->compiledcache             = '{}';
    
            $DB->update_record('qtype_stack_options', $options);
    
            $inputnames = array_keys($this->get_input_names_from_question_text_lang($fromform->questiontext));
            $inputs = $DB->get_records('qtype_stack_inputs',
                    array('questionid' => $fromform->id), '', 'name, id, questionid');
            $questionhasinputs = false;
            foreach ($inputnames as $inputname) {
                if (array_key_exists($inputname, $inputs)) {
                    $input = $inputs[$inputname];
                    unset($inputs[$inputname]);
                } else {
                    $input = new stdClass();
                    $input->questionid = $fromform->id;
                    $input->name       = $inputname;
                    $input->options    = '';
                    $input->id = $DB->insert_record('qtype_stack_inputs', $input);
                }
    
                $input->type               = $fromform->{$inputname . 'type'};
                $input->tans               = $fromform->{$inputname . 'modelans'};
                $input->boxsize            = $fromform->{$inputname . 'boxsize'};
                // TODO: remove this when we remove strictsyntax from the DB.
                $input->strictsyntax       = true;
                $input->insertstars        = $fromform->{$inputname . 'insertstars'};
                $input->syntaxhint         = $fromform->{$inputname . 'syntaxhint'};
                $input->syntaxattribute    = $fromform->{$inputname . 'syntaxattribute'};
                $input->forbidwords        = $fromform->{$inputname . 'forbidwords'};
                $input->allowwords         = $fromform->{$inputname . 'allowwords'};
                $input->forbidfloat        = $fromform->{$inputname . 'forbidfloat'};
                $input->requirelowestterms = $fromform->{$inputname . 'requirelowestterms'};
                $input->checkanswertype    = $fromform->{$inputname . 'checkanswertype'};
                $input->mustverify         = $fromform->{$inputname . 'mustverify'};
                $input->showvalidation     = $fromform->{$inputname . 'showvalidation'};
                $input->options            = $fromform->{$inputname . 'options'};
    
                $questionhasinputs = true;
                $DB->update_record('qtype_stack_inputs', $input);
            }
    
            if ($inputs) {
                list($test, $params) = $DB->get_in_or_equal(array_keys($inputs));
                $params[] = $fromform->id;
                $DB->delete_records_select('qtype_stack_inputs',
                        'name ' . $test . ' AND questionid = ?', $params);
            }
    
            if (!$questionhasinputs) {
                // A question with no inputs is an information item.
                $DB->set_field('question', 'length', 0, array('id' => $fromform->id));
            }
    
            $prtnames = array_keys($this->get_prt_names_from_question($fromform->questiontext, $options->specificfeedback));
    
            $prts = $DB->get_records('qtype_stack_prts',
                    array('questionid' => $fromform->id), '', 'name, id, questionid');
            foreach ($prtnames as $prtname) {
                if (array_key_exists($prtname, $prts)) {
                    $prt = $prts[$prtname];
                    unset($prts[$prtname]);
                } else {
                    $prt = new stdClass();
                    $prt->questionid        = $fromform->id;
                    $prt->name              = $prtname;
                    $prt->feedbackvariables = '';
                    $prt->firstnodename     = 0;
                    $prt->id = $DB->insert_record('qtype_stack_prts', $prt);
                }
    
                // Find the root node of the PRT.
                // Otherwise, if an existing question is being edited, and this is an
                // existing PRT, base things on the existing question definition.
                $graph = new stack_abstract_graph();
                foreach ($fromform->{$prtname . 'answertest'} as $nodename => $notused) {
                    $truenextnode  = $fromform->{$prtname . 'truenextnode'}[$nodename];
                    $falsenextnode = $fromform->{$prtname . 'falsenextnode'}[$nodename];
    
                    if ($truenextnode == -1) {
                        $left = null;
                    } else {
                        $left = $truenextnode + 1;
                    }
                    if ($falsenextnode == -1) {
                        $right = null;
                    } else {
                        $right = $falsenextnode + 1;
                    }
    
                    $graph->add_node($nodename + 1, $left, $right);
                }
                $graph->layout();
                $roots = $graph->get_roots();
                if (count($roots) != 1 || $graph->get_broken_cycles()) {
                    throw new coding_exception('The PRT ' . $prtname . ' is malformed.');
                }
                reset($roots);
                $firstnode = key($roots) - 1;
    
                $prt->value             = $fromform->{$prtname . 'value'};
                $prt->autosimplify      = $fromform->{$prtname . 'autosimplify'};
                $prt->feedbackstyle     = $fromform->{$prtname . 'feedbackstyle'};
                $prt->feedbackvariables = $fromform->{$prtname . 'feedbackvariables'};
                $prt->firstnodename     = $firstnode;
                $DB->update_record('qtype_stack_prts', $prt);
    
                $nodes = $DB->get_records('qtype_stack_prt_nodes',
                        array('questionid' => $fromform->id, 'prtname' => $prtname),
                        '', 'nodename, id, questionid, prtname');
    
                foreach ($fromform->{$prtname . 'answertest'} as $nodename => $notused) {
                    if (array_key_exists($nodename, $nodes)) {
                        $node = $nodes[$nodename];
                        unset($nodes[$nodename]);
                    } else {
                        $node = new stdClass();
                        $node->questionid    = $fromform->id;
                        $node->prtname       = $prtname;
                        $node->nodename      = $nodename;
                        $node->truefeedback  = '';
                        $node->falsefeedback = '';
                        $node->id = $DB->insert_record('qtype_stack_prt_nodes', $node);
                    }
    
                    $node->answertest          = $fromform->{$prtname . 'answertest'}[$nodename];
                    $node->sans                = $fromform->{$prtname . 'sans'}[$nodename];
                    $node->tans                = $fromform->{$prtname . 'tans'}[$nodename];
                    $node->testoptions         = $fromform->{$prtname . 'testoptions'}[$nodename];
                    $node->quiet               = $fromform->{$prtname . 'quiet'}[$nodename];
                    $node->truescoremode       = $fromform->{$prtname . 'truescoremode'}[$nodename];
                    $node->truescore           = $fromform->{$prtname . 'truescore'}[$nodename];
                    $node->truepenalty         = stack_utils::fix_approximate_thirds(
                        $fromform->{$prtname . 'truepenalty'}[$nodename]);
                    $node->truenextnode        = $fromform->{$prtname . 'truenextnode'}[$nodename];
                    $node->trueanswernote      = $fromform->{$prtname . 'trueanswernote'}[$nodename];
                    $node->truefeedback        = $this->import_or_save_files(
                                    $fromform->{$prtname . 'truefeedback'}[$nodename],
                                    $context, 'qtype_stack', 'prtnodetruefeedback', $node->id);
                    $node->truefeedbackformat  = $fromform->{$prtname . 'truefeedback'}[$nodename]['format'];
                    $node->falsescoremode      = $fromform->{$prtname . 'falsescoremode'}[$nodename];
                    $node->falsescore          = $fromform->{$prtname . 'falsescore'}[$nodename];
                    $node->falsepenalty        = stack_utils::fix_approximate_thirds(
                        $fromform->{$prtname . 'falsepenalty'}[$nodename]);
                    $node->falsenextnode       = $fromform->{$prtname . 'falsenextnode'}[$nodename];
                    $node->falseanswernote     = $fromform->{$prtname . 'falseanswernote'}[$nodename];
                    $node->falsefeedback        = $this->import_or_save_files(
                                    $fromform->{$prtname . 'falsefeedback'}[$nodename],
                                    $context, 'qtype_stack', 'prtnodefalsefeedback', $node->id);
                    $node->falsefeedbackformat  = $fromform->{$prtname . 'falsefeedback'}[$nodename]['format'];
    
                    if ('' === $node->truepenalty) {
                        $node->truepenalty = null;
                    }
                    if ('' === $node->falsepenalty) {
                        $node->falsepenalty = null;
                    }
    
                    $DB->update_record('qtype_stack_prt_nodes', $node);
                }
    
                if ($nodes) {
                    list($test, $params) = $DB->get_in_or_equal(array_keys($nodes));
                    $params[] = $fromform->id;
                    $params[] = $prt->name;
                    $DB->delete_records_select('qtype_stack_prt_nodes',
                            'nodename ' . $test . ' AND questionid = ? AND prtname = ?', $params);
                }
            }
    
            if ($prts) {
                list($test, $params) = $DB->get_in_or_equal(array_keys($prts));
                $params[] = $fromform->id;
                $DB->delete_records_select('qtype_stack_prt_nodes',
                        'prtname ' . $test . ' AND questionid = ?', $params);
                $DB->delete_records_select('qtype_stack_prts',
                        'name ' . $test . ' AND questionid = ?', $params);
            }
    
            $this->save_hints($fromform);
    
            // This is a bit of a hack. If doing 'Duplicate' in the question bank
            // then when saving the editing form, then detect that here, and try to
            // copy the deployed variants from the original question.
            if (!isset($fromform->deployedseeds) && !empty($fromform->makecopy)) {
                $oldquestionid = optional_param('id', 0, PARAM_INT);
                if ($oldquestionid) {
                    $fromform->deployedseeds = $DB->get_fieldset_sql('
                            SELECT seed
                              FROM {qtype_stack_deployed_seeds}
                             WHERE questionid = ?
                          ORDER BY id', [$oldquestionid]);;
                }
            }
    
            if (isset($fromform->deployedseeds)) {
                $DB->delete_records('qtype_stack_deployed_seeds', array('questionid' => $fromform->id));
                foreach ($fromform->deployedseeds as $deployedseed) {
                    $record = new stdClass();
                    $record->questionid = $fromform->id;
                    $record->seed = $deployedseed;
                    $DB->insert_record('qtype_stack_deployed_seeds', $record, false);
                }
            }
    
            // This is a bit of a hack. If doing 'Duplicate' in the question bank
            // then when saving the editing form, then detect that here, and try to
            // copy the question tests from the original question.
            if (!isset($fromform->testcases) && !empty($fromform->makecopy)) {
                $oldquestionid = optional_param('id', 0, PARAM_INT);
                if ($oldquestionid) {
                    $fromform->testcases = $this->load_question_tests($oldquestionid);
                }
            }
    
            if (isset($fromform->testcases)) {
                // If the data includes the defintion of the question tests that there
                // should be (i.e. when doing import) then replace the existing set
                // of tests with the new one.
                $this->save_question_tests($fromform->id, $fromform->testcases);
            }
    
            // Irrespective of what else has happened, ensure there is no garbage
            // in the database, for example if we delete a PRT, remove the expected
            // values for that PRT while leaving the rest of the testcases alone.
            list($nametest, $params) = $DB->get_in_or_equal($inputnames, SQL_PARAMS_NAMED, 'input', false, null);
            $params['questionid'] = $fromform->id;
            $DB->delete_records_select('qtype_stack_qtest_inputs',
                    'questionid = :questionid AND inputname ' . $nametest, $params);
    
            list($nametest, $params) = $DB->get_in_or_equal($prtnames, SQL_PARAMS_NAMED, 'prt', false, null);
            $params['questionid'] = $fromform->id;
            $DB->delete_records_select('qtype_stack_qtest_expected',
                    'questionid = :questionid AND prtname ' . $nametest, $params);
        }
    
        public function get_question_options($question) {
            global $DB;
    
            parent::get_question_options($question);
    
            $question->options = $DB->get_record('qtype_stack_options',
                    array('questionid' => $question->id), '*', MUST_EXIST);
    
            $question->inputs = $DB->get_records('qtype_stack_inputs',
                    array('questionid' => $question->id), 'name',
                    'name, id, questionid, type, tans, boxsize, strictsyntax, insertstars, ' .
                    'syntaxhint, syntaxattribute, forbidwords, allowwords, forbidfloat, requirelowestterms, ' .
                    'checkanswertype, mustverify, showvalidation, options');
    
            $question->prts = $DB->get_records('qtype_stack_prts',
                    array('questionid' => $question->id), 'name',
                    'name, id, questionid, value, autosimplify, feedbackstyle, feedbackvariables, firstnodename');
    
            $noders = $DB->get_recordset('qtype_stack_prt_nodes',
                    array('questionid' => $question->id),
                    'prtname, nodename');
            foreach ($noders as $node) {
                if (!property_exists($question->prts[$node->prtname], 'nodes')) {
                    $question->prts[$node->prtname]->nodes = [];
                }
                $question->prts[$node->prtname]->nodes[$node->nodename] = $node;
            }
            $noders->close();
    
            $question->deployedseeds = $this->get_question_deployed_seeds($question->id);
    
            return true;
        }
    
        protected function get_question_deployed_seeds($qid) {
            global $DB;
    
            return $DB->get_fieldset_sql('
                    SELECT seed
                      FROM {qtype_stack_deployed_seeds}
                     WHERE questionid = ?
                  ORDER BY id', array($qid));
        }
    
        protected function initialise_question_instance(question_definition $question, $questiondata) {
            parent::initialise_question_instance($question, $questiondata);
    
            $question->stackversion              = $questiondata->options->stackversion;
            $question->questionvariables         = $questiondata->options->questionvariables;
            $question->questionnote              = $questiondata->options->questionnote;
            $question->specificfeedback          = $questiondata->options->specificfeedback;
            $question->specificfeedbackformat    = $questiondata->options->specificfeedbackformat;
            $question->prtcorrect                = $questiondata->options->prtcorrect;
            $question->prtcorrectformat          = $questiondata->options->prtcorrectformat;
            $question->prtpartiallycorrect       = $questiondata->options->prtpartiallycorrect;
            $question->prtpartiallycorrectformat = $questiondata->options->prtpartiallycorrectformat;
            $question->prtincorrect              = $questiondata->options->prtincorrect;
            $question->prtincorrectformat        = $questiondata->options->prtincorrectformat;
            $question->variantsselectionseed     = $questiondata->options->variantsselectionseed;
            $question->compiledcache             = $questiondata->options->compiledcache;
    
            // Parse the cache in advance.
            if (is_string($question->compiledcache)) {
                $question->compiledcache = json_decode($question->compiledcache, true);
            } else if ($question->compiledcache === null) {
                // If someone has done nulling through the database.
                $question->compiledcache = [];
            }
    
            $question->options = new stack_options();
            $question->options->set_option('multiplicationsign', $questiondata->options->multiplicationsign);
            $question->options->set_option('complexno',          $questiondata->options->complexno);
            $question->options->set_option('inversetrig',        $questiondata->options->inversetrig);
            $question->options->set_option('logicsymbol',        $questiondata->options->logicsymbol);
            $question->options->set_option('matrixparens',       $questiondata->options->matrixparens);
            $question->options->set_option('sqrtsign',    (bool) $questiondata->options->sqrtsign);
            $question->options->set_option('simplify',    (bool) $questiondata->options->questionsimplify);
            $question->options->set_option('assumepos',   (bool) $questiondata->options->assumepositive);
            $question->options->set_option('assumereal',  (bool) $questiondata->options->assumereal);
    
            $requiredparams = stack_input_factory::get_parameters_used();
            foreach (stack_utils::extract_placeholders($question->questiontext, 'input') as $name) {
                $inputdata = $questiondata->inputs[$name];
                $allparameters = array(
                    'boxWidth'        => $inputdata->boxsize,
                    'strictSyntax'    => true,
                    'insertStars'     => (int) $inputdata->insertstars,
                    'syntaxHint'      => $inputdata->syntaxhint,
                    'syntaxAttribute' => $inputdata->syntaxattribute,
                    'forbidWords'     => $inputdata->forbidwords,
                    'allowWords'      => $inputdata->allowwords,
                    'forbidFloats'    => (bool) $inputdata->forbidfloat,
                    'lowestTerms'     => (bool) $inputdata->requirelowestterms,
                    'sameType'        => (bool) $inputdata->checkanswertype,
                    'mustVerify'      => (bool) $inputdata->mustverify,
                    'showValidation'  => $inputdata->showvalidation,
                    'options'         => $inputdata->options,
                );
                $parameters = array();
                foreach ($requiredparams[$inputdata->type] as $paramname) {
                    if ($paramname == 'inputType') {
                        continue;
                    }
                    $parameters[$paramname] = $allparameters[$paramname];
                }
                $question->inputs[$name] = stack_input_factory::make(
                        $inputdata->type, $inputdata->name, $inputdata->tans, $question->options, $parameters);
            }
    
            $totalvalue = 0;
            $allformative = true;
            foreach ($questiondata->prts as $name => $prtdata) {
                // At this point we do not have the PRT method is_formative() available to us.
                if ($prtdata->feedbackstyle > 0) {
                    $totalvalue += $prtdata->value;
                    $allformative = false;
                }
            }
            if ($questiondata->prts && !$allformative && $totalvalue < 0.0000001) {
                throw new coding_exception('There is an error authoring your question. ' .
                        'The $totalvalue, the marks available for the question, must be positive in question ' .
                        $question->name);
            }
    
            $prtnames = array_keys($this->get_prt_names_from_question($question->questiontext, $question->specificfeedback));
    
            foreach ($prtnames as $name) {
                $prtvalue = 0;
                if (!$allformative) {
                    $prtvalue = $questiondata->prts[$name]->value / $totalvalue;
                }
                $question->prts[$name] = new stack_potentialresponse_tree_lite($questiondata->prts[$name],
                    $prtvalue, $question);
            }
    
            $question->deployedseeds = array_values($questiondata->deployedseeds);
        }
    
        /**
         * Get the URL params required for linking to associated scripts like
         * questiontestrun.php.
         *
         * @param stdClass|qtype_stack_question $question question data, as from question_bank::load_question
         *      or question_bank::load_question_data.
         * @return array of URL params. Can be passed to moodle_url.
         */
        protected function get_question_url_params($question) {
            $urlparams = array('questionid' => $question->id);
            if (property_exists($question, 'seed')) {
                $urlparams['seed'] = $question->seed;
            }
    
            // This is a bit of a hack to find the right thing to put in the URL.
            // If we are already on a URL that gives us a clue what to do, use that.
            $context = context::instance_by_id($question->contextid);
            if ($cmid = optional_param('cmid', null, PARAM_INT)) {
                $urlparams['cmid'] = $cmid;
    
            } else if ($courseid = optional_param('courseid', null, PARAM_INT)) {
                $urlparams['courseid'] = $courseid;
    
            } else if ($context->contextlevel == CONTEXT_MODULE) {
                $urlparams['cmid'] = $context->instanceid;
    
            } else if ($context->contextlevel == CONTEXT_COURSE) {
                $urlparams['courseid'] = $context->instanceid;
    
            } else {
                $urlparams['courseid'] = get_site()->id;
            }
    
            return $urlparams;
        }
    
        /**
         * Get the URL for questiontestrun.php for a question.
         *
         * @param stdClass|qtype_stack_question $question question data, as from question_bank::load_question
         *      or question_bank::load_question_data.
         * @return moodle_url the URL.
         */
        public function get_question_test_url($question) {
            $linkparams = $this->get_question_url_params($question);
            return new moodle_url('/question/type/stack/questiontestrun.php', $linkparams);
        }
    
        /**
         * Get the URL for tidyquestion.php for a question.
         *
         * @param stdClass|qtype_stack_question $question question data, as from question_bank::load_question
         *      or question_bank::load_question_data.
         * @return moodle_url the URL.
         */
        public function get_tidy_question_url($question) {
            $linkparams = $this->get_question_url_params($question);
            return new moodle_url('/question/type/stack/tidyquestion.php', $linkparams);
        }
    
        public function get_extra_question_bank_actions(stdClass $question): array {
            $actions = parent::get_extra_question_bank_actions($question);
    
            $linkparams = $this->get_question_url_params($question);
    
            // Directly link to question tests and deployed variants.
            if (question_has_capability_on($question, 'view')) {
                $actions[] = new \action_menu_link_secondary(
                        new moodle_url('/question/type/stack/questiontestrun.php', $linkparams),
                        new \pix_icon('t/approve', ''),
                        get_string('runquestiontests', 'qtype_stack'));
            }
    
            // Directly link to tidy question script.
            if (question_has_capability_on($question, 'view')) {
                $actions[] = new \action_menu_link_secondary(
                        new moodle_url('/question/type/stack/tidyquestion.php', $linkparams),
                        new \pix_icon('t/edit', ''),
                        get_string('tidyquestion', 'qtype_stack'));
            }
    
            return $actions;
        }
    
        public function delete_question($questionid, $contextid) {
            global $DB;
            $this->delete_question_tests($questionid);
            $DB->delete_records('qtype_stack_deployed_seeds', array('questionid' => $questionid));
            $DB->delete_records('qtype_stack_prt_nodes',      array('questionid' => $questionid));
            $DB->delete_records('qtype_stack_prts',           array('questionid' => $questionid));
            $DB->delete_records('qtype_stack_inputs',         array('questionid' => $questionid));
            $DB->delete_records('qtype_stack_options',        array('questionid' => $questionid));
            parent::delete_question($questionid, $contextid);
        }
    
        public function move_files($questionid, $oldcontextid, $newcontextid) {
            global $DB;
            $fs = get_file_storage();
    
            parent::move_files($questionid, $oldcontextid, $newcontextid);
            $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid);
    
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                'qtype_stack', 'specificfeedback',    $questionid);
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                'qtype_stack', 'prtcorrect',          $questionid);
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                'qtype_stack', 'prtpartiallycorrect', $questionid);
            $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                'qtype_stack', 'prtincorrect',        $questionid);
    
            $nodeids = $DB->get_records_menu('qtype_stack_prt_nodes', array('questionid' => $questionid), 'id', 'id,1');
            foreach ($nodeids as $nodeid => $notused) {
                $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                    'qtype_stack', 'prtnodetruefeedback', $nodeid);
                $fs->move_area_files_to_new_context($oldcontextid, $newcontextid,
                                                    'qtype_stack', 'prtnodefalsefeedback', $nodeid);
            }
        }
    
        protected function delete_files($questionid, $contextid) {
            global $DB;
            $fs = get_file_storage();
    
            parent::delete_files($questionid, $contextid);
            $this->delete_files_in_hints($questionid, $contextid);
    
            $fs->delete_area_files($contextid, 'qtype_stack', 'specificfeedback',    $questionid);
            $fs->delete_area_files($contextid, 'qtype_stack', 'prtcorrect',          $questionid);
            $fs->delete_area_files($contextid, 'qtype_stack', 'prtpartiallycorrect', $questionid);
            $fs->delete_area_files($contextid, 'qtype_stack', 'prtincorrect',        $questionid);
    
            $nodeids = $DB->get_records_menu('qtype_stack_prt_nodes', array('questionid' => $questionid), 'id', 'id,1');
            foreach ($nodeids as $nodeid => $notused) {
                $fs->delete_area_files($oldcontextid, $newcontextid,
                                                    'qtype_stack', 'prtnodetruefeedback', $nodeid);
                $fs->delete_area_files($oldcontextid, $newcontextid,
                                                    'qtype_stack', 'prtnodefalsefeedback', $nodeid);
            }
        }
    
        /**
         * Save a set of question tests for a question, replacing any existing tests.
         * @param int $questionid the question id of the question we are manipulating the tests for.
         * @param array $testcases testcase number => stack_question_test
         */
        public function save_question_tests($questionid, $testcases) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
            $this->delete_question_tests($questionid);
            foreach ($testcases as $number => $testcase) {
                $this->save_question_test($questionid, $testcase, $number);
            }
            $transaction->allow_commit();
        }
    
        /**
         * Save a question tests for a question, either replacing the test at a given
         * number, or adding a new test, either with a given number, or taking the
         * first unused number.
         * @param int $questionid the question id of the question we are manipulating the tests for.
         * @param stack_question_test $qtest
         * @param int $testcases testcase number to replace/add. If not given, the first unused number is found.
         */
        public function save_question_test($questionid, stack_question_test $qtest, $testcase = null) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
    
            if (!$testcase || !$DB->record_exists('qtype_stack_qtests',
                    array('questionid' => $questionid, 'testcase' => $testcase))) {
                // Find the first unused testcase number.
                $testcase = $DB->get_field_sql('
                            SELECT MIN(qt.testcase) + 1
                            FROM (
                                SELECT testcase FROM {qtype_stack_qtests} WHERE questionid = ?
                                UNION
                                SELECT 0
                            ) qt
                            LEFT JOIN {qtype_stack_qtests} qt2 ON qt2.questionid = ? AND
                                                                  qt2.testcase = qt.testcase + 1
                            WHERE qt2.id IS NULL
                            ', array($questionid, $questionid));
                $testcasedata = new stdClass();
                $testcasedata->questionid = $questionid;
                $testcasedata->testcase = $testcase;
                $testcasedata->timemodified = time();
                $DB->insert_record('qtype_stack_qtests', $testcasedata);
            } else {
                $DB->set_field('qtype_stack_qtests', 'timemodified', time(),
                        array('questionid' => $questionid, 'testcase' => $testcase));
            }
    
            // Save the input data.
            $DB->delete_records('qtype_stack_qtest_inputs', array('questionid' => $questionid, 'testcase' => $testcase));
            foreach ($qtest->inputs as $name => $value) {
                $testinput = new stdClass();
                $testinput->questionid = $questionid;
                $testinput->testcase   = $testcase;
                $testinput->inputname  = $name;
                $testinput->value      = $value;
                $DB->insert_record('qtype_stack_qtest_inputs', $testinput);
            }
    
            // Save the expected outcome data.
            $DB->delete_records('qtype_stack_qtest_expected', array('questionid' => $questionid, 'testcase' => $testcase));
            foreach ($qtest->expectedresults as $prtname => $expectedresults) {
                $expected = new stdClass();
                $expected->questionid         = $questionid;
                $expected->testcase           = $testcase;
                $expected->prtname            = $prtname;
                if ($expectedresults->score === '' || $expectedresults->score === null) {
                    $expected->expectedscore = null;
                } else {
                    $expected->expectedscore = (float) $expectedresults->score;
                }
                if ($expectedresults->penalty === '' || $expectedresults->penalty === null) {
                    $expected->expectedpenalty = null;
                } else {
                    $expected->expectedpenalty = stack_utils::fix_approximate_thirds(
                            (float) $expectedresults->penalty);
                }
                $expected->expectedanswernote = $expectedresults->answernotes[0];
                $DB->insert_record('qtype_stack_qtest_expected', $expected);
            }
    
            $transaction->allow_commit();
        }
    
        /**
         * Deploy a variant of a question.
         * @param int $questionid the question id.
         * @param int $seed the seed to deploy.
         */
        public function deploy_variant($questionid, $seed) {
            global $DB;
    
            $record = new stdClass();
            $record->questionid = $questionid;
            $record->seed = $seed;
            $DB->insert_record('qtype_stack_deployed_seeds', $record);
    
            $this->notify_question_edited($questionid);
        }
    
        /**
         * Un-deploy a variant of a question.
         * @param int $questionid the question id.
         * @param int $seed the seed to un-deploy.
         */
        public function undeploy_variant($questionid, $seed) {
            global $DB;
    
            $DB->delete_records('qtype_stack_deployed_seeds',
                    array('questionid' => $questionid, 'seed' => $seed));
    
            $this->notify_question_edited($questionid);
        }
    
        /**
         * Rename an input in the question data. It is the caller's responsibility
         * to ensure that the $to name will not violate any unique constraints.
         * @param int $questionid the question id.
         * @param string $from the input to rename.
         * @param string $to the new name to give it.
         */
        public function rename_input($questionid, $from, $to) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
    
            // Place-holders in the question text.
            $questiontext = $DB->get_field('question', 'questiontext', array('id' => $questionid));
            $questiontext = str_replace(array("[[input:{$from}]]", "[[validation:{$from}]]"),
                    array("[[input:{$to}]]", "[[validation:{$to}]]"), $questiontext);
            $DB->set_field('question', 'questiontext', $questiontext, array('id' => $questionid));
    
            // Input names in question test data.
            $DB->set_field('qtype_stack_qtest_inputs', 'inputname', $to,
                    array('questionid' => $questionid, 'inputname' => $from));
    
            // The input itself.
            $DB->set_field('qtype_stack_inputs', 'name', $to,
                    array('questionid' => $questionid, 'name' => $from));
    
            $regex = '~\b' . preg_quote($from, '~') . '\b~';
            // Where the input name appears in expressions in PRTs.
            $prts = $DB->get_records('qtype_stack_prts', array('questionid' => $questionid),
                        'id, feedbackvariables');
            foreach ($prts as $prt) {
                $prt->feedbackvariables = preg_replace($regex, $to, $prt->feedbackvariables, -1, $changes);
                if ($changes) {
                    $DB->update_record('qtype_stack_prts', $prt);
                }
            }
    
            // Where the input name appears in expressions in PRT node.
            $nodes = $DB->get_records('qtype_stack_prt_nodes', array('questionid' => $questionid),
                            'id, sans, tans, testoptions, truefeedback, falsefeedback');
            foreach ($nodes as $node) {
                $changes = false;
                $node->sans = preg_replace($regex, $to, $node->sans, -1, $count);
                $changes = $changes || $count;
                $node->tans = preg_replace($regex, $to, $node->tans, -1, $count);
                $changes = $changes || $count;
                $node->testoptions = preg_replace($regex, $to, $node->testoptions, -1, $count);
                $changes = $changes || $count;
                $node->truefeedback = preg_replace($regex, $to, $node->truefeedback, -1, $count);
                $changes = $changes || $count;
                $node->falsefeedback = preg_replace($regex, $to, $node->falsefeedback, -1, $count);
                $changes = $changes || $count;
                if ($changes) {
                    $DB->update_record('qtype_stack_prt_nodes', $node);
                }
            }
    
            // If someone plays with input names we need to clear compiledcache.
            $sql = 'UPDATE {qtype_stack_options} SET compiledcache = ? WHERE questionid = ?';
            $params[] = '{}';
            $params[] = $questionid;
            $DB->execute($sql, $params);
    
            $transaction->allow_commit();
            $this->notify_question_edited($questionid);
        }
    
        /**
         * Rename a PRT in the question data. It is the caller's responsibility
         * to ensure that the $to name will not violate any unique constraints.
         * @param int $questionid the question id.
         * @param string $from the PRT to rename.
         * @param string $to the new name to give it.
         */
        public function rename_prt($questionid, $from, $to) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
    
            // Place-holders in the question text.
            $questiontext = $DB->get_field('question', 'questiontext', array('id' => $questionid));
            $questiontext = str_replace("[[feedback:{$from}]]", "[[feedback:{$to}]]", $questiontext);
            $DB->set_field('question', 'questiontext', $questiontext, array('id' => $questionid));
    
            // Place-holders in the specific feedback.
            $specificfeedback = $DB->get_field('qtype_stack_options', 'specificfeedback',
                    array('questionid' => $questionid));
            $specificfeedback = str_replace("[[feedback:{$from}]]", "[[feedback:{$to}]]", $specificfeedback);
            $DB->set_field('qtype_stack_options', 'specificfeedback', $specificfeedback,
                    array('questionid' => $questionid));
    
            // PRT names in question test data.
            $DB->set_field('qtype_stack_qtest_expected', 'prtname', $to,
                    array('questionid' => $questionid, 'prtname' => $from));
    
            // The PRT name in its nodes.
            $DB->set_field('qtype_stack_prt_nodes', 'prtname', $to,
                    array('questionid' => $questionid, 'prtname' => $from));
    
            // The PRT itself.
            $DB->set_field('qtype_stack_prts', 'name', $to,
                    array('questionid' => $questionid, 'name' => $from));
    
            // If someone plays with PRT names we need to clear compiledcache.
            $sql = 'UPDATE {qtype_stack_options} SET compiledcache = ? WHERE questionid = ?';
            $params[] = '{}';
            $params[] = $questionid;
            $DB->execute($sql, $params);
    
            $transaction->allow_commit();
            $this->notify_question_edited($questionid);
        }
    
        /**
         * Rename a PRT node in the question data. It is the caller's responsibility
         * to ensure that the $to name will not violate any unique constraints.
         * @param int $questionid the question id.
         * @param string $prtname the PRT that the node belongs to.
         * @param string $from the input to rename.
         * @param string $to the new name to give it.
         */
        public function rename_prt_node($questionid, $prtname, $from, $to) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
    
            // The PRT node itself.
            $DB->set_field('qtype_stack_prt_nodes', 'nodename', $to,
                    array('questionid' => $questionid, 'prtname' => $prtname, 'nodename' => $from));
    
            // True next node links.
            $DB->set_field('qtype_stack_prt_nodes', 'truenextnode', $to,
                    array('questionid' => $questionid, 'prtname' => $prtname, 'truenextnode' => $from));
    
            // False next node links.
            $DB->set_field('qtype_stack_prt_nodes', 'falsenextnode', $to,
                    array('questionid' => $questionid, 'prtname' => $prtname, 'falsenextnode' => $from));
    
            // PRT first node link.
            $DB->set_field('qtype_stack_prts', 'firstnodename', $to,
                    array('questionid' => $questionid, 'name' => $prtname, 'firstnodename' => $from));
    
            // If someone plays with PRT node names we need to clear compiledcache.
            $sql = 'UPDATE {qtype_stack_options} SET compiledcache = ? WHERE questionid = ?';
            $params[] = '{}';
            $params[] = $questionid;
            $DB->execute($sql, $params);
    
            $transaction->allow_commit();
            $this->notify_question_edited($questionid);
        }
    
        /**
         * From Moodle 2.4 onwards, we need to clear the entry from the question
         * cache if a question definition changes. This method deals with doing
         * that without causing errors on earlier versions of Moodle.
         * @param int $questionid the question id to clear from the cache.
         */
        protected function notify_question_edited($questionid) {
            if (method_exists('question_bank', 'notify_question_edited')) {
                call_user_func(array('question_bank', 'notify_question_edited'), $questionid);
            }
        }
    
        /**
         * Load all the question tests for a question.
         * @param int $questionid the id of the question to load the tests for.
         * @return array testcase number => stack_question_test
         */
        public function load_question_tests($questionid) {
            global $DB;
    
            $testinputdata = $DB->get_records('qtype_stack_qtest_inputs',
                    array('questionid' => $questionid), 'testcase, inputname');
            $testinputs = array();
            foreach ($testinputdata as $data) {
                $testinputs[$data->testcase][$data->inputname] = $data->value;
            }
    
            $testcasenumbers = $DB->get_records_menu('qtype_stack_qtests',
                    array('questionid' => $questionid), 'testcase', 'testcase, 1');
            $testcases = array();
            foreach ($testcasenumbers as $number => $notused) {
                if (!array_key_exists($number, $testinputs)) {
                    $testinputs[$number] = array();
                }
                $testcase = new stack_question_test($testinputs[$number], $number);
                $testcases[$number] = $testcase;
            }
    
            $expecteddata = $DB->get_records('qtype_stack_qtest_expected',
                    array('questionid' => $questionid), 'testcase, prtname');
            foreach ($expecteddata as $data) {
                $testcases[$data->testcase]->add_expected_result($data->prtname,
                        new stack_potentialresponse_tree_state(1, true,
                                $data->expectedscore, $data->expectedpenalty,
                                '', array($data->expectedanswernote)));
            }
    
            return $testcases;
        }
    
        /**
         * Load one particular question tests for a question.
         * @param int $questionid the id of the question to load the tests for.
         * @param int $testcase the testcase nubmer to load.
         * @return stack_question_test the test-case
         */
        public function load_question_test($questionid, $testcase) {
            global $DB;
    
            // Verify that this testcase exists.
            $DB->get_record('qtype_stack_qtests',
                    array('questionid' => $questionid, 'testcase' => $testcase), '*', MUST_EXIST);
    
            // Load the inputs.
            $inputs = $DB->get_records_menu('qtype_stack_qtest_inputs',
                    array('questionid' => $questionid, 'testcase' => $testcase),
                    'inputname', 'inputname, value');
            $qtest = new stack_question_test($inputs, $testcase);
    
            // Load the expectations.
            $expectations = $DB->get_records('qtype_stack_qtest_expected',
                    array('questionid' => $questionid, 'testcase' => $testcase), 'prtname',
                    'prtname, expectedscore, expectedpenalty, expectedanswernote');
            foreach ($expectations as $prtname => $expected) {
                $qtest->add_expected_result($prtname, new stack_potentialresponse_tree_state(
                        1, true, $expected->expectedscore, $expected->expectedpenalty,
                        '', array($expected->expectedanswernote)));
            }
    
            return $qtest;
        }
    
        /**
         * Delete all the question tests for a question.
         * @param int $questionid the id of the question to load the tests for.
         */
        protected function delete_question_tests($questionid) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
            $DB->delete_records('qtype_stack_qtest_expected', array('questionid' => $questionid));
            $DB->delete_records('qtype_stack_qtest_inputs',   array('questionid' => $questionid));
            $DB->delete_records('qtype_stack_qtests',         array('questionid' => $questionid));
            $DB->delete_records('qtype_stack_qtest_results',  array('questionid' => $questionid));
            $transaction->allow_commit();
        }
    
        /**
         * Delete one particular question test for a question.
         * @param int $questionid the id of the question to load the tests for.
         * @param int $testcase the testcase nubmer to load.
         */
        public function delete_question_test($questionid, $testcase) {
            global $DB;
            $transaction = $DB->start_delegated_transaction();
            $DB->delete_records('qtype_stack_qtest_expected',
                    array('questionid' => $questionid, 'testcase' => $testcase));
            $DB->delete_records('qtype_stack_qtest_inputs',
                    array('questionid' => $questionid, 'testcase' => $testcase));
            $DB->delete_records('qtype_stack_qtests',
                    array('questionid' => $questionid, 'testcase' => $testcase));
            $DB->delete_records('qtype_stack_qtest_results',
                    array('questionid' => $questionid, 'testcase' => $testcase));
            $transaction->allow_commit();
        }
    
        public function get_possible_responses($questiondata) {
            $parts = array();
    
            $q = $this->make_question($questiondata);
    
            foreach ($q->prts as $index => $prt) {
                foreach ($prt->get_nodes_summary() as $nodeid => $choices) {
                    $parts[$index . '-' . $nodeid] = array(
                        $choices->falseanswernote => new question_possible_response(
                                $choices->falseanswernote, $choices->falsescore * $prt->get_value()),
                        $choices->trueanswernote => new question_possible_response(
                                $choices->trueanswernote, $choices->truescore * $prt->get_value()),
                        null              => question_possible_response::no_response(),
                    );
                }
            }
    
            return $parts;
        }
    
        /**
         * Helper method used by {@link export_to_xml()}.
         * @param qformat_xml $format the importer/exporter object.
         * @param string $tag the XML tag to use.
         * @param string $text the text to output.
         * @param int $textformat the text's format.
         * @param int $itemid the itemid for any files.
         * @param int $contextid the context id that the text belongs to.
         * @param string $indent the amount of indent to add at the start of the line.
         * @return string XML fragment.
         */
        protected function export_xml_text(qformat_xml $format, $tag, $text, $textformat,
                $contextid, $filearea, $itemid, $indent = '    ') {
            $fs = get_file_storage();
            $files = $fs->get_area_files($contextid, 'qtype_stack', $filearea, $itemid);
    
            $output = '';
            $output .= $indent . "<{$tag} {$format->format($textformat)}>\n";
            $output .= $indent . '  ' . $format->writetext($text);
            $output .= $format->write_files($files);
            $output .= $indent . "</{$tag}>\n";
    
            return $output;
        }
    
        public function export_to_xml($questiondata, qformat_xml $format, $notused = null) {
            $contextid = $questiondata->contextid;
    
            if (!isset($questiondata->testcases)) {
                // The method get_question_options does not load the testcases, because
                // they are not normally needed, so we have to load them manually here.
                // However, we only do it conditionally, so that the unit tests can
                // just pass the data in.
                $questiondata->testcases = $this->load_question_tests($questiondata->id);
            }
    
            $output = '';
    
            $options = $questiondata->options;
            $output .= "    <stackversion>\n";
            $output .= "      " . $format->writetext($options->stackversion, 0);
            $output .= "    </stackversion>\n";
            $output .= "    <questionvariables>\n";
            $output .= "      " . $format->writetext($options->questionvariables, 0);
            $output .= "    </questionvariables>\n";
            $output .= $this->export_xml_text($format, 'specificfeedback', $options->specificfeedback,
                            $options->specificfeedbackformat, $contextid, 'specificfeedback', $questiondata->id);
            $output .= "    <questionnote>\n";
            $output .= "      " . $format->writetext($options->questionnote, 0);
            $output .= "    </questionnote>\n";
            $output .= "    <questionsimplify>{$options->questionsimplify}</questionsimplify>\n";
            $output .= "    <assumepositive>{$options->assumepositive}</assumepositive>\n";
            $output .= "    <assumereal>{$options->assumereal}</assumereal>\n";
            $output .= $this->export_xml_text($format, 'prtcorrect', $options->prtcorrect,
                            $options->prtcorrectformat, $contextid, 'prtcorrect', $questiondata->id);
            $output .= $this->export_xml_text($format, 'prtpartiallycorrect', $options->prtpartiallycorrect,
                            $options->prtpartiallycorrectformat, $contextid, 'prtpartiallycorrect', $questiondata->id);
            $output .= $this->export_xml_text($format, 'prtincorrect', $options->prtincorrect,
                            $options->prtincorrectformat, $contextid, 'prtincorrect', $questiondata->id);
            $output .= "    <multiplicationsign>{$options->multiplicationsign}</multiplicationsign>\n";
            $output .= "    <sqrtsign>{$options->sqrtsign}</sqrtsign>\n";
            $output .= "    <complexno>{$options->complexno}</complexno>\n";
            $output .= "    <inversetrig>{$options->inversetrig}</inversetrig>\n";
            $output .= "    <logicsymbol>{$options->logicsymbol}</logicsymbol>\n";
            $output .= "    <matrixparens>{$options->matrixparens}</matrixparens>\n";
            $output .= "    <variantsselectionseed>{$format->xml_escape($options->variantsselectionseed)}</variantsselectionseed>\n";
    
            foreach ($questiondata->inputs as $input) {
                $output .= "    <input>\n";
                $output .= "      <name>{$input->name}</name>\n";
                $output .= "      <type>{$input->type}</type>\n";
                $output .= "      <tans>{$format->xml_escape($input->tans)}</tans>\n";
                $output .= "      <boxsize>{$input->boxsize}</boxsize>\n";
                $output .= "      <strictsyntax>{$input->strictsyntax}</strictsyntax>\n";
                $output .= "      <insertstars>{$input->insertstars}</insertstars>\n";
                $output .= "      <syntaxhint>{$format->xml_escape($input->syntaxhint)}</syntaxhint>\n";
                $output .= "      <syntaxattribute>{$format->xml_escape($input->syntaxattribute)}</syntaxattribute>\n";
                $output .= "      <forbidwords>{$format->xml_escape($input->forbidwords)}</forbidwords>\n";
                $output .= "      <allowwords>{$format->xml_escape($input->allowwords)}</allowwords>\n";
                $output .= "      <forbidfloat>{$input->forbidfloat}</forbidfloat>\n";
                $output .= "      <requirelowestterms>{$input->requirelowestterms}</requirelowestterms>\n";
                $output .= "      <checkanswertype>{$input->checkanswertype}</checkanswertype>\n";
                $output .= "      <mustverify>{$input->mustverify}</mustverify>\n";
                $output .= "      <showvalidation>{$input->showvalidation}</showvalidation>\n";
                $output .= "      <options>{$input->options}</options>\n";
                $output .= "    </input>\n";
            }
    
            foreach ($questiondata->prts as $prt) {
                $output .= "    <prt>\n";
                $output .= "      <name>{$prt->name}</name>\n";
                $output .= "      <value>{$prt->value}</value>\n";
                $output .= "      <autosimplify>{$prt->autosimplify}</autosimplify>\n";
                $output .= "      <feedbackstyle>{$prt->feedbackstyle}</feedbackstyle>\n";
                $output .= "      <feedbackvariables>\n";
                $output .= "        " . $format->writetext($prt->feedbackvariables, 0);
                $output .= "      </feedbackvariables>\n";
    
                foreach ($prt->nodes as $node) {
                    $output .= "      <node>\n";
                    $output .= "        <name>{$node->nodename}</name>\n";
                    $output .= "        <answertest>{$node->answertest}</answertest>\n";
                    $output .= "        <sans>{$format->xml_escape($node->sans)}</sans>\n";
                    $output .= "        <tans>{$format->xml_escape($node->tans)}</tans>\n";
                    $output .= "        <testoptions>{$format->xml_escape($node->testoptions)}</testoptions>\n";
                    $output .= "        <quiet>{$node->quiet}</quiet>\n";
                    $output .= "        <truescoremode>{$node->truescoremode}</truescoremode>\n";
                    $output .= "        <truescore>{$format->xml_escape($node->truescore)}</truescore>\n";
                    $output .= "        <truepenalty>{$format->xml_escape($node->truepenalty)}</truepenalty>\n";
                    $output .= "        <truenextnode>{$node->truenextnode}</truenextnode>\n";
                    $output .= "        <trueanswernote>{$format->xml_escape($node->trueanswernote)}</trueanswernote>\n";
                    $output .= $this->export_xml_text($format, 'truefeedback', $node->truefeedback, $node->truefeedbackformat,
                                    $contextid, 'prtnodetruefeedback', $node->id, '        ');
                    $output .= "        <falsescoremode>{$node->falsescoremode}</falsescoremode>\n";
                    $output .= "        <falsescore>{$format->xml_escape($node->falsescore)}</falsescore>\n";
                    $output .= "        <falsepenalty>{$format->xml_escape($node->falsepenalty)}</falsepenalty>\n";
                    $output .= "        <falsenextnode>{$node->falsenextnode}</falsenextnode>\n";
                    $output .= "        <falseanswernote>{$format->xml_escape($node->falseanswernote)}</falseanswernote>\n";
                    $output .= $this->export_xml_text($format, 'falsefeedback', $node->falsefeedback, $node->falsefeedbackformat,
                                    $contextid, 'prtnodefalsefeedback', $node->id, '        ');
                    $output .= "      </node>\n";
                }
    
                $output .= "    </prt>\n";
            }
    
            foreach ($questiondata->deployedseeds as $deployedseed) {
                $output .= "    <deployedseed>{$deployedseed}</deployedseed>\n";
            }
    
            foreach ($questiondata->testcases as $testcase => $qtest) {
                $output .= "    <qtest>\n";
                $output .= "      <testcase>{$testcase}</testcase>\n";
    
                foreach ($qtest->inputs as $name => $value) {
                    $output .= "      <testinput>\n";
                    $output .= "        <name>{$name}</name>\n";
                    $output .= "        <value>{$format->xml_escape($value)}</value>\n";
                    $output .= "      </testinput>\n";
                }
    
                foreach ($qtest->expectedresults as $name => $expected) {
                    $output .= "      <expected>\n";
                    $output .= "        <name>{$name}</name>\n";
                    $output .= "        <expectedscore>{$format->xml_escape($expected->score)}</expectedscore>\n";
                    $output .= "        <expectedpenalty>{$format->xml_escape($expected->penalty)}</expectedpenalty>\n";
                    $output .= "        <expectedanswernote>{$format->xml_escape($expected->answernotes[0])}</expectedanswernote>\n";
                    $output .= "      </expected>\n";
                }
    
                $output .= "    </qtest>\n";
            }
    
            return $output;
        }
    
        public function import_from_xml($xml, $fromform, qformat_xml $format, $notused = null) {
            if (!isset($xml['@']['type']) || $xml['@']['type'] != $this->name()) {
                return false;
            }
    
            $fromform = $format->import_headers($xml);
            $fromform->qtype = $this->name();
    
            $fromform->stackversion          = $format->getpath($xml, array('#', 'stackversion', 0, '#', 'text', 0, '#'), '', true);
            $fromform->questionvariables     = $format->getpath($xml, array('#', 'questionvariables',
                                                                0, '#', 'text', 0, '#'), '', true);
            $fromform->specificfeedback      = $this->import_xml_text($xml, 'specificfeedback', $format, $fromform->questiontextformat);
            $fromform->questionnote          = $format->getpath($xml, array('#', 'questionnote', 0, '#', 'text', 0, '#'), '', true);
            $fromform->questionsimplify      = $format->getpath($xml, array('#', 'questionsimplify', 0, '#'), 1);
            $fromform->assumepositive        = $format->getpath($xml, array('#', 'assumepositive', 0, '#'), 0);
            $fromform->assumereal            = $format->getpath($xml, array('#', 'assumereal', 0, '#'), 0);
            $fromform->prtcorrect            = $this->import_xml_text($xml, 'prtcorrect', $format, $fromform->questiontextformat);
            $fromform->prtpartiallycorrect   = $this->import_xml_text($xml, 'prtpartiallycorrect',
                                                                      $format, $fromform->questiontextformat);
            $fromform->prtincorrect          = $this->import_xml_text($xml, 'prtincorrect', $format, $fromform->questiontextformat);
            $fromform->penalty               = $format->getpath($xml, array('#', 'penalty', 0, '#'), 0.1);
            $fromform->multiplicationsign    = $format->getpath($xml, array('#', 'multiplicationsign', 0, '#'), 'dot');
            $fromform->sqrtsign              = $format->getpath($xml, array('#', 'sqrtsign', 0, '#'), 1);
            $fromform->complexno             = $format->getpath($xml, array('#', 'complexno', 0, '#'), 'i');
            $fromform->inversetrig           = $format->getpath($xml, array('#', 'inversetrig', 0, '#'), 'cos-1');
            $fromform->logicsymbol           = $format->getpath($xml, array('#', 'logicsymbol', 0, '#'), 'lang');
            $fromform->matrixparens          = $format->getpath($xml, array('#', 'matrixparens', 0, '#'), '[');
            $fromform->variantsselectionseed = $format->getpath($xml, array('#', 'variantsselectionseed', 0, '#'), 'i');
    
            if (isset($xml['#']['input'])) {
                foreach ($xml['#']['input'] as $inputxml) {
                    $this->import_xml_input($inputxml, $fromform, $format);
                }
            }
    
            if (isset($xml['#']['input'])) {
                foreach ($xml['#']['input'] as $inputxml) {
                    $this->import_xml_input($inputxml, $fromform, $format);
                }
            }
    
            if (isset($xml['#']['prt'])) {
                foreach ($xml['#']['prt'] as $prtxml) {
                    $this->import_xml_prt($prtxml, $fromform, $format);
                }
            }
    
            $format->import_hints($fromform, $xml, false, false,
                    $format->get_format($fromform->questiontextformat));
    
            if (isset($xml['#']['deployedseed'])) {
                $fromform->deployedseeds = array();
                foreach ($xml['#']['deployedseed'] as $seedxml) {
                    $fromform->deployedseeds[] = $format->getpath($seedxml, array('#'), null);
                }
            }
    
            if (isset($xml['#']['qtest'])) {
                $fromform->testcases = array();
                foreach ($xml['#']['qtest'] as $qtestxml) {
                    list($no, $testcase) = $this->import_xml_qtest($qtestxml, $format);
                    $fromform->testcases[$no] = $testcase;
                }
            }
    
            return $fromform;
        }
    
        /**
         * Helper method used by {@link export_to_xml()}.
         * @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.
         * @return array with fields text, format and files.
         */
        protected function import_xml_text($xml, $field, qformat_xml $format, $defaultformat) {
            $text = array();
            $text['text']   = $format->getpath($xml, array('#', $field, 0, '#', 'text', 0, '#'), '', true);
            $text['format'] = $format->trans_format($format->getpath($xml, array('#', $field, 0, '@', 'format'),
                                                    $format->get_format($defaultformat)));
            $text['files']  = $format->import_files($format->getpath($xml, array('#', $field, 0, '#', 'file'), array(), false));
    
            return $text;
        }
    
        /**
         * Helper method used by {@link export_to_xml()}. Handle the data for one input.
         * @param array $xml the bit of the XML representing one input.
         * @param object $fromform the data structure we are building from the XML.
         * @param qformat_xml $format the importer/exporter object.
         */
        protected function import_xml_input($xml, $fromform, qformat_xml $format) {
            $name = $format->getpath($xml, array('#', 'name', 0, '#'), null, false, 'Missing input name in the XML.');
    
            $fromform->{$name . 'type'}               = $format->getpath($xml, array('#', 'type', 0, '#'), '');
            $fromform->{$name . 'modelans'}           = $format->getpath($xml, array('#', 'tans', 0, '#'), '');
            $fromform->{$name . 'boxsize'}            = $format->getpath($xml, array('#', 'boxsize', 0, '#'), 15);
            $fromform->{$name . 'strictsyntax'}       = $format->getpath($xml, array('#', 'strictsyntax', 0, '#'), 1);
            $fromform->{$name . 'insertstars'}        = $format->getpath($xml, array('#', 'insertstars', 0, '#'), 0);
            $fromform->{$name . 'syntaxhint'}         = $format->getpath($xml, array('#', 'syntaxhint', 0, '#'), '');
            $fromform->{$name . 'syntaxattribute'}    = $format->getpath($xml, array('#', 'syntaxattribute', 0, '#'), 0);
            $fromform->{$name . 'forbidwords'}        = $format->getpath($xml, array('#', 'forbidwords', 0, '#'), '');
            $fromform->{$name . 'allowwords'}         = $format->getpath($xml, array('#', 'allowwords', 0, '#'), '');
            $fromform->{$name . 'forbidfloat'}        = $format->getpath($xml, array('#', 'forbidfloat', 0, '#'), 1);
            $fromform->{$name . 'requirelowestterms'} = $format->getpath($xml, array('#', 'requirelowestterms', 0, '#'), 0);
            $fromform->{$name . 'checkanswertype'}    = $format->getpath($xml, array('#', 'checkanswertype', 0, '#'), 0);
            $fromform->{$name . 'mustverify'}         = $format->getpath($xml, array('#', 'mustverify', 0, '#'), 1);
            $fromform->{$name . 'showvalidation'}     = $format->getpath($xml, array('#', 'showvalidation', 0, '#'), 1);
            $fromform->{$name . 'options'}            = $format->getpath($xml, array('#', 'options', 0, '#'), '');
        }
    
        /**
         * Helper method used by {@link export_to_xml()}. Handle the data for one PRT.
         * @param array $xml the bit of the XML representing one PRT.
         * @param object $fromform the data structure we are building from the XML.
         * @param qformat_xml $format the importer/exporter object.
         */
        protected function import_xml_prt($xml, $fromform, qformat_xml $format) {
            $name = $format->getpath($xml, array('#', 'name', 0, '#'), null, false, 'Missing PRT name in the XML.');
    
            $fromform->{$name . 'value'}             = $format->getpath($xml, array('#', 'value', 0, '#'), 1);
            $fromform->{$name . 'autosimplify'}      = $format->getpath($xml, array('#', 'autosimplify', 0, '#'), 1);
            $fromform->{$name . 'feedbackstyle'}     = $format->getpath($xml, array('#', 'feedbackstyle', 0, '#'), 1);
            $fromform->{$name . 'feedbackvariables'} = $format->getpath($xml,
                                array('#', 'feedbackvariables', 0, '#', 'text', 0, '#'), '', true);
    
            if (isset($xml['#']['node'])) {
                foreach ($xml['#']['node'] as $nodexml) {
                    $this->import_xml_prt_node($nodexml, $name, $fromform, $format);
                }
            }
        }
    
        /**
         * Helper method used by {@link import_xml_prt()}. Handle the data for one PRT node.
         * @param array $xml the bit of the XML representing one PRT.
         * @param string $prtname the name of the PRT this node belongs to.
         * @param object $fromform the data structure we are building from the XML.
         * @param qformat_xml $format the importer/exporter object.
         */
        protected function import_xml_prt_node($xml, $prtname, $fromform, qformat_xml $format) {
            $name = $format->getpath($xml, array('#', 'name', 0, '#'), null, false, 'Missing PRT name in the XML.');
    
            $fromform->{$prtname . 'answertest'}[$name]      = $format->getpath($xml, array('#', 'answertest', 0, '#'), '');
            $fromform->{$prtname . 'sans'}[$name]            = $format->getpath($xml, array('#', 'sans', 0, '#'), '');
            $fromform->{$prtname . 'tans'}[$name]            = $format->getpath($xml, array('#', 'tans', 0, '#'), '');
            $fromform->{$prtname . 'testoptions'}[$name]     = $format->getpath($xml, array('#', 'testoptions', 0, '#'), '');
            $fromform->{$prtname . 'quiet'}[$name]           = $format->getpath($xml, array('#', 'quiet', 0, '#'), 0);
            $fromform->{$prtname . 'truescoremode'}[$name]   = $format->getpath($xml, array('#', 'truescoremode', 0, '#'), '=');
            $fromform->{$prtname . 'truescore'}[$name]       = $format->getpath($xml, array('#', 'truescore', 0, '#'), 1);
            $fromform->{$prtname . 'truepenalty'}[$name]     = $format->getpath($xml, array('#', 'truepenalty', 0, '#'), '');
            $fromform->{$prtname . 'truenextnode'}[$name]    = $format->getpath($xml, array('#', 'truenextnode', 0, '#'), -1);
            $fromform->{$prtname . 'trueanswernote'}[$name]  = $format->getpath($xml,
                    array('#', 'trueanswernote', 0, '#'), 1, '');
            $fromform->{$prtname . 'truefeedback'}[$name]    = $this->import_xml_text($xml,
                    'truefeedback', $format, $fromform->questiontextformat);
            $fromform->{$prtname . 'falsescoremode'}[$name]  = $format->getpath($xml, array('#', 'falsescoremode', 0, '#'), '=');
            $fromform->{$prtname . 'falsescore'}[$name]      = $format->getpath($xml, array('#', 'falsescore', 0, '#'), 1);
            $fromform->{$prtname . 'falsepenalty'}[$name]    = $format->getpath($xml, array('#', 'falsepenalty', 0, '#'), '');
            $fromform->{$prtname . 'falsenextnode'}[$name]   = $format->getpath($xml, array('#', 'falsenextnode', 0, '#'), -1);
            $fromform->{$prtname . 'falseanswernote'}[$name] = $format->getpath($xml, array('#', 'falseanswernote', 0, '#'), '');
            $fromform->{$prtname . 'falsefeedback'}[$name]   = $this->import_xml_text($xml,
                    'falsefeedback', $format, $fromform->questiontextformat);
        }
    
        /**
         * Helper method used by {@link export_to_xml()}. Handle the data for one question text.
         * @param array $xml the bit of the XML representing one question text.
         * @param qformat_xml $format the importer/exporter object.
         * @return stack_question_test the question test.
         */
        protected function import_xml_qtest($xml, qformat_xml $format) {
            $number = $format->getpath($xml, array('#', 'testcase', 0, '#'), null, false, 'Missing testcase number in the XML.');
    
            $inputs = array();
            if (isset($xml['#']['testinput'])) {
                foreach ($xml['#']['testinput'] as $inputxml) {
                    $name  = $format->getpath($inputxml, array('#', 'name', 0, '#'), '');
                    $value = $format->getpath($inputxml, array('#', 'value', 0, '#'), '');
                    $inputs[$name] = $value;
                }
            }
    
            $testcase = new stack_question_test($inputs, $number);
    
            if (isset($xml['#']['expected'])) {
                foreach ($xml['#']['expected'] as $expectedxml) {
                    $name  = $format->getpath($expectedxml, array('#', 'name', 0, '#'), '');
                    $expectedscore = $format->getpath($expectedxml, array('#', 'expectedscore', 0, '#'), '');
                    $expectedpenalty = $format->getpath($expectedxml, array('#', 'expectedpenalty', 0, '#'), '');
                    $expectedanswernote = $format->getpath($expectedxml, array('#', 'expectedanswernote', 0, '#'), '');
    
                    $testcase->add_expected_result($name, new stack_potentialresponse_tree_state(
                            1, true, $expectedscore, $expectedpenalty, '', array($expectedanswernote)));
                }
            }
    
            return array($number, $testcase);
        }
    
        /*
         * This method takes Moodle's "fromform" data type and validates the question.  All question level validation and warnings
         * should be in this method.
         * Much of this code was in edit_stack_form.php (until Jan 2018).
         * See https://docs.moodle.org/dev/Question_data_structures for why we chose the "fromform" data structure,
         * not "question" objects.
         *
         * @param array $fromform Moodle's "fromform" data type.
         * @param array $errors Existing partial error array.
         * @return array($errors, $warnings).
         */
        public function validate_fromform($fromform, $errors) {
    
            $fixingdollars = array_key_exists('fixdollars', $fromform);
    
            $this->options = new stack_options();
            $this->options->set_option('multiplicationsign', $fromform['multiplicationsign']);
            $this->options->set_option('complexno',          $fromform['complexno']);
            $this->options->set_option('inversetrig',        $fromform['inversetrig']);
            $this->options->set_option('logicsymbol',        $fromform['logicsymbol']);
            $this->options->set_option('matrixparens',       $fromform['matrixparens']);
            $this->options->set_option('sqrtsign',    (bool) $fromform['sqrtsign']);
            $this->options->set_option('simplify',    (bool) $fromform['questionsimplify']);
            $this->options->set_option('assumepos',   (bool) $fromform['assumepositive']);
            $this->options->set_option('assumereal',  (bool) $fromform['assumereal']);
    
            // We slightly break the usual conventions of validation, in that rather
            // than building up $errors as an array of strings, we initially build it
            // up as an array of arrays, then at the end remove any empty arrays,
            // and implode (' ', ...) any arrays that are non-empty. This makes our
            // rather complex validation easier to implement.
    
            // Question text.
            $errors['questiontext'] = array();
            $errors = $this->validate_cas_text($errors, $fromform['questiontext']['text'], 'questiontext', $fixingdollars);
    
            // Check multi-language versions all have the same feedback tags.
            $ml = new stack_multilang();
            $combinedtext = $fromform['questiontext']['text'] . $fromform['specificfeedback']['text'];
            $langs = $ml->languages_used($combinedtext);
            if ($langs == array()) {
                $prts = $this->get_prt_names_from_question($fromform['questiontext']['text'], $fromform['specificfeedback']['text']);
            } else {
                $prtsbylang = array();
                foreach ($langs as $lang) {
                    $prtsbylang[$lang] = $this->get_prt_names_from_question_lang($ml->filter($combinedtext, $lang));
                }
                // Check they are all equal, but don't fuss about exact differences as feedback.
                $prts = reset($prtsbylang);
                $failed = false;
                foreach ($langs as $lang) {
                    if ($prtsbylang[$lang] != $prts) {
                        $failed = true;
                    }
                }
                if ($failed) {
                    $errors['questiontext'][] = stack_string('questiontextfeedbacklanguageproblems');
                }
            }
    
            // Check for whitespace following placeholders.
            $sloppytags = $this->validation_get_sloppy_tags($fromform['questiontext']['text']);
            foreach ($sloppytags as $sloppytag) {
                $errors['questiontext'][] = stack_string(
                        'questiontextplaceholderswhitespace', $sloppytag);
            }
    
            // Check multi-language versions all have the same inputs and validation tags.
            $ml = new stack_multilang();
            $langs = $ml->languages_used($fromform['questiontext']['text']);
            if ($langs == array()) {
                $inputs = $this->get_input_names_from_question_text_lang($fromform['questiontext']['text']);
            } else {
                $inputsbylang = array();
                foreach ($langs as $lang) {
                    $inputsbylang[$lang] = $this->get_input_names_from_question_text_lang(
                            $ml->filter($fromform['questiontext']['text'], $lang));
                }
                // Check they are all equal, but don't fuss about exact differences as feedback.
                $inputs = reset($inputsbylang);
                $failed = false;
                foreach ($langs as $lang) {
                    if ($inputsbylang[$lang] != $inputs) {
                        $failed = true;
                    }
                }
                if ($failed) {
                    $errors['questiontext'][] = stack_string('inputlanguageproblems');
                }
            }
    
            // Check input placholders appear with the correct number of times in the question text.
            foreach ($inputs as $inputname => $counts) {
                list($numinputs, $numvalidations) = $counts;
    
                if ($numinputs == 0 && $numvalidations == 0) {
                    if (!$fromform[$inputname . 'deleteconfirm']) {
                        $errors['questiontext'][] = stack_string('inputremovedconfirmbelow', $inputname);
                    }
                    continue;
                }
    
                if ($numinputs == 0) {
                    $errors['questiontext'][] = stack_string(
                            'questiontextmustcontain', '[[input:' . $inputname . ']]');
                } else if ($numinputs > 1) {
                    $errors['questiontext'][] = stack_string(
                            'questiontextonlycontain', '[[input:' . $inputname . ']]');
                }
    
                if ($numvalidations == 0) {
                    $errors['questiontext'][] = stack_string(
                            'questiontextmustcontain', '[[validation:' . $inputname . ']]');
                } else if ($numvalidations > 1) {
                    $errors['questiontext'][] = stack_string(
                            'questiontextonlycontain', '[[validation:' . $inputname . ']]');
                }
            }
    
            if (empty($inputs) && !empty($prts)) {
                $errors['questiontext'][] = stack_string('noprtsifnoinputs');
            }
    
            // Question variables.
            $errors = $this->validate_cas_keyval($errors, $fromform['questionvariables'], 'questionvariables',
                    array_keys($inputs));
    
            // Default mark.
            if (empty($inputs) && $fromform['defaultmark'] != 0) {
                $errors['defaultmark'][] = stack_string('defaultmarkzeroifnoprts');
            }
    
            // Penalty.
            $penalty = $fromform['penalty'];
            if (!is_numeric($penalty) || $penalty < 0 || $penalty > 1) {
                $errors['penalty'][] = stack_string('penaltyerror');
            }
    
            // Specific feedback.
            $errors['specificfeedback'] = array();
            $errors = $this->validate_cas_text($errors, $fromform['specificfeedback']['text'], 'specificfeedback', $fixingdollars);
    
            $errors['specificfeedback'] += $this->validation_check_no_placeholders(
                    stack_string('specificfeedback'), $fromform['specificfeedback']['text'],
                    array('input', 'validation'));
    
            // General feedback.
            $errors['generalfeedback'] = array();
            $errors = $this->validate_cas_text($errors, $fromform['generalfeedback']['text'], 'generalfeedback', $fixingdollars);
            $errors['generalfeedback'] += $this->validation_check_no_placeholders(
                    get_string('generalfeedback', 'question'), $fromform['generalfeedback']['text']);
    
            // Question note.
            $errors['questionnote'] = array();
            if ('' == $fromform['questionnote']) {
                $foundrandom = false;
                foreach (stack_cas_security::get_all_with_feature('random') as $rndid) {
                    if (!(false === strpos($fromform['questionvariables'], $rndid))) {
                        $foundrandom = true;
                        break;
                    }
                }
                if ($foundrandom) {
                    $errors['questionnote'][] = stack_string('questionnotempty');
                }
            } else {
                // Note, the 'questionnote' does not have an editor field and hence no 'text' sub-clause.
                $errors = $this->validate_cas_text($errors, $fromform['questionnote'], 'questionnote', $fixingdollars);
            }
    
            $errors['questionnote'] += $this->validation_check_no_placeholders(
                    stack_string('questionnote'), $fromform['questionnote']);
    
            // 2) Validate all inputs.
            $stackinputfactory = new stack_input_factory();
            foreach ($inputs as $inputname => $counts) {
                list($numinputs, $numvalidations) = $counts;
    
                if ($numinputs == 0 && $numvalidations == 0 && !$fromform[$inputname . 'deleteconfirm']) {
                    $errors[$inputname . 'deleteconfirm'][] = stack_string('youmustconfirm');
                }
    
                if ($numinputs == 0 && $numvalidations == 0) {
                    // Input is being deleted. Don't show validation errors.
                    continue;
                }
    
                if (strlen($inputname) > 18 && !isset($fromform[$inputname . 'deleteconfirm'])) {
                    $errors['questiontext'][] = stack_string('inputnamelength', $inputname);
                }
    
                if (!preg_match('/^([a-zA-Z]+|[a-zA-Z]+[0-9a-zA-Z_]*[0-9a-zA-Z]+)$/', $inputname) &&
                        !isset($fromform[$inputname . 'deleteconfirm'])) {
                    $errors['questiontext'][] = stack_string('inputnameform', $inputname);
                }
    
                if ($fromform[$inputname . 'mustverify'] and $fromform[$inputname . 'showvalidation'] == 0) {
                    $errors[$inputname . 'mustverify'][] = stack_string('mustverifyshowvalidation');
                }
    
                if (array_key_exists($inputname . 'modelans', $fromform)) {
                    $errors = $this->validate_cas_string($errors,
                            $fromform[$inputname . 'modelans'], $inputname . 'modelans', $inputname . 'modelans');
                }
    
                $inputtype = $fromform[$inputname . 'type'];
                $modelans = '';
                if (array_key_exists($inputname . 'modelans', $fromform)) {
                    $modelans = $fromform[$inputname . 'modelans'];
                }
                $stackinput = $stackinputfactory->make($inputtype, $inputname, $modelans, null, null, false);
                $parameters = array();
                foreach ($stackinputfactory->get_parameters_fromform_mapping($inputtype) as $key => $param) {
                    $paramvalue = $stackinputfactory->convert_parameter_fromform($key, $fromform[$inputname .$param]);
                    $parameters[$key] = $paramvalue;
                    if ('options' !== $key) {
                        $validityresult = $stackinput->validate_parameter($key, $paramvalue);
                        if (!($validityresult === true)) {
                            $errors[$inputname . $param][] = stack_string('inputinvalidparamater');
                        }
                    }
                }
                // Create an input with these parameters, in particular the 'options', and validate that.
                $stackinput = $stackinputfactory->make($inputtype, $inputname,
                        $fromform[$inputname . 'modelans'], null, $parameters, false);
                $stackinput->validate_extra_options();
                $errors[$inputname . 'options'] = $stackinput->get_errors();
            }
    
            // 3) Validate all prts.
            foreach ($prts as $prtname => $count) {
                if ($count == 0) {
                    if (!$fromform[$prtname . 'prtdeleteconfirm']) {
                        $errors['specificfeedback'][] = stack_string('prtremovedconfirmbelow', $prtname);
                        $errors[$prtname . 'prtdeleteconfirm'][] = stack_string('youmustconfirm');
                    }
                    // Don't show validation errors relating to a PRT that is to be deleted.
                    continue;
    
                } else if ($count > 1) {
                    $errors['specificfeedback'][] = stack_string(
                            'questiontextfeedbackonlycontain', '[[feedback:' . $prtname . ']]');
                }
    
                $errors = $this->validate_prt($errors, $fromform, $prtname, $fixingdollars);
    
            }
    
            // 4) Validate all hints.
            foreach ($fromform['hint'] as $index => $hint) {
                $errors = $this->validate_cas_text($errors, $hint['text'], 'hint[' . $index . ']', $fixingdollars);
            }
    
            // Clear out any empty $errors elements, ready for the next check.
            foreach ($errors as $field => $messages) {
                if (empty($messages)) {
                    unset($errors[$field]);
                }
            }
    
            // If everything else is OK, try executing the CAS code to check for errors.
            if (empty($errors)) {
                $errors = $this->validate_question_cas_code($errors, $fromform, $fixingdollars);
            }
    
            // Convert the $errors array from our array of arrays format to the
            // standard array of strings format.
            foreach ($errors as $field => $messages) {
                if ($messages) {
                    foreach ($messages as $key => $val) {
                        if (is_array($val)) {
                            $messages[$key] = implode(' ', $val);
                        }
                    }
                    $errors[$field] = implode(' ', $messages);
                } else {
                    unset($errors[$field]);
                }
            }
    
            return $errors;
        }
    
        /**
         * Validate a CAS string field to make sure that: 1. it fits in the DB, and
         * 2. that it is syntactically valid.
         * @param array $errors the errors array that validation is assembling.
         * @param string $value the submitted value validate.
         * @param string $fieldname the name of the field add any errors to.
         * @param string $savesession the array key to save the string to in $this->validationcasstrings.
         * @param bool|string $notblank false means do nothing (default). A string
         *      will validate that the field is not blank, and if it is, display that error.
         * @param int $maxlength the maximum allowable length. Defaults to 255.
         * @return array updated $errors array.
         */
        protected function validate_cas_string($errors, $value, $fieldname, $savesession, $notblank = true, $maxlength = 255) {
    
            if ($notblank && '' === trim($value)) {
                $errors[$fieldname][] = stack_string('nonempty');
    
            } else if (strlen($value) > $maxlength) {
                $errors[$fieldname][] = stack_string('strlengtherror');
    
            } else {
                $casstring = stack_ast_container::make_from_teacher_source($value, '', new stack_cas_security());
                if (!$casstring->get_valid()) {
                    $errors[$fieldname][] = $casstring->get_errors();
                }
            }
    
            // These particular castrings must not end in a semicolon, (unlike keyvals!).
            $tvalue = trim($value);
            $tvalue = substr($tvalue, strlen($tvalue) - 1);
            if ($tvalue === ';') {
                $errors[$fieldname][] = stack_string('nosemicolon');
            }
    
            return $errors;
        }
    
        /**
         * Validate a CAS text field.
         * @param array $errors the errors array that validation is assembling.
         * @param string $value the submitted value validate.
         * @param string $fieldname the name of the field add any errors to.
         * @param string $savesession the array key to save the session to in $this->validationcasstrings.
         * @return array updated $errors array.
         */
        protected function validate_cas_text($errors, $value, $fieldname, $fixingdollars, $session = null) {
            if (!$fixingdollars && strpos($value, '$$') !== false) {
                $errors[$fieldname][] = stack_string('forbiddendoubledollars');
            }
    
            // The castext2_evaluatable-class is much simpler for validation use.
            // Could ask the utils class directly for the internal casstrings,
            // but why when the evaluatable-class already does that.
            $castext = castext2_evaluatable::make_from_source($value, 'validation-' . $fieldname);
            if (!$castext->get_valid()) {
                $errors[$fieldname][] = $castext->get_errors();
                return $errors;
            }
    
            // Validate any [[facts:...]] tags.
            $unrecognisedtags = stack_fact_sheets::get_unrecognised_tags($value);
            if ($unrecognisedtags) {
                $errors[$fieldname][] = stack_string('unrecognisedfactstags',
                        array('tags' => implode(', ', $unrecognisedtags)));
                return $errors;
            }
    
            if ($castext->get_errors()) {
                $errors[$fieldname][] = $castext->get_errors();
                return $errors;
            }
    
            return $errors;
        }
    
        /**
         * Validate a CAS string field to make sure that: 1. it fits in the DB, and
         * 2. that it is syntactically valid.
         * @param array $errors the errors array that validation is assembling.
         * @param string $value the submitted value validate.
         * @param string $fieldname the name of the field add any errors to.
         * @return array updated $errors array.
         */
        protected function validate_cas_keyval($errors, $value, $fieldname, $inputs = null) {
            if ('' == trim($value)) {
                return $errors;
            }
    
            $keyval = new stack_cas_keyval($value, $this->options, $this->seed);
            if (!$keyval->get_valid($inputs)) {
                $errors[$fieldname][] = $keyval->get_errors();
            }
    
            return $errors;
        }
    
        /**
         * Validate all the maxima code in the question.
         *
         * This is done last, and separate from the other validation for two reasons:
         * 1. The rest of the validation is organised to validate the form in order,
         *    to match the way the form is defined. Here we need to validate in the
         *    order that the CAS is evaluated at runtime.
         * 2. This is the slowest part of validation, so we only do it at the end if
         *    everything else is OK.
         *
         * @param array $errors the errors array that validation is assembling.
         * @param array $fromform the submitted data to validate.
         * @return array updated $errors array.
         */
        protected function validate_question_cas_code($errors, $fromform, $fixingdollars) {
    
            $keyval = new stack_cas_keyval($fromform['questionvariables'], $this->options, $this->seed);
            if ($keyval->get_valid()) {
                $runtimeerrors = $keyval->instantiate();
            }
            if ($runtimeerrors) {
                $errors['questionvariables'][] = $runtimeerrors;
            }
            $session = $keyval->get_session();
            if ($session->get_errors()) {
                $errors['questionvariables'][] = $session->get_errors(true);
                $errors['questionvariables'] = array_unique($errors['questionvariables']);
                return $errors;
            }
    
            // Instantiate all text fields and look for errors.
            $castextfields = array('questiontext', 'specificfeedback', 'generalfeedback');
            foreach ($castextfields as $field) {
                $errors = $this->validate_cas_text($errors, $fromform[$field]['text'], $field, $fixingdollars, clone $session);
            }
            $errors = $this->validate_cas_text($errors, $fromform['questionnote'], 'questionnote', $fixingdollars, clone $session);
    
            // Make a list of all inputs, instantiate it and then look for errors.
            $inputs = array_keys($this->get_input_names_from_question_text($fromform['questiontext']['text']));
            $inputvalues = array();
            foreach ($inputs as $inputname) {
                if (array_key_exists($inputname . 'modelans', $fromform)) {
                    $value = $inputname.':'.$fromform[$inputname . 'modelans'];
                    $cs = stack_ast_container::make_from_teacher_source($value, '', new stack_cas_security());
                    $inputvalues[] = $cs;
                }
            }
            // TODO: why clone when we never reuse the original...
            $inputsession = clone $session;
            $inputsession->add_statements($inputvalues);
            if ($inputsession->get_valid()) {
                $inputsession->instantiate();
            }
    
            $getdebuginfo = false;
            foreach ($inputs as $inputname) {
                if ($inputsession->get_by_key($inputname) !== null &&
                        $inputsession->get_by_key($inputname)->get_errors() !== '') {
                    $errors[$inputname . 'modelans'][] = $inputsession->get_by_key($inputname)->get_errors();
                    $in = $inputsession->get_by_key($inputname);
                    if (!$in->is_correctly_evaluated()) {
                        $getdebuginfo = true;
                    }
                    // TODO: Send the actual value to the input, and ask it to validate it.
                    // For example, the matrix input type could check that the model answer is a matrix.
                }
    
                if ($fromform[$inputname . 'options'] && $inputsession->get_by_key('optionsfor' . $inputname)
                        && $inputsession->get_by_key('optionsfor' . $inputname)->get_errors() !== '') {
                    $errors[$inputname . 'options'][] = $inputsession->get_by_key('optionsfor' . $inputname)->get_errors();
                }
                // ... else TODO: Send the actual value to the input, and ask it to validate it.
            }
    
            if ($getdebuginfo) {
                $errors['questionvariables'][] = $inputsession->get_debuginfo();
            }
    
            // At this point if we have errors, especially with inputs, there is no point in executing any of the PRTs.
            if (!empty($errors)) {
                return $errors;
            }
    
            // TODO: loop over all the PRTs in a similar manner....
            // Remember, to clone the inputsession as the base session for each PRT.
            // This will have all the teacher's answers instantiated.
            // Otherwise we are likley to do illigitimate things to the various inputs.
    
            return $errors;
        }
    
        /**
         * Tags which have extra whitespace within them. E.g. [[input: ans1]] are forbidden.
         * @return array of tags.
         */
        public function validation_get_sloppy_tags($text) {
    
            $sloppytags = stack_utils::extract_placeholders_sloppy($text, 'input');
            $sloppytags = array_merge(stack_utils::extract_placeholders_sloppy($text, 'validation'), $sloppytags);
            $sloppytags = array_merge(stack_utils::extract_placeholders_sloppy($text, 'prt'), $sloppytags);
    
            return $sloppytags;
        }
    
        /**
         * Check a form field to ensure it does not contain any placeholders of given types.
         * @param string $fieldname the name of this field. Used in the error messages.
         * @param value $value the value to check.
         * @param array $placeholders types to check for. By default 'input', 'validation' and 'feedback'.
         * @return array of problems (so an empty array means all is well).
         */
        protected function validation_check_no_placeholders($fieldname, $value,
                $placeholders = array('input', 'validation', 'feedback')) {
            $problems = array();
            foreach ($placeholders as $placeholder) {
                if (stack_utils::extract_placeholders($value, 'input')) {
                    $problems[] = stack_string('fieldshouldnotcontainplaceholder',
                            array('field' => $fieldname, 'type' => $placeholder));
                }
            }
            return $problems;
        }
    
        /**
         * Validate the fields for a given PRT
         * @param array $errors the error so far. This array is added to and returned.
         * @param array $fromform the submitted data to validate.
         * @param string $prtname the name of the PRT to validate.
         * @return array the update $errors array.
         */
        protected function validate_prt($errors, $fromform, $prtname, $fixingdollars) {
    
            if (strlen($prtname) > 18 && !isset($fromform[$prtname . 'prtdeleteconfirm'])) {
                $errors['specificfeedback'][] = stack_string('prtnamelength', $prtname);
            }
    
            if (!array_key_exists($prtname . 'feedbackvariables', $fromform)) {
                // This happens when you edit the question text to add more PRTs.
                // The user added a new PRT and did not click "Verify the question
                // text and update the form". We need to fail validation, so the
                // form is re-displayed so that this PRT can be configured.
                $errors[$prtname . 'value'][] = stack_string('prtmustbesetup');
                return $errors;
            }
    
            // Check the fields that belong to the PRT as a whole.
            $inputs = array_keys($this->get_input_names_from_question_text($fromform['questiontext']['text']));
            $errors = $this->validate_cas_keyval($errors, $fromform[$prtname . 'feedbackvariables'],
                    $prtname . 'feedbackvariables', $inputs);
    
            if ($fromform[$prtname . 'value'] < 0) {
                $errors[$prtname . 'value'][] = stack_string('questionvaluepostive');
            }
    
            // Check that answernotes are not duplicated.
            $answernotes = array_merge($fromform[$prtname . 'trueanswernote'], $fromform[$prtname . 'falseanswernote']);
            if (count(array_unique($answernotes)) < count($answernotes)) {
                // Strictly speaking this should not be in the feedback variables.  But there is no general place to put this error.
                $errors[$prtname . 'feedbackvariables'][] = stack_string('answernoteunique');
            }
    
            // Check the nodes.
            $question = null;
            if (property_exists($this, 'question')) {
                $question = $this->question;
            }
            $graph = $this->get_prt_graph($prtname, $question);
            $textformat = null;
            foreach ($graph->get_nodes() as $node) {
                $nodekey = $node->name - 1;
    
                // Check the fields the belong to this node individually.
                $errors = $this->validate_prt_node($errors, $fromform, $prtname, $nodekey, $fixingdollars);
    
                if (is_null($textformat)) {
                    $textformat = $fromform[$prtname . 'truefeedback'][$nodekey]['format'];
                }
                if ($textformat != $fromform[$prtname . 'truefeedback'][$nodekey]['format']) {
                    $errors[$prtname . 'truefeedback[' . $nodekey . ']'][]
                        = stack_string('allnodefeedbackmustusethesameformat');
                }
            }
    
            // Check that the nodes form a directed acyclic graph.
            $roots = $graph->get_roots();
    
            // There should only be a single root. If there is more than one, then we
            // assume that the first one is the intended root, and flat the others as unused.
            array_shift($roots);
            foreach ($roots as $node) {
                $errors[$prtname . 'node[' . ($node->name - 1) . ']'][] = stack_string('nodenotused');
            }
            foreach ($graph->get_broken_cycles() as $backlink => $notused) {
                list($nodename, $direction) = explode('|', $backlink);
                if ($direction == stack_abstract_graph::LEFT) {
                    $field = 'nodewhentrue';
                } else {
                    $field = 'nodewhenfalse';
                }
                $errors[$prtname.$field.'['.($nodename - 1).']'][] = stack_string('nodeloopdetected');
            }
    
            return $errors;
        }
    
        /**
         * Validate the fields for a given PRT node.
         * @param array $errors the error so far. This array is added to and returned.
         * @param array $fromform the submitted data to validate.
         * @param string $prtname the name of the PRT to validate.
         * @param string $nodekey the name of the node to validate.
         * @return array the update $errors array.
         */
        protected function validate_prt_node($errors, $fromform, $prtname, $nodekey, $fixingdollars) {
            $nodegroup = $prtname . 'node[' . $nodekey . ']';
    
            $errors = $this->validate_cas_string($errors, $fromform[$prtname . 'sans'][$nodekey],
                    $nodegroup, $prtname . 'sans' . $nodekey, 'sansrequired');
    
            $errors = $this->validate_cas_string($errors, $fromform[$prtname . 'tans'][$nodekey],
                    $nodegroup, $prtname . 'tans' . $nodekey, 'tansrequired');
    
            $atname = $fromform[$prtname . 'answertest'][$nodekey];
            if (stack_ans_test_controller::required_atoptions($atname)) {
                $opt = trim($fromform[$prtname . 'testoptions'][$nodekey]);
    
                if ('' === trim($opt) && stack_ans_test_controller::required_atoptions($atname) === true) {
                    $errors[$nodegroup][] = stack_string('testoptionsrequired');
    
                } else if (strlen($opt) > 255) {
                    $errors[$nodegroup][] = stack_string('testoptionsinvalid',
                            stack_string('strlengtherror'));
    
                } else {
                    $cs = stack_ast_container::make_from_teacher_source('null', '', new stack_cas_security());
                    $answertest = new stack_ans_test_controller($atname, $cs, $cs);
                    list($valid, $message) = $answertest->validate_atoptions($opt);
                    if (!$valid) {
                        $errors[$nodegroup][] = stack_string('testoptionsinvalid', $message);
                    }
                }
            }
    
            foreach (array('true', 'false') as $branch) {
                $branchgroup = $prtname . 'nodewhen' . $branch . '[' . $nodekey . ']';
    
                $score = $fromform[$prtname . $branch . 'score'][$nodekey];
                if (is_numeric($score)) {
                    if ($score < 0 || $score > 1) {
                        $errors[$branchgroup][] = stack_string('scoreerror');
                    }
                }
    
                $penalty = $fromform[$prtname . $branch . 'penalty'][$nodekey];
                if ('' != $penalty && is_numeric($penalty)) {
                    if ($penalty < 0 || $penalty > 1) {
                        $errors[$branchgroup][] = stack_string('penaltyerror2');
                    }
                }
    
                $answernote = $fromform[$prtname . $branch . 'answernote'][$nodekey];
                if ('' == $answernote) {
                    $errors[$branchgroup][] = stack_string('answernoterequired');
                } else if (strstr($answernote, '|') !== false) {
                    $errors[$branchgroup][] = stack_string('answernote_err');
                    foreach ($fromform[$prtname.$branch.'answernote'] as $key => $strin) {
                        if ('' == trim($strin)) {
                            $interror[$prtname.'nodewhen'.$branch.'['.$key.']'][] = stack_string('answernoterequired');
                        } else if (strstr($strin, '|') !== false) {
                            $nodename = $key + 1;
                            $interror[$prtname.'nodewhen'.$branch.'['.$key.']'][] = stack_string('answernote_err');
                        }
                    }
                } else if (strstr($answernote, ';') !== false || strstr($answernote, ':') !== false) {
                    $errors[$branchgroup][] = stack_string('answernote_err2');
                }
    
                $errors = $this->validate_cas_text($errors, $fromform[$prtname . $branch . 'feedback'][$nodekey]['text'],
                        $prtname . $branch . 'feedback[' . $nodekey . ']', $fixingdollars);
            }
    
            return $errors;
        }
    
        /**
         * This method is needed for validation, and to construct the editing form.
         * @return array of the input names that currently appear in the question text.
         */
        public function get_input_names_from_question_text($questiontext) {
            $ml = new stack_multilang();
            $langs = $ml->languages_used($questiontext);
            if ($langs == array()) {
                return $this->get_input_names_from_question_text_lang($questiontext);
            }
    
            // At this point, all languages are assumed to have the same inputs.
            $lang = reset($langs);
            return($this->get_input_names_from_question_text_lang($ml->filter($questiontext, $lang)));
        }
    
        private function get_input_names_from_question_text_lang($questiontext) {
            $inputs = stack_utils::extract_placeholders($questiontext, 'input');
            $validations = stack_utils::extract_placeholders($questiontext, 'validation');
            $inputnames = array();
    
            $data = data_submitted();
            if ($data) {
                foreach (get_object_vars($data) as $name => $value) {
                    if (preg_match('~(' . stack_utils::VALID_NAME_REGEX . ')modelans~', $name, $matches)) {
                        $inputnames[$matches[1]] = array(0, 0);
                    }
                }
            }
    
            foreach ($inputs as $inputname) {
                if (!array_key_exists($inputname, $inputnames)) {
                    $inputnames[$inputname] = array(0, 0);
                }
                $inputnames[$inputname][self::INPUTS] += 1;
            }
    
            foreach ($validations as $inputname) {
                if (!array_key_exists($inputname, $inputnames)) {
                    $inputnames[$inputname] = array(0, 0);
                }
                $inputnames[$inputname][self::VALIDATIONS] += 1;
            }
    
            return $inputnames;
        }
    
        /**
         * This method is needed for validation, and to construct the editing form.
         * @return array of the PRT names that currently appear in the question
         *      text and specific feedback.
         */
        public function get_prt_names_from_question($questiontext, $specificfeedback) {
            $ml = new stack_multilang();
            $langs = $ml->languages_used($questiontext.$specificfeedback);
            if ($langs == array()) {
                return $this->get_prt_names_from_question_lang($questiontext.$specificfeedback);
            }
    
            // At this point, all languages are assumed to have the same prts.
            $lang = reset($langs);
            return($this->get_prt_names_from_question_lang($ml->filter($questiontext.$specificfeedback, $lang)));
        }
    
        private function get_prt_names_from_question_lang($text) {
            $prts = stack_utils::extract_placeholders($text, 'feedback');
            $prtnames = array();
    
            $data = data_submitted();
            if ($data) {
                foreach (get_object_vars($data) as $name => $value) {
                    if (preg_match('~(' . stack_utils::VALID_NAME_REGEX . ')feedbackvariables~', $name, $matches)) {
                        $prtnames[$matches[1]] = 0;
                    }
                }
            }
    
            foreach ($prts as $name) {
                if (!array_key_exists($name, $prtnames)) {
                    $prtnames[$name] = 0;
                }
                $prtnames[$name] += 1;
            }
            return $prtnames;
        }
    
        /**
         * Get a list of the PRT notes that should be present for a given PRT.
         * @param string $prtname the name of a PRT.
         * @param $question the question itself.
         * @return array list of nodes that should be present in the form definitino for this PRT.
         */
        public function get_prt_graph($prtname, $question) {
            if (array_key_exists($prtname, $this->prtgraph)) {
                return $this->prtgraph[$prtname];
            }
    
            // If the form has been submitted and is being redisplayed, and this is
            // an existing PRT, base things on the submitted data.
            $submitted = optional_param_array($prtname . 'truenextnode', null, PARAM_RAW);
            if ($submitted) {
                $truescoremode  = optional_param_array($prtname . 'truescoremode',  null, PARAM_RAW);
                $truescore      = optional_param_array($prtname . 'truescore',      null, PARAM_RAW);
                $falsenextnode  = optional_param_array($prtname . 'falsenextnode',  null, PARAM_RAW);
                $falsescoremode = optional_param_array($prtname . 'falsescoremode', null, PARAM_RAW);
                $falsescore     = optional_param_array($prtname . 'falsescore',     null, PARAM_RAW);
                $graph = new stack_abstract_graph();
    
                $deletednode = null;
                $lastkey = -1;
                foreach ($submitted as $key => $truenextnode) {
                    if (optional_param($prtname . 'nodedelete' . $key, false, PARAM_BOOL)) {
                        // For deleted nodes, we add them to the tree anyway, and
                        // then remove them again below. We have to do it that way
                        // because we also need to delete links that point to the
                        // deleted node.
                        $deletednode = $key;
                    }
    
                    if ($truenextnode == -1 || !array_key_exists($truenextnode, $submitted)) {
                        $left = null;
                    } else {
                        $left = $truenextnode + 1;
                    }
                    if ($falsenextnode[$key] == -1 || !array_key_exists($falsenextnode[$key], $submitted)) {
                        $right = null;
                    } else {
                        $right = $falsenextnode[$key] + 1;
                    }
                    $ts = $truescore[$key];
                    if (is_numeric($ts)) {
                        $ts = round($ts, 2);
                    }
                    $fs = $falsescore[$key];
                    if (is_numeric($fs)) {
                        $fs = round($fs, 2);
                    }
                    $graph->add_node($key + 1, $left, $right,
                            $truescoremode[$key] . $ts,
                            $falsescoremode[$key] . $fs,
                            '#fgroup_id_' . $prtname . 'node_' . $key);
    
                    $lastkey = max($lastkey, $key);
                }
    
                if (optional_param($prtname . 'nodeadd', false, PARAM_BOOL)) {
                    $graph->add_node($lastkey + 2, null, null, '+0', '-0',
                            '#fgroup_id_' . $prtname . 'node_' . ($lastkey + 1));
                }
    
                if (!is_null($deletednode)) {
                    $graph->remove_node($deletednode + 1);
                }
    
                $graph->layout();
                $this->prtgraph[$prtname] = $graph;
                return $graph;
            }
    
            // Otherwise, if an existing question is being edited, and this is an
            // existing PRT, base things on the existing question definition.
            if (!empty($question->prts[$prtname]->nodes)) {
                $graph = new stack_abstract_graph();
                foreach ($question->prts[$prtname]->nodes as $node) {
                    if ($node->truenextnode == -1) {
                        $left = null;
                    } else {
                        $left = $node->truenextnode + 1;
                    }
                    if ($node->falsenextnode == -1) {
                        $right = null;
                    } else {
                        $right = $node->falsenextnode + 1;
                    }
                    $graph->add_node($node->nodename + 1, $left, $right,
                            $node->truescoremode . $node->truescore,
                            $node->falsescoremode . $node->falsescore,
                            '#fgroup_id_' . $prtname . 'node_' . $node->nodename);
                }
                $graph->layout();
                $this->prtgraph[$prtname] = $graph;
                return $graph;
            }
    
            // Otherwise, it is a new PRT. Just one node.
            $graph = new stack_abstract_graph();
            $graph->add_node('1', null, null, '=1', '=0', '#fgroup_id_' . $prtname . 'node_0');
            $graph->layout();
            $this->prtgraph[$prtname] = $graph;
            return $graph;
        }
    
        /**
         * Helper method to get the list of inputs required by a PRT, given the current
         * state of the form.
         * @param string $prtname the name of a PRT.
         * @param qtype_stack $question
         * @return array list of inputs used by this PRT.
         */
        public function get_inputs_used_by_prt($prtname, $question) {
            // Needed for questions with no inputs, (in particular blank starting questions).
            if (!property_exists($question, 'inputs')) {
                return array();
            }
            if (is_null($question->inputs)) {
                return array();
            }
            $inputs = $question->inputs;
            $inputkeys = array();
            if (is_array($inputs)) {
                foreach ($inputs as $input) {
                    $inputkeys[$input->name] = $input->name;
                }
            } else {
                return array();
            }
    
            // Note in real use we would simply read this from the compiled-cache
            // and would let it find out if need be, but the assumption is that
            // at this moment it is not possible. Basically:
            // $question->get_cached('required')[$prtname].
    
            // TODO fix this. At the moment it only considers the data from the unedited
            // question. We should take into account any changes made since the
            // form was first shown, for example adding or removing nodes, or changing
            // the things they compare. However, it is not critical.
    
            // If we are creating a new question, or if we add a new prt in the
            // question stem, then the PRT will not yet exist, so return an empty array.
            if (is_null($question->prts) || !array_key_exists($prtname, $question->prts)) {
                return array();
            }
            $prt = $question->prts[$prtname];
    
            // Safe to hard-wire the value as 1 here as this PRT is not used for scoreing.
            $prt = new stack_potentialresponse_tree_lite($prt, 1, $question);
    
            // Just do the full compile it does all the checking including feedback.
            $compile['required'] = array();
            try {
                $compile = $prt->compile($inputkeys, [], 0.0, new stack_cas_security(), '/p/0');
            } catch (Exception $e) {
                // Avoids dealing with an error in the PRT definition that a latter part handles.
                return array();
            }
    
            return array_keys($compile['required']);
        }
    }