diff --git a/classes/api/attempt_file.php b/classes/api/attempt_file.php new file mode 100644 index 00000000..b612b6a1 --- /dev/null +++ b/classes/api/attempt_file.php @@ -0,0 +1,58 @@ +. + +namespace qtype_questionpy\api; + +use qtype_questionpy\array_converter\array_converter; +use qtype_questionpy\array_converter\converter_config; + +defined('MOODLE_INTERNAL') || die; + +/** + * A file used in an attempt at a QuestionPy question. + * + * @package qtype_questionpy + * @author Jan Britz + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class attempt_file { + /** @var string */ + public string $name; + + /** @var string|null */ + public ?string $mimetype = null; + + /** @var string $data */ + public string $data; + + /** + * Initializes a new instance. + * + * @param string $name + * @param string $data + * @param string|null $mimetype + */ + public function __construct(string $name, string $data, ?string $mimetype = null) { + $this->name = $name; + $this->data = $data; + $this->mimetype = $mimetype; + } +} + +array_converter::configure(attempt_file::class, function (converter_config $config) { + $config->rename("mimetype", "mime_type"); +}); diff --git a/classes/api/attempt_scored.php b/classes/api/attempt_scored.php index 9c9ad8ef..14459275 100644 --- a/classes/api/attempt_scored.php +++ b/classes/api/attempt_scored.php @@ -49,12 +49,17 @@ class attempt_scored extends attempt { * @param attempt_ui $ui * @param string $scoringstate * @param string $scoringcode + * @param float|null $score + * @param array|null $classification */ - public function __construct(int $variant, attempt_ui $ui, string $scoringstate, string $scoringcode) { + public function __construct(int $variant, attempt_ui $ui, string $scoringstate, string $scoringcode, ?float $score = null, + ?array $classification = null) { parent::__construct($variant, $ui); $this->scoringstate = $scoringstate; $this->scoringcode = $scoringcode; + $this->score = $score; + $this->classification = $classification; } } diff --git a/classes/api/attempt_ui.php b/classes/api/attempt_ui.php index bf40214c..b720e484 100644 --- a/classes/api/attempt_ui.php +++ b/classes/api/attempt_ui.php @@ -31,36 +31,61 @@ */ class attempt_ui { /** @var string */ - public string $content; + public string $formulation; - /** @var array string to string mapping of placeholder names to the values (to be replaced in the content) */ - public array $placeholders = []; + /** @var string|null */ + public ?string $generalfeedback = null; - /** @var string|null specifics TBD */ - public ?string $includeinlinecss = null; + /** @var string|null */ + public ?string $specificfeedback = null; - /** @var string|null specifics TBD */ - public ?string $includecssfile = null; + /** @var string|null */ + public ?string $rightanswer = null; - /** @var string specifics TBD */ - public string $cachecontrol = "private"; + /** @var array string to string mapping of placeholder names to the values (to be replaced in the content) */ + public array $placeholders = []; - /** @var object[] specifics TBD */ + /** @var string[]|null */ + public ?array $cssfiles = null; + + /** @var array specifics TBD */ public array $files = []; + /** @var string specifics TBD */ + public string $cachecontrol = "PRIVATE_CACHE"; + /** * Initializes a new instance. * - * @param string $content + * @param string $formulation + * @param string|null $generalfeedback + * @param string|null $specificfeedback + * @param string|null $rightanswer + * @param array $placeholders + * @param array|null $cssfiles + * @param array $files + * @param string $cachecontrol */ - public function __construct(string $content) { - $this->content = $content; + public function __construct(string $formulation, ?string $generalfeedback = null, ?string $specificfeedback = null, + ?string $rightanswer = null, array $placeholders = [], ?array $cssfiles = null, array $files = [], + string $cachecontrol = "PRIVATE_CACHE") { + $this->formulation = $formulation; + $this->generalfeedback = $generalfeedback; + $this->specificfeedback = $specificfeedback; + $this->rightanswer = $rightanswer; + $this->placeholders = $placeholders; + $this->cssfiles = $cssfiles; + $this->files = $files; + $this->cachecontrol = $cachecontrol; } } array_converter::configure(attempt_ui::class, function (converter_config $config) { $config - ->rename("includeinlinecss", "include_inline_css") - ->rename("includecssfile", "include_css_file") - ->rename("cachecontrol", "cache_control"); + ->rename("generalfeedback", "general_feedback") + ->rename("specificfeedback", "specific_feedback") + ->rename("rightanswer", "right_answer") + ->rename("cssfiles", "css_files") + ->rename("cachecontrol", "cache_control") + ->array_elements("files", attempt_file::class); }); diff --git a/classes/question_ui_metadata_extractor.php b/classes/question_ui_metadata_extractor.php new file mode 100644 index 00000000..33f3abfb --- /dev/null +++ b/classes/question_ui_metadata_extractor.php @@ -0,0 +1,112 @@ +. + +namespace qtype_questionpy; + +use DOMAttr; +use DOMDocument; +use DOMElement; +use DOMXPath; + +/** + * Parses the question UI XML and extracts the metadata. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2023 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_ui_metadata_extractor { + /** @var string XML namespace for XHTML */ + private const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; + /** @var string XML namespace for our custom things */ + private const QPY_NAMESPACE = "http://questionpy.org/ns/question"; + + /** @var DOMDocument $xml */ + private DOMDocument $xml; + + /** @var DOMXPath $xpath */ + private DOMXPath $xpath; + + /** @var question_metadata|null $metadata */ + private ?question_metadata $metadata = null; + + /** + * Parses the given XML and initializes a new {@see question_ui_metadata_extractor} instance. + * + * @param string $xml XML as returned by the QPy Server + */ + public function __construct(string $xml) { + $this->xml = new DOMDocument(); + $this->xml->loadXML($xml); + + $this->xpath = new DOMXPath($this->xml); + $this->xpath->registerNamespace("xhtml", self::XHTML_NAMESPACE); + $this->xpath->registerNamespace("qpy", self::QPY_NAMESPACE); + } + + /** + * Extracts metadata from the question UI. + * + * @return question_metadata + */ + public function extract(): question_metadata { + if (!is_null($this->metadata)) { + return $this->metadata; + } + + $this->metadata = new question_metadata(); + /** @var DOMAttr $attr */ + foreach ($this->xpath->query("//@qpy:correct-response") as $attr) { + /** @var DOMElement $element */ + $element = $attr->ownerElement; + $name = $element->getAttribute("name"); + if (!$name) { + continue; + } + + if (is_null($this->metadata->correctresponse)) { + $this->metadata->correctresponse = []; + } + + if ($element->tagName == "input" && $element->getAttribute("type") == "radio") { + // On radio buttons, we expect the correct option to be marked with correct-response. + $radiovalue = $element->getAttribute("value"); + $this->metadata->correctresponse[$name] = $radiovalue; + } else { + $this->metadata->correctresponse[$name] = $attr->value; + } + } + + /** @var DOMElement $element */ + foreach ( + $this->xpath->query( + "//*[self::xhtml:input or self::xhtml:select or self::xhtml:textarea or self::xhtml:button]" + ) as $element + ) { + $name = $element->getAttribute("name"); + if ($name) { + $this->metadata->expecteddata[$name] = PARAM_RAW; + + if ($element->hasAttribute("required")) { + $this->metadata->requiredfields[] = $name; + } + } + } + + return $this->metadata; + } +} diff --git a/classes/question_ui_renderer.php b/classes/question_ui_renderer.php index df89f6c7..a1800e8c 100644 --- a/classes/question_ui_renderer.php +++ b/classes/question_ui_renderer.php @@ -20,7 +20,6 @@ use DOMAttr; use DOMDocument; use DOMElement; -use DOMException; use DOMNameSpaceNode; use DOMNode; use DOMProcessingInstruction; @@ -39,223 +38,104 @@ */ class question_ui_renderer { /** @var string XML namespace for XHTML */ - public const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; + private const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; /** @var string XML namespace for our custom things */ - public const QPY_NAMESPACE = "http://questionpy.org/ns/question"; + private const QPY_NAMESPACE = "http://questionpy.org/ns/question"; - /** @var DOMDocument $question */ - private DOMDocument $question; + /** @var DOMDocument $xml */ + private DOMDocument $xml; + + /** @var DOMXPath $xpath */ + private DOMXPath $xpath; + + /** @var string|null $html */ + private ?string $html = null; /** @var array $placeholders */ private array $placeholders; - /** @var question_metadata|null $metadata */ - private ?question_metadata $metadata = null; + /** @var question_display_options $options */ + private question_display_options $options; + + /** @var question_attempt $attempt */ + private question_attempt $attempt; /** * Parses the given XML and initializes a new {@see question_ui_renderer} instance. * * @param string $xml XML as returned by the QPy Server * @param array $placeholders string to string mapping of placeholder names to the values - */ - public function __construct(string $xml, array $placeholders) { - $this->question = new DOMDocument(); - $this->question->loadXML($xml); - $this->question->normalizeDocument(); - - $this->placeholders = $placeholders; - } - - /** - * Renders the contents of the `qpy:formulation` element. Throws an exception if there is none. - * - * @param question_attempt $qa * @param question_display_options $options - * @return string - * @throws DOMException - * @throws coding_exception + * @param question_attempt $attempt */ - public function render_formulation(question_attempt $qa, question_display_options $options): string { - $elements = $this->question->getElementsByTagNameNS(self::QPY_NAMESPACE, "formulation"); - if ($elements->length < 1) { - // TODO: Helpful exception. - throw new coding_exception("Question UI XML contains no 'qpy:formulation' element"); - } + public function __construct(string $xml, array $placeholders, question_display_options $options, question_attempt $attempt) { + $this->xml = new DOMDocument(); + $this->xml->preserveWhiteSpace = false; + $this->xml->loadXML($xml); + $this->xml->normalizeDocument(); - return $this->render_part($elements->item(0), $qa, $options); - } + $this->xpath = new DOMXPath($this->xml); + $this->xpath->registerNamespace("xhtml", self::XHTML_NAMESPACE); + $this->xpath->registerNamespace("qpy", self::QPY_NAMESPACE); - /** - * Renders the contents of the `qpy:general-feedback` element or returns null if there is none. - * - * @param question_attempt $qa - * @param question_display_options $options - * @return string|null - * @throws DOMException - * @throws coding_exception - */ - public function render_general_feedback(question_attempt $qa, question_display_options $options): ?string { - $elements = $this->question->getElementsByTagNameNS(self::QPY_NAMESPACE, "general-feedback"); - if ($elements->length < 1) { - return null; - } - - return $this->render_part($elements->item(0), $qa, $options); - } - - /** - * Renders the contents of the `qpy:specific-feedback` element or returns null if there is none. - * - * @param question_attempt $qa - * @param question_display_options $options - * @return string|null - * @throws DOMException - * @throws coding_exception - */ - public function render_specific_feedback(question_attempt $qa, question_display_options $options): ?string { - $elements = $this->question->getElementsByTagNameNS(self::QPY_NAMESPACE, "specific-feedback"); - if ($elements->length < 1) { - return null; - } - - return $this->render_part($elements->item(0), $qa, $options); - } - - /** - * Renders the contents of the `qpy:right-answer` element or returns null if there is none. - * - * @param question_attempt $qa - * @param question_display_options $options - * @return string|null - * @throws DOMException - * @throws coding_exception - */ - public function render_right_answer(question_attempt $qa, question_display_options $options): ?string { - $elements = $this->question->getElementsByTagNameNS(self::QPY_NAMESPACE, "right-answer"); - if ($elements->length < 1) { - return null; - } - - return $this->render_part($elements->item(0), $qa, $options); - } - - /** - * Extracts metadata from the question UI. - * - * @return question_metadata - */ - public function get_metadata(): question_metadata { - if (!$this->metadata) { - $xpath = new DOMXPath($this->question); - $xpath->registerNamespace("xhtml", self::XHTML_NAMESPACE); - $xpath->registerNamespace("qpy", self::QPY_NAMESPACE); - - $this->metadata = new question_metadata(); - /** @var DOMAttr $attr */ - foreach ($xpath->query("/qpy:question/qpy:formulation//@qpy:correct-response") as $attr) { - /** @var DOMElement $element */ - $element = $attr->ownerElement; - $name = $element->getAttribute("name"); - if (!$name) { - continue; - } - - if (is_null($this->metadata->correctresponse)) { - $this->metadata->correctresponse = []; - } - - if ($element->tagName == "input" && $element->getAttribute("type") == "radio") { - // On radio buttons, we expect the correct option to be marked with correct-response. - $radiovalue = $element->getAttribute("value"); - $this->metadata->correctresponse[$name] = $radiovalue; - } else { - $this->metadata->correctresponse[$name] = $attr->value; - } - } - - /** @var DOMElement $element */ - foreach ( - $xpath->query( - "/qpy:question/qpy:formulation - //*[self::xhtml:input or self::xhtml:select or self::xhtml:textarea or self::xhtml:button]" - ) as $element - ) { - $name = $element->getAttribute("name"); - if ($name) { - $this->metadata->expecteddata[$name] = PARAM_RAW; - - if ($element->hasAttribute("required")) { - $this->metadata->requiredfields[] = $name; - } - } - } - } - - return $this->metadata; + $this->placeholders = $placeholders; + $this->options = $options; + $this->attempt = $attempt; } /** - * Applies transformations to the descendants of a given node and returns the resulting HTML. + * Renders the given XML to HTML. * - * @param DOMNode $part - * @param question_attempt $qa - * @param question_display_options $options - * @return string - * @throws DOMException + * @return string rendered html * @throws coding_exception */ - private function render_part(DOMNode $part, question_attempt $qa, question_display_options $options): string { - $newdoc = new DOMDocument(); - $div = $newdoc->appendChild($newdoc->createElementNS(self::XHTML_NAMESPACE, "div")); - foreach ($part->childNodes as $child) { - $div->appendChild($newdoc->importNode($child, true)); + public function render(): string { + if (!is_null($this->html)) { + return $this->html; } - $xpath = new DOMXPath($newdoc); - $xpath->registerNamespace("xhtml", self::XHTML_NAMESPACE); - $xpath->registerNamespace("qpy", self::QPY_NAMESPACE); - $nextseed = mt_rand(); - if ($qa->get_database_id() === null) { + $id = $this->attempt->get_database_id(); + if ($id === null) { throw new coding_exception("question_attempt does not have an id"); } - mt_srand($qa->get_database_id()); + + mt_srand($id); try { - $this->resolve_placeholders($xpath); - $this->hide_unwanted_feedback($xpath, $options); - $this->hide_if_role($xpath, $options); - $this->set_input_values_and_readonly($xpath, $qa, $options); - $this->soften_validation($xpath); - $this->defuse_buttons($xpath); - $this->shuffle_contents($xpath); - $this->add_styles($xpath); - $this->format_floats($xpath); - $this->mangle_ids_and_names($xpath, $qa); - $this->clean_up($xpath); + $this->resolve_placeholders(); + $this->hide_unwanted_feedback(); + $this->hide_if_role(); + $this->set_input_values_and_readonly(); + $this->soften_validation(); + $this->defuse_buttons(); + $this->shuffle_contents(); + $this->add_styles(); + $this->format_floats(); + $this->mangle_ids_and_names(); + $this->clean_up(); } finally { // I'm not sure whether it is strictly necessary to reset the PRNG seed here, but it feels safer. // Resetting it to its original state would be ideal, but that doesn't seem to be possible. mt_srand($nextseed); } - return $newdoc->saveHTML(); + $this->html = $this->xml->saveHTML(); + return $this->html; } /** * Hides elements marked with `qpy:feedback` if the type of feedback is disabled in {@see question_display_options}. * - * @param DOMXPath $xpath - * @param question_display_options $options * @return void */ - private function hide_unwanted_feedback(\DOMXPath $xpath, question_display_options $options): void { + private function hide_unwanted_feedback(): void { /** @var DOMElement $element */ - foreach (iterator_to_array($xpath->query("//*[@qpy:feedback]")) as $element) { + foreach (iterator_to_array($this->xpath->query("//*[@qpy:feedback]")) as $element) { $feedback = $element->getAttributeNS(self::QPY_NAMESPACE, "feedback"); if ( - ($feedback == "general" && !$options->generalfeedback) - || ($feedback == "specific" && !$options->feedback) + ($feedback == "general" && !$this->options->generalfeedback) + || ($feedback == "specific" && !$this->options->feedback) ) { $element->parentNode->removeChild($element); } @@ -267,12 +147,11 @@ private function hide_unwanted_feedback(\DOMXPath $xpath, question_display_optio * * Also replaces `qpy:shuffled-index` elements which are descendants of each child with the new index of the child. * - * @param DOMXPath $xpath * @throws coding_exception */ - private function shuffle_contents(\DOMXPath $xpath): void { + private function shuffle_contents(): void { /** @var DOMElement $element */ - foreach (iterator_to_array($xpath->query("//*[@qpy:shuffle-contents]")) as $element) { + foreach (iterator_to_array($this->xpath->query("//*[@qpy:shuffle-contents]")) as $element) { $element->removeAttributeNS(self::QPY_NAMESPACE, "shuffle-contents"); $newelement = $element->cloneNode(); @@ -294,7 +173,7 @@ private function shuffle_contents(\DOMXPath $xpath): void { if ($child instanceof DOMElement) { $child = array_pop($childelements); $newelement->appendChild($child); - $this->replace_shuffled_indices($xpath, $child, $i++); + $this->replace_shuffled_indices($child, $i++); } else { $newelement->appendChild($child); } @@ -307,18 +186,17 @@ private function shuffle_contents(\DOMXPath $xpath): void { /** * Among the descendants of `$element`, finds `qpy:shuffled-index` elements and replaces them with `$index`. * - * @param DOMXPath $xpath * @param DOMNode $element * @param int $index * @throws coding_exception */ - private function replace_shuffled_indices(DOMXPath $xpath, DOMNode $element, int $index): void { + private function replace_shuffled_indices(DOMNode $element, int $index): void { /** @var DOMElement $indexelement */ - foreach (iterator_to_array($xpath->query(".//qpy:shuffled-index", $element)) as $indexelement) { + foreach (iterator_to_array($this->xpath->query(".//qpy:shuffled-index", $element)) as $indexelement) { // phpcs:ignore Squiz.ControlStructures.ForLoopDeclaration.SpacingAfterSecond for ( $ancestor = $indexelement->parentNode; $ancestor !== null && $ancestor !== $indexelement; - $ancestor = $ancestor->parentNode + $ancestor = $ancestor->parentNode ) { assert($ancestor instanceof DOMElement); if ($ancestor->hasAttributeNS(self::QPY_NAMESPACE, "shuffle-contents")) { @@ -356,14 +234,12 @@ private function replace_shuffled_indices(DOMXPath $xpath, DOMNode $element, int /** * Mangles element IDs and names so that they are unique when multiple questions are shown at once. * - * @param DOMXPath $xpath - * @param question_attempt $qa * @return void */ - private function mangle_ids_and_names(\DOMXPath $xpath, question_attempt $qa): void { + private function mangle_ids_and_names(): void { /** @var DOMAttr $attr */ foreach ( - $xpath->query(" + $this->xpath->query(" //xhtml:*/@id | //xhtml:label/@for | //xhtml:output/@for | //xhtml:input/@list | (//xhtml:button | //xhtml:form | //xhtml:fieldset | //xhtml:iframe | //xhtml:input | //xhtml:object | //xhtml:output | //xhtml:select | //xhtml:textarea | //xhtml:map)/@name | @@ -373,9 +249,9 @@ private function mangle_ids_and_names(\DOMXPath $xpath, question_attempt $qa): v $original = $attr->value; if ($attr->name === "usemap" && utils::str_starts_with($original, "#")) { // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/useMap. - $attr->value = "#" . $qa->get_qt_field_name(substr($original, 1)); + $attr->value = "#" . $this->attempt->get_qt_field_name(substr($original, 1)); } else { - $attr->value = $qa->get_qt_field_name($original); + $attr->value = $this->attempt->get_qt_field_name($original); } } } @@ -388,16 +264,12 @@ private function mangle_ids_and_names(\DOMXPath $xpath, question_attempt $qa): v * * Requires the unmangled name of the element, so must be called _before_ {@see mangle_ids_and_names}. * - * @param DOMXPath $xpath - * @param question_attempt $qa - * @param question_display_options $options * @return void */ - private function set_input_values_and_readonly(DOMXPath $xpath, question_attempt $qa, - question_display_options $options): void { + private function set_input_values_and_readonly(): void { /** @var DOMElement $element */ - foreach ($xpath->query("//xhtml:button | //xhtml:input | //xhtml:select | //xhtml:textarea") as $element) { - if ($options->readonly) { + foreach ($this->xpath->query("//xhtml:button | //xhtml:input | //xhtml:select | //xhtml:textarea") as $element) { + if ($this->options->readonly) { $element->setAttribute("disabled", "disabled"); } @@ -414,7 +286,7 @@ private function set_input_values_and_readonly(DOMXPath $xpath, question_attempt } // Set the last saved value. - $lastvalue = $qa->get_last_qt_var($name); + $lastvalue = $this->attempt->get_last_qt_var($name); if (!is_null($lastvalue)) { if (($type === "checkbox" || $type === "radio") && $element->getAttribute("value") === $lastvalue) { $element->setAttribute("checked", "checked"); @@ -438,18 +310,20 @@ private function set_input_values_and_readonly(DOMXPath $xpath, question_attempt /** * Removes remaining QuestionPy elements and attributes as well as comments and xmlns declarations. * - * @param DOMXPath $xpath * @return void */ - private function clean_up(DOMXPath $xpath): void { + private function clean_up(): void { /** @var DOMNode|DOMNameSpaceNode $node */ - foreach (iterator_to_array($xpath->query("//qpy:* | //@qpy:* | //comment() | //namespace::*")) as $node) { + foreach (iterator_to_array($this->xpath->query("//qpy:* | //@qpy:* | //comment() | //namespace::*")) as $node) { if ($node instanceof DOMAttr || $node instanceof DOMNameSpaceNode) { $node->parentNode->removeAttributeNS($node->namespaceURI, $node->localName); } else { $node->parentNode->removeChild($node); } } + /** @var DOMNode $root */ + $root = $this->xpath->document->documentElement; + $root->removeAttributeNS(self::XHTML_NAMESPACE, ""); } /** @@ -458,12 +332,11 @@ private function clean_up(DOMXPath $xpath): void { * Since QPy transformations should not be applied to the content of the placeholders, this method should be called * last. * - * @param DOMXPath $xpath * @return void */ - private function resolve_placeholders(DOMXPath $xpath): void { + private function resolve_placeholders(): void { /** @var DOMProcessingInstruction $pi */ - foreach (iterator_to_array($xpath->query("//processing-instruction('p')")) as $pi) { + foreach (iterator_to_array($this->xpath->query("//processing-instruction('p')")) as $pi) { $parts = preg_split("/\s+/", trim($pi->data)); $key = $parts[0]; $cleanoption = $parts[1] ?? "clean"; @@ -474,10 +347,10 @@ private function resolve_placeholders(DOMXPath $xpath): void { $rawvalue = $this->placeholders[$key]; if (strcasecmp($cleanoption, "clean") == 0) { // Allow (X)HTML, but clean using Moodle's clean_text to prevent XSS. - $element = $xpath->document->createDocumentFragment(); + $element = $this->xpath->document->createDocumentFragment(); $element->appendXML(clean_text($rawvalue)); } else if (strcasecmp($cleanoption, "noclean") == 0) { - $element = $xpath->document->createDocumentFragment(); + $element = $this->xpath->document->createDocumentFragment(); $element->appendXML($rawvalue); } else { if (strcasecmp($cleanoption, "plain") != 0) { @@ -498,43 +371,42 @@ private function resolve_placeholders(DOMXPath $xpath): void { * * The standard attributes are replaced with `data-qpy_X`, which are then evaluated in JS. * - * @param DOMXPath $xpath * @return void */ - private function soften_validation(DOMXPath $xpath): void { + private function soften_validation(): void { /** @var DOMElement $element */ - foreach ($xpath->query("//xhtml:input[@pattern]") as $element) { + foreach ($this->xpath->query("//xhtml:input[@pattern]") as $element) { $pattern = $element->getAttribute("pattern"); $element->removeAttribute("pattern"); $element->setAttribute("data-qpy_pattern", $pattern); } - foreach ($xpath->query("(//xhtml:input | //xhtml:select | //xhtml:textarea)[@required]") as $element) { + foreach ($this->xpath->query("(//xhtml:input | //xhtml:select | //xhtml:textarea)[@required]") as $element) { $element->removeAttribute("required"); $element->setAttribute("data-qpy_required", "data-qpy_required"); $element->setAttribute("aria-required", "true"); } - foreach ($xpath->query("(//xhtml:input | //xhtml:textarea)[@minlength]") as $element) { + foreach ($this->xpath->query("(//xhtml:input | //xhtml:textarea)[@minlength]") as $element) { $minlength = $element->getAttribute("minlength"); $element->removeAttribute("minlength"); $element->setAttribute("data-qpy_minlength", $minlength); } - foreach ($xpath->query("(//xhtml:input | //xhtml:textarea)[@maxlength]") as $element) { + foreach ($this->xpath->query("(//xhtml:input | //xhtml:textarea)[@maxlength]") as $element) { $maxlength = $element->getAttribute("maxlength"); $element->removeAttribute("maxlength"); $element->setAttribute("data-qpy_maxlength", $maxlength); } - foreach ($xpath->query("//xhtml:input[@min]") as $element) { + foreach ($this->xpath->query("//xhtml:input[@min]") as $element) { $min = $element->getAttribute("min"); $element->removeAttribute("min"); $element->setAttribute("data-qpy_min", $min); $element->setAttribute("aria-valuemin", $min); } - foreach ($xpath->query("//xhtml:input[@max]") as $element) { + foreach ($this->xpath->query("//xhtml:input[@max]") as $element) { $max = $element->getAttribute("max"); $element->removeAttribute("max"); $element->setAttribute("data-qpy_max", $max); @@ -545,13 +417,12 @@ private function soften_validation(DOMXPath $xpath): void { /** * Adds CSS classes to various elements to style them similarly to Moodle's own question types. * - * @param DOMXPath $xpath * @return void */ - private function add_styles(DOMXPath $xpath): void { + private function add_styles(): void { /** @var DOMElement $element */ foreach ( - $xpath->query(" + $this->xpath->query(" //xhtml:input[@type != 'checkbox' and @type != 'radio' and @type != 'button' and @type != 'submit' and @type != 'reset'] | //xhtml:select | //xhtml:textarea @@ -561,13 +432,13 @@ private function add_styles(DOMXPath $xpath): void { } foreach ( - $xpath->query("//xhtml:input[@type = 'button' or @type = 'submit' or @type = 'reset'] + $this->xpath->query("//xhtml:input[@type = 'button' or @type = 'submit' or @type = 'reset'] | //xhtml:button") as $element ) { $this->add_class_names($element, "btn", "btn-primary", "qpy-input"); } - foreach ($xpath->query("//xhtml:input[@type = 'checkbox' or @type = 'radio']") as $element) { + foreach ($this->xpath->query("//xhtml:input[@type = 'checkbox' or @type = 'radio']") as $element) { $this->add_class_names($element, "qpy-input"); } } @@ -578,12 +449,11 @@ private function add_styles(DOMXPath $xpath): void { * When multiple questions are shown on the same page, they share a form, so one question must not reset or submit * the entire form. * - * @param DOMXPath $xpath * @return void */ - private function defuse_buttons(DOMXPath $xpath): void { + private function defuse_buttons(): void { /** @var DOMElement $element */ - foreach ($xpath->query("(//xhtml:input | //xhtml:button)[@type = 'submit' or @type = 'reset']") as $element) { + foreach ($this->xpath->query("(//xhtml:input | //xhtml:button)[@type = 'submit' or @type = 'reset']") as $element) { $element->setAttribute("type", "button"); } } @@ -598,17 +468,15 @@ private function defuse_buttons(DOMXPath $xpath): void { * - The user is a scorer if they have the `mod/quiz:grade` capability. * - The user is a developer if they are a teacher AND debugging is turned on. (As per {@see debugging}.) * - * @param DOMXPath $xpath - * @param question_display_options $options * @throws coding_exception */ - public function hide_if_role(DOMXPath $xpath, question_display_options $options): void { + private function hide_if_role(): void { /** @var DOMAttr $attr */ - foreach (iterator_to_array($xpath->query("//@qpy:if-role")) as $attr) { + foreach (iterator_to_array($this->xpath->query("//@qpy:if-role")) as $attr) { $allowedroles = preg_split("/[\s|]+/", $attr->value, -1, PREG_SPLIT_NO_EMPTY); - $isteacher = has_capability("mod/quiz:viewreports", $options->context); - $isscorer = has_capability("mod/quiz:grade", $options->context); + $isteacher = has_capability("mod/quiz:viewreports", $this->options->context); + $isscorer = has_capability("mod/quiz:grade", $this->options->context); $isdeveloper = $isteacher && debugging(); if ( @@ -625,13 +493,12 @@ public function hide_if_role(DOMXPath $xpath, question_display_options $options) /** * Handles `qpy:format-float`. Uses {@see format_float} and optionally adds thousands separators. * - * @param DOMXPath $xpath * @return void * @throws coding_exception */ - private function format_floats(DOMXPath $xpath): void { + private function format_floats(): void { /** @var DOMElement $element */ - foreach (iterator_to_array($xpath->query("//qpy:format-float")) as $element) { + foreach (iterator_to_array($this->xpath->query("//qpy:format-float")) as $element) { $float = floatval($element->textContent); $precision = intval($element->hasAttribute("precision") ? $element->getAttribute("precision") : -1); diff --git a/question.php b/question.php index fbc34854..0970d40b 100644 --- a/question.php +++ b/question.php @@ -23,7 +23,8 @@ */ use qtype_questionpy\api\api; -use qtype_questionpy\question_ui_renderer; +use qtype_questionpy\api\attempt_ui; +use qtype_questionpy\question_ui_metadata_extractor; /** * Represents a QuestionPy question. @@ -52,8 +53,10 @@ class qtype_questionpy_question extends question_graded_automatically_with_count public string $attemptstate; /** @var string|null */ public ?string $scoringstate; - /** @var question_ui_renderer */ - public question_ui_renderer $ui; + /** @var attempt_ui */ + public attempt_ui $ui; + /** @var question_ui_metadata_extractor $metadata */ + public question_ui_metadata_extractor $metadata; /** * Initialize a new question. Called from {@see qtype_questionpy::make_question_instance()}. @@ -70,6 +73,16 @@ public function __construct(string $packagehash, string $questionstate, ?stored_ $this->packagefile = $packagefile; } + /** + * Updates the ui property and metadata extractor. + * + * @param attempt_ui $ui + */ + private function update_ui(attempt_ui $ui): void { + $this->ui = $ui; + $this->metadata = new question_ui_metadata_extractor($this->ui->formulation); + } + /** * Start a new attempt at this question, storing any information that will * be needed later in the step. @@ -93,8 +106,7 @@ public function start_attempt(question_attempt_step $step, $variant): void { $this->attemptstate = $attempt->attemptstate; $step->set_qt_var(self::QT_VAR_ATTEMPT_STATE, $attempt->attemptstate); $this->scoringstate = null; - - $this->ui = new question_ui_renderer($attempt->ui->content, $attempt->ui->placeholders); + $this->update_ui($attempt->ui); } /** @@ -128,7 +140,7 @@ public function apply_attempt_state(question_attempt_step $step) { $this->attemptstate, $this->scoringstate ); - $this->ui = new question_ui_renderer($attempt->ui->content, $attempt->ui->placeholders); + $this->update_ui($attempt->ui); } /** @@ -143,7 +155,7 @@ public function apply_attempt_state(question_attempt_step $step) { * meaning take all the raw submitted data belonging to this question. */ public function get_expected_data(): array { - return $this->ui->get_metadata()->expecteddata; + return $this->metadata->extract()->expecteddata; } /** @@ -155,7 +167,7 @@ public function get_expected_data(): array { * @return array|null parameter name => value. */ public function get_correct_response(): ?array { - return $this->ui->get_metadata()->correctresponse; + return $this->metadata->extract()->correctresponse; } /** @@ -168,7 +180,7 @@ public function get_correct_response(): ?array { * @return bool whether this response is a complete answer to this question. */ public function is_complete_response(array $response): bool { - foreach ($this->ui->get_metadata()->requiredfields as $requiredfield) { + foreach ($this->metadata->extract()->requiredfields as $requiredfield) { if (!isset($response[$requiredfield]) || $response[$requiredfield] === "") { return false; } @@ -230,7 +242,7 @@ public function grade_response(array $response): array { $this->scoringstate, $response ); - $this->ui = new question_ui_renderer($attemptscored->ui->content, $attemptscored->ui->placeholders); + $this->update_ui($attemptscored->ui); // TODO: Persist scoring state. We need to set a qtvar, but we don't have access to the pending step here. $this->scoringstate = $attemptscored->scoringstate; switch ($attemptscored->scoringcode) { diff --git a/renderer.php b/renderer.php index b198cd48..bd8344be 100644 --- a/renderer.php +++ b/renderer.php @@ -22,6 +22,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use qtype_questionpy\question_ui_renderer; + /** * Generates the output for QuestionPy questions. * @@ -42,7 +44,7 @@ public function head_code(question_attempt $qa) { /** * Generate the display of the formulation part of the question. This is the - * area that contains the quetsion text, and the controls for students to + * area that contains the question text, and the controls for students to * input their answers. Some question types also embed bits of feedback, for * example ticks and crosses, in this area. * @@ -54,7 +56,8 @@ public function head_code(question_attempt $qa) { public function formulation_and_controls(question_attempt $qa, question_display_options $options): string { $question = $qa->get_question(); assert($question instanceof qtype_questionpy_question); - return $question->ui->render_formulation($qa, $options); + $renderer = new question_ui_renderer($question->ui->formulation, $question->ui->placeholders, $options, $qa); + return $renderer->render(); } /** @@ -68,7 +71,6 @@ public function formulation_and_controls(question_attempt $qa, question_display_ * @param question_display_options $options controls what should and should not be displayed. * @return string HTML fragment. * @throws coding_exception - * @throws DOMException */ public function feedback(question_attempt $qa, question_display_options $options): string { $question = $qa->get_question(); @@ -77,10 +79,11 @@ public function feedback(question_attempt $qa, question_display_options $options $output = ''; $hint = null; - if ($options->feedback) { + if ($options->feedback && !is_null($question->ui->specificfeedback)) { + $renderer = new question_ui_renderer($question->ui->specificfeedback, $question->ui->placeholders, $options, $qa); $output .= html_writer::nonempty_tag( 'div', - $question->ui->render_specific_feedback($qa, $options) ?? "", + $renderer->render(), ['class' => 'specificfeedback'] ); $hint = $qa->get_applicable_hint(); @@ -94,18 +97,20 @@ public function feedback(question_attempt $qa, question_display_options $options $output .= $this->hint($qa, $hint); } - if ($options->generalfeedback) { + if ($options->generalfeedback && !is_null($question->ui->generalfeedback)) { + $renderer = new question_ui_renderer($question->ui->generalfeedback, $question->ui->placeholders, $options, $qa); $output .= html_writer::nonempty_tag( 'div', - $question->ui->render_general_feedback($qa, $options) ?? "", + $renderer->render(), ['class' => 'generalfeedback'] ); } - if ($options->rightanswer) { + if ($options->rightanswer && !is_null($question->ui->rightanswer)) { + $renderer = new question_ui_renderer($question->ui->rightanswer, $question->ui->placeholders, $options, $qa); $output .= html_writer::nonempty_tag( 'div', - $question->ui->render_right_answer($qa, $options) ?? "", + $renderer->render(), ['class' => 'rightanswer'] ); } diff --git a/tests/question_ui_metadata_extractor_test.php b/tests/question_ui_metadata_extractor_test.php new file mode 100644 index 00000000..3fae7cba --- /dev/null +++ b/tests/question_ui_metadata_extractor_test.php @@ -0,0 +1,54 @@ +. + +namespace qtype_questionpy; + +/** + * Unit tests for {@see question_ui_metadata_extractor}. + * + * @package qtype_questionpy + * @author Maximilian Haye + * @copyright 2023 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_ui_metadata_extractor_test extends \advanced_testcase { + /** + * Tests that metadata is correctly extracted from the UI's input elements. + * + * @covers \qtype_questionpy\question_ui_metadata_extractor + * @covers \qtype_questionpy\question_metadata + */ + public function test_should_extract_correct_metadata() { + $input = file_get_contents(__DIR__ . "/question_uis/metadata.xhtml"); + + $metadata = new question_ui_metadata_extractor($input); + + $this->assertEquals(new question_metadata([ + "my_number" => "42", + "my_select" => "1", + "my_radio" => "2", + "my_text" => "Lorem ipsum dolor sit amet.", + ], [ + "my_number" => PARAM_RAW, + "my_select" => PARAM_RAW, + "my_radio" => PARAM_RAW, + "my_text" => PARAM_RAW, + "my_button" => PARAM_RAW, + "only_lowercase_letters" => PARAM_RAW, + "between_5_and_10_chars" => PARAM_RAW, + ], ["my_number"]), $metadata->extract()); + } +} diff --git a/tests/question_ui_renderer_test.php b/tests/question_ui_renderer_test.php index 37d62fec..648ac5cc 100644 --- a/tests/question_ui_renderer_test.php +++ b/tests/question_ui_renderer_test.php @@ -17,7 +17,7 @@ namespace qtype_questionpy; use coding_exception; -use DOMException; +use DOMDocument; /** * Unit tests for {@see question_ui_renderer}. @@ -29,56 +29,47 @@ */ class question_ui_renderer_test extends \advanced_testcase { /** - * Tests that metadata is correctly extracted from the UI's input elements. + * Asserts that two HTML strings are equal. * - * @covers \qtype_questionpy\question_ui_renderer::get_metadata - * @covers \qtype_questionpy\question_metadata + * @param string $expectedhtml + * @param string $actualhtml */ - public function test_should_extract_correct_metadata() { - $input = file_get_contents(__DIR__ . "/question_uis/metadata.xhtml"); - - $ui = new question_ui_renderer($input, []); - $metadata = $ui->get_metadata(); - - $this->assertEquals(new question_metadata([ - "my_number" => "42", - "my_select" => "1", - "my_radio" => "2", - "my_text" => "Lorem ipsum dolor sit amet.", - ], [ - "my_number" => PARAM_RAW, - "my_select" => PARAM_RAW, - "my_radio" => PARAM_RAW, - "my_text" => PARAM_RAW, - "my_button" => PARAM_RAW, - "only_lowercase_letters" => PARAM_RAW, - "between_5_and_10_chars" => PARAM_RAW, - ], ["my_number"]), $metadata); + private function assert_html_string_equals_html_string(string $expectedhtml, string $actualhtml): void { + $actualxml = "
{$actualhtml}
"; + $expectedxml = "
{$expectedhtml}
"; + + $actual = new DOMDocument(); + $actual->preserveWhiteSpace = false; + $actual->loadXML($actualxml); + + $expected = new DOMDocument(); + $expected->preserveWhiteSpace = false; + $expected->loadXML($expectedxml); + + $this->assertEquals($expected, $actual); } /** * Tests that inline feedback is hidden when the {@see \question_display_options} say so. * * @throws coding_exception - * @throws DOMException * @covers \qtype_questionpy\question_ui_renderer */ public function test_should_hide_inline_feedback() { $input = file_get_contents(__DIR__ . "/question_uis/feedbacks.xhtml"); - $ui = new question_ui_renderer($input, []); - $qa = $this->createStub(\question_attempt::class); $qa->method("get_database_id") ->willReturn(mt_rand()); $opts = new \question_display_options(); $opts->hide_all_feedback(); - $result = $ui->render_formulation($qa, $opts); + $ui = new question_ui_renderer($input, [], $opts, $qa); + $result = $ui->render(); - $this->assertXmlStringEqualsXmlString(<< - No feedback + $this->assert_html_string_equals_html_string(<< + No feedback EXPECTED, $result); } @@ -87,150 +78,37 @@ public function test_should_hide_inline_feedback() { * Tests that inline feedback is shown when the {@see \question_display_options} say so. * * @throws coding_exception - * @throws DOMException * @covers \qtype_questionpy\question_ui_renderer */ public function test_should_show_inline_feedback() { $input = file_get_contents(__DIR__ . "/question_uis/feedbacks.xhtml"); - $ui = new question_ui_renderer($input, []); - $qa = $this->createStub(\question_attempt::class); $qa->method("get_database_id") ->willReturn(mt_rand()); $opts = new \question_display_options(); - $result = $ui->render_formulation($qa, $opts); + $ui = new question_ui_renderer($input, [], $opts, $qa); + $result = $ui->render(); - $this->assertXmlStringEqualsXmlString(<< - No feedback - General feedback - Specific feedback + $this->assert_html_string_equals_html_string(<< + No feedback + General feedback + Specific feedback EXPECTED, $result); } - /** - * Tests that general feedback is extracted correctly. - * - * @throws coding_exception - * @throws DOMException - * @covers \qtype_questionpy\question_ui_renderer::render_general_feedback - */ - public function test_should_render_general_feedback_part_when_present() { - $input = file_get_contents(__DIR__ . "/question_uis/all-parts.xhtml"); - - $ui = new question_ui_renderer($input, []); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); - - $result = $ui->render_general_feedback($qa, new \question_display_options()); - - $this->assertXmlStringEqualsXmlString( - '
General feedback part
', - $result - ); - } - - /** - * Tests that specific feedback is extracted correctly. - * - * @throws coding_exception - * @throws DOMException - * @covers \qtype_questionpy\question_ui_renderer::render_specific_feedback - */ - public function test_should_render_specific_feedback_part_when_present() { - $input = file_get_contents(__DIR__ . "/question_uis/all-parts.xhtml"); - - $ui = new question_ui_renderer($input, []); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); - - $result = $ui->render_specific_feedback($qa, new \question_display_options()); - - $this->assertXmlStringEqualsXmlString( - '
Specific feedback part
', - $result - ); - } - - /** - * Tests that the right answer explanation is extracted correctly. - * - * @throws coding_exception - * @throws DOMException - * @covers \qtype_questionpy\question_ui_renderer::render_right_answer - */ - public function test_should_render_right_answer_part_when_present() { - $input = file_get_contents(__DIR__ . "/question_uis/all-parts.xhtml"); - - $ui = new question_ui_renderer($input, []); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); - - $result = $ui->render_right_answer($qa, new \question_display_options()); - - $this->assertXmlStringEqualsXmlString( - '
Right answer part
', - $result - ); - } - - /** - * Tests that `null` is returned when any of the optional parts of the XML are missing. - * - * @throws coding_exception - * @throws DOMException - * @covers \qtype_questionpy\question_ui_renderer::render_general_feedback - * @covers \qtype_questionpy\question_ui_renderer::render_specific_feedback - * @covers \qtype_questionpy\question_ui_renderer::render_right_answer - */ - public function test_should_return_null_when_optional_part_is_missing() { - $input = file_get_contents(__DIR__ . "/question_uis/no-parts.xhtml"); - - $ui = new question_ui_renderer($input, []); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); - - $this->assertNull($ui->render_general_feedback($qa, new \question_display_options())); - $this->assertNull($ui->render_specific_feedback($qa, new \question_display_options())); - $this->assertNull($ui->render_right_answer($qa, new \question_display_options())); - } - - /** - * Tests that an exception is thrown when the question formulation is missing. - * - * @covers \qtype_questionpy\question_ui_renderer::render_formulation - * @throws DOMException - */ - public function test_should_throw_when_formulation_is_missing() { - $input = file_get_contents(__DIR__ . "/question_uis/no-parts.xhtml"); - - $ui = new question_ui_renderer($input, []); - $qa = $this->createStub(\question_attempt::class); - $qa->method("get_database_id") - ->willReturn(mt_rand()); - - $this->expectException(coding_exception::class); - $ui->render_formulation($qa, new \question_display_options()); - } - /** * Tests that `name` attributes in most elements are mangled correctly. * * @throws coding_exception - * @throws DOMException * @covers \qtype_questionpy\question_ui_renderer */ public function test_should_mangle_names() { $input = file_get_contents(__DIR__ . "/question_uis/ids_and_names.xhtml"); - $ui = new question_ui_renderer($input, []); $qa = $this->createStub(\question_attempt::class); $qa->method("get_database_id") ->willReturn(mt_rand()); @@ -239,30 +117,29 @@ public function test_should_mangle_names() { return "mangled:$name"; }); - $result = $ui->render_formulation($qa, new \question_display_options()); - - $this->assertXmlStringEqualsXmlString(<< -
- - - - - - - One - Two -