From 21ececff56bb1ef4bc368fcdf53bc359bd69e757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Marsza=C5=82?= Date: Fri, 29 Nov 2024 17:30:21 +0100 Subject: [PATCH 1/6] feat: add getResponseValidityConstraint --- qtism/data/AssessmentItem.php | 29 +++ qtism/data/ExtendedAssessmentItemRef.php | 37 ++++ qtism/data/IAssessmentItem.php | 11 ++ .../interactions/AssociateInteraction.php | 24 +++ .../interactions/ChoiceInteraction.php | 10 + .../interactions/ExtendedTextInteraction.php | 17 ++ .../interactions/GapMatchInteraction.php | 23 +++ .../GraphicAssociateInteraction.php | 23 +++ .../GraphicGapMatchInteraction.php | 23 +++ .../interactions/GraphicOrderInteraction.php | 10 + .../interactions/HotspotInteraction.php | 10 + .../interactions/HottextInteraction.php | 10 + .../interactions/InlineChoiceInteraction.php | 10 + .../data/content/interactions/Interaction.php | 15 ++ .../content/interactions/MatchInteraction.php | 23 +++ .../content/interactions/OrderInteraction.php | 10 + .../PositionObjectInteraction.php | 10 + .../interactions/SelectPointInteraction.php | 10 + .../interactions/TextEntryInteraction.php | 6 + .../state/AssociationValidityConstraint.php | 187 ++++++++++++++++++ ...ssociationValidityConstraintCollection.php | 47 +++++ .../ResponseValidityConstraintCollection.php | 47 +++++ qtism/data/storage/xml/XmlCompactDocument.php | 1 + .../ExtendedAssessmentItemRefMarshaller.php | 10 + qtism/runtime/tests/AssessmentItemSession.php | 31 ++- 25 files changed, 632 insertions(+), 2 deletions(-) create mode 100644 qtism/data/state/AssociationValidityConstraint.php create mode 100644 qtism/data/state/AssociationValidityConstraintCollection.php create mode 100644 qtism/data/state/ResponseValidityConstraintCollection.php diff --git a/qtism/data/AssessmentItem.php b/qtism/data/AssessmentItem.php index aec4ed8ff..6b79a8cd2 100644 --- a/qtism/data/AssessmentItem.php +++ b/qtism/data/AssessmentItem.php @@ -32,6 +32,7 @@ use qtism\data\processing\TemplateProcessing; use qtism\data\state\OutcomeDeclarationCollection; use qtism\data\state\ResponseDeclarationCollection; +use qtism\data\state\ResponseValidityConstraintCollection; use qtism\data\state\TemplateDeclarationCollection; use SplObjectStorage; @@ -658,6 +659,34 @@ public function getModalFeedbacks() return $this->modalFeedbacks; } + public function getResponseValidityConstraints(): ResponseValidityConstraintCollection + { + $classNames = [ + 'choiceInteraction', + 'orderInteraction', + 'associateInteraction', + 'matchInteraction', + 'inlineChoiceInteraction', + 'textEntryInteraction', + 'extendedTextInteraction', + 'hottextInteraction', + 'hotspotInteraction', + 'selectPointInteraction', + 'graphicOrderInteraction', + 'graphicAssociateInteraction', + 'positionObjectInteraction', + 'gapMatchInteraction', + 'graphicGapMatchInteraction', + ]; + + $responseValidityConstraints = new ResponseValidityConstraintCollection(); + foreach ($this->getComponentsByClassName($classNames) as $component) { + $responseValidityConstraints[] = $component->getResponseValidityConstraint(); + } + + return $responseValidityConstraints; + } + /** * @return string */ diff --git a/qtism/data/ExtendedAssessmentItemRef.php b/qtism/data/ExtendedAssessmentItemRef.php index 13a0867f8..ab70248c5 100644 --- a/qtism/data/ExtendedAssessmentItemRef.php +++ b/qtism/data/ExtendedAssessmentItemRef.php @@ -30,6 +30,8 @@ use qtism\data\state\OutcomeDeclarationCollection; use qtism\data\state\ResponseDeclaration; use qtism\data\state\ResponseDeclarationCollection; +use qtism\data\state\ResponseValidityConstraint; +use qtism\data\state\ResponseValidityConstraintCollection; /** * The ExtendedAssessmentItemRef class is an extended representation of the QTI assessmentItemRef class. @@ -93,6 +95,7 @@ public function __construct($identifier, $href, IdentifierCollection $categories $this->setOutcomeDeclarations(new OutcomeDeclarationCollection()); $this->setResponseDeclarations(new ResponseDeclarationCollection()); + $this->setResponseValidityConstraints(new ResponseValidityConstraintCollection()); } /** @@ -298,4 +301,38 @@ public function getComponents() return new QtiComponentCollection($components); } + + /** + * Get the response validity constraints related to the item content. + */ + public function getResponseValidityConstraints(): ResponseValidityConstraintCollection + { + return $this->responseValidityConstraints; + } + + /** + * Add a response validity constraint related to item content. + */ + public function addResponseValidityConstraint(ResponseValidityConstraint $responseValidityConstraint): void + { + $this->getResponseValidityConstraints()->attach($responseValidityConstraint); + } + + /** + * Remove a response validity constraint related to item content. + */ + public function removeResponseValidityConstraint(ResponseValidityConstraint $responseValidityConstraint): void + { + $this->getResponseValidityConstraints()->detach($responseValidityConstraint); + } + + /** + * Set the response validity constraints related to the item content. + */ + public function setResponseValidityConstraints(ResponseValidityConstraintCollection $responseValidityConstraints): void + { + $this->responseValidityConstraints = $responseValidityConstraints; + } + + } diff --git a/qtism/data/IAssessmentItem.php b/qtism/data/IAssessmentItem.php index b4369cb35..247e418a7 100644 --- a/qtism/data/IAssessmentItem.php +++ b/qtism/data/IAssessmentItem.php @@ -27,6 +27,7 @@ use qtism\data\processing\ResponseProcessing; use qtism\data\state\OutcomeDeclarationCollection; use qtism\data\state\ResponseDeclarationCollection; +use qtism\data\state\ResponseValidityConstraintCollection; /** * Any clas that claims to represent An AssessmentItem must implement this interface. @@ -104,4 +105,14 @@ public function getResponseProcessing(); * @param ResponseProcessing $responseProcessing A ResponseProcessing object or null if no associated response processing. */ public function setResponseProcessing(ResponseProcessing $responseProcessing = null); + + /** + * Get the ResponseValidityConstraintCollection object. + * + * The ResponseValidityConstraint objects returned describes how the responses provided to make + * an attempt on the item should be validated. + * + * @return ResponseValidityConstraintCollection + */ + public function getResponseValidityConstraints(): ResponseValidityConstraintCollection; } diff --git a/qtism/data/content/interactions/AssociateInteraction.php b/qtism/data/content/interactions/AssociateInteraction.php index 71858d357..7c2d84570 100644 --- a/qtism/data/content/interactions/AssociateInteraction.php +++ b/qtism/data/content/interactions/AssociateInteraction.php @@ -25,6 +25,8 @@ use InvalidArgumentException; use qtism\data\QtiComponentCollection; +use qtism\data\state\AssociationValidityConstraint; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -238,6 +240,28 @@ public function getSimpleAssociableChoices() return $this->simpleAssociableChoices; } + public function getResponseValidityConstraint(): ResponseValidityConstraint + { + $responseValidityConstraint = new ResponseValidityConstraint( + $this->getResponseIdentifier(), + $this->getMinAssociations(), + $this->getMaxAssociations() + ); + + $simpleAssociableChoices = $this->getComponentsByClassName('simpleAssociableChoice'); + foreach ($simpleAssociableChoices as $simpleAssociableChoice) { + $responseValidityConstraint->addAssociationValidityConstraint( + new AssociationValidityConstraint( + $simpleAssociableChoice->getIdentifier(), + $simpleAssociableChoice->getMatchMin(), + $simpleAssociableChoice->getMatchMax() + ) + ); + } + + return $responseValidityConstraint; + } + /** * @return QtiComponentCollection */ diff --git a/qtism/data/content/interactions/ChoiceInteraction.php b/qtism/data/content/interactions/ChoiceInteraction.php index 67d74380c..b20f18236 100644 --- a/qtism/data/content/interactions/ChoiceInteraction.php +++ b/qtism/data/content/interactions/ChoiceInteraction.php @@ -25,6 +25,7 @@ use InvalidArgumentException; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -249,6 +250,15 @@ public function getOrientation() return $this->orientation; } + public function getResponseValidityConstraint(): ResponseValidityConstraint + { + return new ResponseValidityConstraint( + $this->getResponseIdentifier(), + $this->getMinChoices(), + $this->getMaxChoices() + ); + } + /** * @return string */ diff --git a/qtism/data/content/interactions/ExtendedTextInteraction.php b/qtism/data/content/interactions/ExtendedTextInteraction.php index c4ec2c563..2aa922519 100644 --- a/qtism/data/content/interactions/ExtendedTextInteraction.php +++ b/qtism/data/content/interactions/ExtendedTextInteraction.php @@ -26,6 +26,7 @@ use InvalidArgumentException; use qtism\common\utils\Format; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -495,6 +496,22 @@ public function getFormat() return $this->format; } + public function getResponseValidityConstraint(): ResponseValidityConstraint + { + return new ResponseValidityConstraint( + $this->getResponseIdentifier(), + $this->getMinStrings(), + ($this->hasMaxStrings() === false) ? 0 : $this->getMaxStrings(), + $this->getPatternMask(), + [ + 'qtiClassName' => $this->getQtiClassName(), + 'options' => [ + 'format' => $this->getFormat(), + ], + ] + ); + } + /** * @return QtiComponentCollection */ diff --git a/qtism/data/content/interactions/GapMatchInteraction.php b/qtism/data/content/interactions/GapMatchInteraction.php index 9c4c28d79..fef71c019 100644 --- a/qtism/data/content/interactions/GapMatchInteraction.php +++ b/qtism/data/content/interactions/GapMatchInteraction.php @@ -26,6 +26,8 @@ use InvalidArgumentException; use qtism\data\content\BlockStaticCollection; use qtism\data\QtiComponentCollection; +use qtism\data\state\AssociationValidityConstraint; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -188,6 +190,27 @@ public function getComponents() return new QtiComponentCollection(array_merge($parentComponents->getArrayCopy(), $this->getGapChoices()->getArrayCopy(), $this->getContent()->getArrayCopy())); } + public function getResponseValidityConstraint(): ?ResponseValidityConstraint + { + $responseValidityConstraint = new ResponseValidityConstraint( + $this->getResponseIdentifier(), + 0, + 0 + ); + + foreach ($this->getComponentsByClassName(['gapImg', 'gapText']) as $gapChoice) { + $responseValidityConstraint->addAssociationValidityConstraint( + new AssociationValidityConstraint( + $gapChoice->getIdentifier(), + $gapChoice->getMatchMin(), + $gapChoice->getMatchMax() + ) + ); + } + + return $responseValidityConstraint; + } + /** * @return string */ diff --git a/qtism/data/content/interactions/GraphicAssociateInteraction.php b/qtism/data/content/interactions/GraphicAssociateInteraction.php index 9ee5baf0f..b2ce9cff3 100644 --- a/qtism/data/content/interactions/GraphicAssociateInteraction.php +++ b/qtism/data/content/interactions/GraphicAssociateInteraction.php @@ -26,6 +26,8 @@ use InvalidArgumentException; use qtism\data\content\xhtml\ObjectElement; use qtism\data\QtiComponentCollection; +use qtism\data\state\AssociationValidityConstraint; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -193,6 +195,27 @@ public function getComponents() return new QtiComponentCollection(array_merge([$this->getObject()], $this->getAssociableHotspots()->getArrayCopy())); } + public function getResponseValidityConstraint(): ?ResponseValidityConstraint + { + $responseValidityConstraint = new ResponseValidityConstraint( + $this->getResponseIdentifier(), + $this->getMinAssociations(), + $this->getMaxAssociations() + ); + + foreach ($this->getComponentsByClassName('associableHotspot') as $associableHotspot) { + $responseValidityConstraint->addAssociationValidityConstraint( + new AssociationValidityConstraint( + $associableHotspot->getIdentifier(), + $associableHotspot->getMatchMin(), + $associableHotspot->getMatchMax() + ) + ); + } + + return $responseValidityConstraint; + } + /** * @return string */ diff --git a/qtism/data/content/interactions/GraphicGapMatchInteraction.php b/qtism/data/content/interactions/GraphicGapMatchInteraction.php index d667bbf2e..52780a465 100644 --- a/qtism/data/content/interactions/GraphicGapMatchInteraction.php +++ b/qtism/data/content/interactions/GraphicGapMatchInteraction.php @@ -26,6 +26,8 @@ use InvalidArgumentException; use qtism\data\content\xhtml\ObjectElement; use qtism\data\QtiComponentCollection; +use qtism\data\state\AssociationValidityConstraint; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -159,6 +161,27 @@ public function getComponents() ); } + public function getResponseValidityConstraint(): ?ResponseValidityConstraint + { + $responseValidityConstraint = new ResponseValidityConstraint( + $this->getResponseIdentifier(), + 0, + 0 + ); + + foreach ($this->getComponentsByClassName(['gapImg', 'gapText']) as $gapChoice) { + $responseValidityConstraint->addAssociationValidityConstraint( + new AssociationValidityConstraint( + $gapChoice->getIdentifier(), + $gapChoice->getMatchMin(), + $gapChoice->getMatchMax() + ) + ); + } + + return $responseValidityConstraint; + } + /** * @return string */ diff --git a/qtism/data/content/interactions/GraphicOrderInteraction.php b/qtism/data/content/interactions/GraphicOrderInteraction.php index 5467c9e41..a92f7710b 100644 --- a/qtism/data/content/interactions/GraphicOrderInteraction.php +++ b/qtism/data/content/interactions/GraphicOrderInteraction.php @@ -26,6 +26,7 @@ use InvalidArgumentException; use qtism\data\content\xhtml\ObjectElement; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -227,6 +228,15 @@ public function getComponents() return new QtiComponentCollection(array_merge([$this->getObject()], $this->getHotspotChoices()->getArrayCopy())); } + public function getResponseValidityConstraint(): ResponseValidityConstraint + { + return new ResponseValidityConstraint( + $this->getResponseIdentifier(), + ($this->hasMinChoices() === true) ? $this->getMinChoices() : count($this->getHotspotChoices()), + $this->hasMinChoices() && $this->hasMaxChoices() ? $this->getMaxChoices() : 0 + ); + } + /** * @return string */ diff --git a/qtism/data/content/interactions/HotspotInteraction.php b/qtism/data/content/interactions/HotspotInteraction.php index 8cb358caa..c60de1d71 100644 --- a/qtism/data/content/interactions/HotspotInteraction.php +++ b/qtism/data/content/interactions/HotspotInteraction.php @@ -26,6 +26,7 @@ use InvalidArgumentException; use qtism\data\content\xhtml\ObjectElement; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -195,6 +196,15 @@ public function getComponents() return new QtiComponentCollection(array_merge($array, $this->getHotspotChoices()->getArrayCopy())); } + public function getResponseValidityConstraint(): ?ResponseValidityConstraint + { + return new ResponseValidityConstraint( + $this->getResponseIdentifier(), + $this->getMinChoices(), + $this->getMaxChoices() + ); + } + /** * @return string */ diff --git a/qtism/data/content/interactions/HottextInteraction.php b/qtism/data/content/interactions/HottextInteraction.php index edbfaa264..f384e7244 100644 --- a/qtism/data/content/interactions/HottextInteraction.php +++ b/qtism/data/content/interactions/HottextInteraction.php @@ -26,6 +26,7 @@ use InvalidArgumentException; use qtism\data\content\BlockStaticCollection; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -193,6 +194,15 @@ public function getComponents() return $this->getContent(); } + public function getResponseValidityConstraint(): ?ResponseValidityConstraint + { + return new ResponseValidityConstraint( + $this->getResponseIdentifier(), + $this->getMinChoices(), + $this->getMaxChoices() + ); + } + /** * @return string */ diff --git a/qtism/data/content/interactions/InlineChoiceInteraction.php b/qtism/data/content/interactions/InlineChoiceInteraction.php index 7e8c39368..9b4d41636 100644 --- a/qtism/data/content/interactions/InlineChoiceInteraction.php +++ b/qtism/data/content/interactions/InlineChoiceInteraction.php @@ -25,6 +25,7 @@ use InvalidArgumentException; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -183,6 +184,15 @@ public function getComponents() return $this->getContent(); } + public function getResponseValidityConstraint(): ?ResponseValidityConstraint + { + return new ResponseValidityConstraint( + $this->getResponseIdentifier(), + ($this->isRequired() === true) ? 1 : 0, + 1 + ); + } + /** * @return string */ diff --git a/qtism/data/content/interactions/Interaction.php b/qtism/data/content/interactions/Interaction.php index dac1685fa..48ba68c22 100644 --- a/qtism/data/content/interactions/Interaction.php +++ b/qtism/data/content/interactions/Interaction.php @@ -26,6 +26,7 @@ use InvalidArgumentException; use qtism\common\utils\Format; use qtism\data\content\BodyElement; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -98,6 +99,20 @@ public function setResponseIdentifier($responseIdentifier) } } + + /** + * Get the validaty constraint rules to be applied on the response bound to the interaction. + * + * Subclasses of Interaction that claim to provide response validity constraints must override + * this method in order to return an appropriately instantiated ResponseValidityConstraint object. + * + * @return ResponseValidityConstraint|null A ResponseValidityConstraint object or a null value if there is not response validity constraint bound to the interaction's response variable. + */ + public function getResponseValidityConstraint(): ?ResponseValidityConstraint + { + return null; + } + /** * Get the response variable associated with the interaction. * diff --git a/qtism/data/content/interactions/MatchInteraction.php b/qtism/data/content/interactions/MatchInteraction.php index 56d6aad9c..c3455dddb 100644 --- a/qtism/data/content/interactions/MatchInteraction.php +++ b/qtism/data/content/interactions/MatchInteraction.php @@ -25,6 +25,8 @@ use InvalidArgumentException; use qtism\data\QtiComponentCollection; +use qtism\data\state\AssociationValidityConstraint; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -260,6 +262,27 @@ public function getComponents() return new QtiComponentCollection(array_merge($parentComponents->getArrayCopy(), $this->getSimpleMatchSets()->getArrayCopy())); } + public function getResponseValidityConstraint(): ResponseValidityConstraint + { + $responseValidityConstraint = new ResponseValidityConstraint( + $this->getResponseIdentifier(), + $this->getMinAssociations(), + $this->getMaxAssociations() + ); + + foreach ($this->getComponentsByClassName('simpleAssociableChoice') as $simpleAssociableChoice) { + $responseValidityConstraint->addAssociationValidityConstraint( + new AssociationValidityConstraint( + $simpleAssociableChoice->getIdentifier(), + $simpleAssociableChoice->getMatchMin(), + $simpleAssociableChoice->getMatchMax() + ) + ); + } + + return $responseValidityConstraint; + } + /** * @return string */ diff --git a/qtism/data/content/interactions/OrderInteraction.php b/qtism/data/content/interactions/OrderInteraction.php index 7f63ec697..992d2b150 100644 --- a/qtism/data/content/interactions/OrderInteraction.php +++ b/qtism/data/content/interactions/OrderInteraction.php @@ -25,6 +25,7 @@ use InvalidArgumentException; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -301,6 +302,15 @@ public function getComponents() return new QtiComponentCollection(array_merge($parentComponents->getArrayCopy(), $this->getSimpleChoices()->getArrayCopy())); } + public function getResponseValidityConstraint(): ResponseValidityConstraint + { + return new ResponseValidityConstraint( + $this->getResponseIdentifier(), + ($this->hasMinChoices() === true) ? $this->getMinChoices() : count($this->getSimpleChoices()), + $this->hasMinChoices() && $this->hasMaxChoices() ? $this->getMaxChoices() : 0 + ); + } + /** * @return string */ diff --git a/qtism/data/content/interactions/PositionObjectInteraction.php b/qtism/data/content/interactions/PositionObjectInteraction.php index 415db46b4..f62c785ff 100644 --- a/qtism/data/content/interactions/PositionObjectInteraction.php +++ b/qtism/data/content/interactions/PositionObjectInteraction.php @@ -27,6 +27,7 @@ use qtism\common\datatypes\QtiPoint; use qtism\data\content\xhtml\ObjectElement; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -241,6 +242,15 @@ public function getComponents() return new QtiComponentCollection([$this->getObject()]); } + public function getResponseValidityConstraint(): ResponseValidityConstraint + { + return new ResponseValidityConstraint( + $this->getResponseIdentifier(), + $this->getMinChoices(), + $this->getMaxChoices() + ); + } + /** * @return string */ diff --git a/qtism/data/content/interactions/SelectPointInteraction.php b/qtism/data/content/interactions/SelectPointInteraction.php index 423448f50..7b3c217dd 100644 --- a/qtism/data/content/interactions/SelectPointInteraction.php +++ b/qtism/data/content/interactions/SelectPointInteraction.php @@ -26,6 +26,7 @@ use InvalidArgumentException; use qtism\data\content\xhtml\ObjectElement; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -147,6 +148,15 @@ public function getComponents() return new QtiComponentCollection([$this->getObject()]); } + public function getResponseValidityConstraint(): ?ResponseValidityConstraint + { + return new ResponseValidityConstraint( + $this->getResponseIdentifier(), + $this->getMinChoices(), + $this->getMaxChoices() + ); + } + /** * @return string */ diff --git a/qtism/data/content/interactions/TextEntryInteraction.php b/qtism/data/content/interactions/TextEntryInteraction.php index 6789fe8d6..1808d9c61 100644 --- a/qtism/data/content/interactions/TextEntryInteraction.php +++ b/qtism/data/content/interactions/TextEntryInteraction.php @@ -26,6 +26,7 @@ use InvalidArgumentException; use qtism\common\utils\Format; use qtism\data\QtiComponentCollection; +use qtism\data\state\ResponseValidityConstraint; /** * From IMS QTI: @@ -319,6 +320,11 @@ public function getComponents() return new QtiComponentCollection(); } + public function getResponseValidityConstraint(): ResponseValidityConstraint + { + return new ResponseValidityConstraint($this->getResponseIdentifier(), 0, 1, $this->getPatternMask()); + } + /** * @return string */ diff --git a/qtism/data/state/AssociationValidityConstraint.php b/qtism/data/state/AssociationValidityConstraint.php new file mode 100644 index 000000000..0872b1d02 --- /dev/null +++ b/qtism/data/state/AssociationValidityConstraint.php @@ -0,0 +1,187 @@ + + * @license GPLv2 + */ + +namespace qtism\data\state; + +use InvalidArgumentException; +use qtism\data\QtiComponent; +use qtism\data\QtiComponentCollection; + +/** + * The AssociationValidityConstraint class. + * + * It represents an identifier association constraint to be applied on a given response variable of Pair/DirectedPair baseType. + */ +class AssociationValidityConstraint extends QtiComponent +{ + /** + * The identifier on which the validity constraint applies to. + * + * @var string + * @qtism-bean-property + */ + private $identifier; + + /** + * The minimum number of times $identifier may be found in a Response Variable. + * + * @var int + * @qtism-bean-property + */ + private $minConstraint; + + /** + * The maximum number of times $identifier may be found in a Response Variable. + * + * @var int + * @qtism-bean-property + */ + private $maxConstraint; + + /** + * Create a new AssociationValidityConstraint object. + * + * If the $patternMask attribute is provided, it represent a constraint to be applied on all string + * values contained by the variable described in the $responseÏdentifier variable. + * + * @param string $identifier The identifier on which the validity constraint applies to. + * @param int $minConstraint The minimum number of times $identifier may be found in a Response Variable. + * @param int $maxConstraint The maximum number of times $identifier may be found in a Response Variable. + * @throws InvalidArgumentException If one or more of the arguments above are invalid. + */ + public function __construct($identifier, $minConstraint, $maxConstraint) + { + $this->setIdentifier($identifier); + $this->setMinConstraint($minConstraint); + $this->setMaxConstraint($maxConstraint); + } + + /** + * Set the identifier on which the validity constraint applies to. + * + * @param int $identifier + * @throws InvalidArgumentException If $identifier is an empty string. + */ + public function setIdentifier($identifier): void + { + if (is_string($identifier) === false || empty($identifier)) { + throw new InvalidArgumentException( + "The 'identifier' argument must be a non-empty string." + ); + } + + $this->identifier = $identifier; + } + + /** + * Get the identifier on which the validity constraint applies to. + * + * @return string + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * Set the minimum number of times $identifier may be found in a Response Variable. + * + * @param int $minConstraint A non negative integer (>= 0) integer value. + * @throws InvalidArgumentException If $minConstraint is not a non negative (>= 0) integer value. + */ + public function setMinConstraint($minConstraint): void + { + if (is_int($minConstraint) === false || $minConstraint < 0) { + throw new InvalidArgumentException( + "The 'minConstraint' argument must be a non negative (>= 0) integer." + ); + } + + $this->minConstraint = $minConstraint; + } + + /** + * Get the minimum number of times $identifier may be found in a Response Variable. + * + * @return int A non negative (>= 0) integer value. + */ + public function getMinConstraint(): int + { + return $this->minConstraint; + } + + /** + * Set the maximum number of times $identifier may be found in a Response Variable. + * + * Please note that 0 indicates no constraint. + * + * @param int $maxConstraint An integer value which is greater than the 'minConstraint' in place. + * @throws InvalidArgumentException If $maxConstraint is not an integer greater or equal to the 'minConstraint' in place. + */ + public function setMaxConstraint($maxConstraint): void + { + if (is_int($maxConstraint) === false) { + throw new InvalidArgumentException( + "The 'maxConstraint' argument must be an integer." + ); + } elseif ($maxConstraint < 0) { + throw new InvalidArgumentException( + "The 'maxConstraint' argument must be a non negative (>= 0) integer." + ); + } elseif ($maxConstraint !== 0 && $maxConstraint < ($minConstraint = $this->getMinConstraint())) { + throw new InvalidArgumentException( + "The 'maxConstraint' argument must be greather or equal to than the 'minConstraint' in place." + ); + } + + $this->maxConstraint = $maxConstraint; + } + + /** + * Get the maximum number of times $identifier may be found in a Response Variable. + * + * Please note that 0 indicates no constraint. + * + * @return int + */ + public function getMaxConstraint(): int + { + return $this->maxConstraint; + } + + /** + * @return string + */ + public function getQtiClassName(): string + { + return 'associationValidityConstraint'; + } + + /** + * @return QtiComponentCollection + */ + public function getComponents(): QtiComponentCollection + { + return new QtiComponentCollection(); + } +} diff --git a/qtism/data/state/AssociationValidityConstraintCollection.php b/qtism/data/state/AssociationValidityConstraintCollection.php new file mode 100644 index 000000000..97040240e --- /dev/null +++ b/qtism/data/state/AssociationValidityConstraintCollection.php @@ -0,0 +1,47 @@ + + * @license GPLv2 + */ + +namespace qtism\data\state; + +use InvalidArgumentException; +use qtism\data\QtiComponentCollection; + +/** + * A collection of AssociationValidityConstraint objects. + */ +class AssociationValidityConstraintCollection extends QtiComponentCollection +{ + /** + * Check if a given $value is an instance of AssociationValidityConstraint. + * + * @param mixed $value + * @throws InvalidArgumentException If the given $value is not an instance of AssociationValidityConstraint. + */ + protected function checkType($value): void + { + if (!$value instanceof AssociationValidityConstraint) { + $msg = "AssociationValidityConstraintCollection only accepts to store AssociationValidityConstraint objects, '" . gettype($value) . "' given."; + throw new InvalidArgumentException($msg); + } + } +} diff --git a/qtism/data/state/ResponseValidityConstraintCollection.php b/qtism/data/state/ResponseValidityConstraintCollection.php new file mode 100644 index 000000000..a35ffb578 --- /dev/null +++ b/qtism/data/state/ResponseValidityConstraintCollection.php @@ -0,0 +1,47 @@ + + * @license GPLv2 + */ + +namespace qtism\data\state; + +use InvalidArgumentException; +use qtism\data\QtiComponentCollection; + +/** + * A collection of ResponseValidityConstraint objects. + */ +class ResponseValidityConstraintCollection extends QtiComponentCollection +{ + /** + * Check if a given $value is an instance of ResponseValidityConstraint. + * + * @param mixed $value + * @throws InvalidArgumentException If the given $value is not an instance of ResponseValidityConstraint. + */ + protected function checkType($value): void + { + if (!$value instanceof ResponseValidityConstraint) { + $msg = "ResponseValidityConstraintCollection only accepts to store ResponseValidityConstraint objects, '" . gettype($value) . "' given."; + throw new InvalidArgumentException($msg); + } + } +} diff --git a/qtism/data/storage/xml/XmlCompactDocument.php b/qtism/data/storage/xml/XmlCompactDocument.php index 326e14bc9..32191ae64 100644 --- a/qtism/data/storage/xml/XmlCompactDocument.php +++ b/qtism/data/storage/xml/XmlCompactDocument.php @@ -275,6 +275,7 @@ protected static function resolveAssessmentItemRef(ExtendedAssessmentItemRef $co $compactAssessmentItemRef->setResponseProcessing($doc->getDocumentComponent()->getResponseProcessing()); } + $compactAssessmentItemRef->setResponseValidityConstraints($doc->getDocumentComponent()->getResponseValidityConstraints()); $compactAssessmentItemRef->setAdaptive($doc->getDocumentComponent()->isAdaptive()); $compactAssessmentItemRef->setTimeDependent($doc->getDocumentComponent()->isTimeDependent()); } catch (Exception $e) { diff --git a/qtism/data/storage/xml/marshalling/ExtendedAssessmentItemRefMarshaller.php b/qtism/data/storage/xml/marshalling/ExtendedAssessmentItemRefMarshaller.php index b9beaa2bb..a9b9b8cca 100644 --- a/qtism/data/storage/xml/marshalling/ExtendedAssessmentItemRefMarshaller.php +++ b/qtism/data/storage/xml/marshalling/ExtendedAssessmentItemRefMarshaller.php @@ -28,6 +28,7 @@ use qtism\data\QtiComponent; use qtism\data\state\OutcomeDeclarationCollection; use qtism\data\state\ResponseDeclarationCollection; +use qtism\data\state\ResponseValidityConstraintCollection; /** * A Marshaller aiming at marshalling/unmarshalling ExtendedAssessmentItemRefs. @@ -120,6 +121,15 @@ protected function unmarshall(DOMElement $element) $compactAssessmentItemRef->setResponseProcessing($marshaller->unmarshall($responseProcessingElts[0])); } + // ResponseValidityConstraints. + $responseValidityConstraintElts = $this->getChildElementsByTagName($element, 'responseValidityConstraint'); + $responseValidityConstraints = new ResponseValidityConstraintCollection(); + foreach ($responseValidityConstraintElts as $responseValidityConstraintElt) { + $marshaller = $this->getMarshallerFactory()->createMarshaller($responseValidityConstraintElt); + $responseValidityConstraints[] = $marshaller->unmarshall($responseValidityConstraintElt); + } + $compactAssessmentItemRef->setResponseValidityConstraints($responseValidityConstraints); + if (($adaptive = $this->getDOMElementAttributeAs($element, 'adaptive', 'boolean')) !== null) { $compactAssessmentItemRef->setAdaptive($adaptive); } diff --git a/qtism/runtime/tests/AssessmentItemSession.php b/qtism/runtime/tests/AssessmentItemSession.php index a5a115af3..18eb9a9b4 100644 --- a/qtism/runtime/tests/AssessmentItemSession.php +++ b/qtism/runtime/tests/AssessmentItemSession.php @@ -35,8 +35,6 @@ use qtism\data\ItemSessionControl; use qtism\data\NavigationMode; use qtism\data\processing\ResponseProcessing; -use qtism\data\state\OutcomeDeclaration; -use qtism\data\state\OutcomeDeclarationCollection; use qtism\data\storage\php\PhpStorageException; use qtism\data\SubmissionMode; use qtism\data\TimeLimits; @@ -45,6 +43,7 @@ use qtism\runtime\common\State; use qtism\runtime\common\Utils; use qtism\runtime\processing\ResponseProcessingEngine; +use qtism\runtime\tests\Utils as TestUtils; /** * The AssessmentItemSession class implements the lifecycle of an AssessmentItem session. @@ -1184,4 +1183,32 @@ public function __clone() $this->setDataPlaceHolder($newData); } + + public function checkResponseValidityConstraints(State $responses): void + { + if ($this->getSubmissionMode() === SubmissionMode::INDIVIDUAL && $this->getItemSessionControl()->mustValidateResponses() === true) { + $session = clone $this; + + foreach ($responses as $identifier => $value) { + if (isset($session[$identifier])) { + $session[$identifier] = $value->getValue(); + } + } + + $state = $session->getResponseVariables(false); + + foreach ($this->getAssessmentItem()->getResponseValidityConstraints() as $constraint) { + $responseIdentifier = $constraint->getResponseIdentifier(); + $value = $state[$responseIdentifier]; + + if (TestUtils::isResponseValid($value, $constraint) === false) { + throw new AssessmentItemSessionException( + "Response '{$responseIdentifier}' is invalid against the constraints described in the interaction it is bound to.", + $this, + AssessmentItemSessionException::INVALID_RESPONSE + ); + } + } + } + } } From 69d44cf43b01bf77196f535abfcfdab64324cd88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Marsza=C5=82?= Date: Wed, 4 Dec 2024 11:15:46 +0100 Subject: [PATCH 2/6] feat: Compile to cache fixed --- qtism/data/ExtendedAssessmentItemRef.php | 10 +- qtism/data/ExtendedAssessmentTest.php | 130 ++++++++ qtism/data/ExtendedTestPart.php | 135 ++++++++ qtism/data/IAssessmentItem.php | 4 +- qtism/data/QtiComponent.php | 25 +- qtism/data/TestFeedbackRef.php | 257 +++++++++++++++ qtism/data/TestFeedbackRefCollection.php | 47 +++ .../data/state/ResponseValidityConstraint.php | 301 ++++++++++++++++++ qtism/data/storage/xml/XmlCompactDocument.php | 10 +- 9 files changed, 911 insertions(+), 8 deletions(-) create mode 100644 qtism/data/ExtendedAssessmentTest.php create mode 100644 qtism/data/ExtendedTestPart.php create mode 100644 qtism/data/TestFeedbackRef.php create mode 100644 qtism/data/TestFeedbackRefCollection.php create mode 100644 qtism/data/state/ResponseValidityConstraint.php diff --git a/qtism/data/ExtendedAssessmentItemRef.php b/qtism/data/ExtendedAssessmentItemRef.php index ab70248c5..415a20a32 100644 --- a/qtism/data/ExtendedAssessmentItemRef.php +++ b/qtism/data/ExtendedAssessmentItemRef.php @@ -15,7 +15,7 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * - * Copyright (c) 2013-2020 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * Copyright (c) 2013-2024 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); * * @author Jérôme Bogaerts * @license GPLv2 @@ -81,6 +81,12 @@ class ExtendedAssessmentItemRef extends AssessmentItemRef implements IAssessment */ private $timeDependent = false; + /** + * @var ResponseValidityConstraintCollection + * @qtism-bean-property + */ + private $responseValidityConstraints; + /** * Create a new instance of CompactAssessmentItem * @@ -305,7 +311,7 @@ public function getComponents() /** * Get the response validity constraints related to the item content. */ - public function getResponseValidityConstraints(): ResponseValidityConstraintCollection + public function getResponseValidityConstraints() { return $this->responseValidityConstraints; } diff --git a/qtism/data/ExtendedAssessmentTest.php b/qtism/data/ExtendedAssessmentTest.php new file mode 100644 index 000000000..d33cd5ad6 --- /dev/null +++ b/qtism/data/ExtendedAssessmentTest.php @@ -0,0 +1,130 @@ + + * @license GPLv2 + */ + +namespace qtism\data; + +/** + * The ExtendedAssessmentTest class is an extended representation of the QTI + * AssessmentTest class. It gathers together the AssessmentTest + additional references + * to testFeedback components. + */ +class ExtendedAssessmentTest extends AssessmentTest +{ + /** + * A collection of TestFeedbackRef objects. + * + * @var TestFeedbackRefCollection + * @qtism-bean-property + */ + private $testFeedbackRefs; + + /** + * Create a new ExtendedAssessmentTest object. + * + * @param string $identifier A QTI identifier. + * @param string $title A title. + * @param TestPartCollection $testParts A collection of ExtendedTestPart objects. + */ + public function __construct($identifier, $title, TestPartCollection $testParts = null) + { + parent::__construct($identifier, $title, $testParts); + $this->setTestFeedbackRefs(new TestFeedbackRefCollection()); + } + + /** + * Set the collection of TestFeedbackRef objects. + * + * @param TestFeedbackRefCollection $testFeedbackRefs + */ + public function setTestFeedbackRefs(TestFeedbackRefCollection $testFeedbackRefs): void + { + $this->testFeedbackRefs = $testFeedbackRefs; + } + + /** + * Get the collection of TestFeedbackRef objects. + * + * @return TestFeedbackRefCollection + */ + public function getTestFeedbackRefs(): TestFeedbackRefCollection + { + return $this->testFeedbackRefs; + } + + /** + * Add a TestFeedbackRef object. + * + * @param TestFeedbackRef $testFeedbackRef + */ + public function addTestFeedbackRef(TestFeedbackRef $testFeedbackRef): void + { + $this->getTestFeedbackRefs()->attach($testFeedbackRef); + } + + /** + * Remove a TestFeedbackRef object. + * + * @param TestFeedbackRef $testFeedbackRef + */ + public function removeTestFeedbackRef(TestFeedbackRef $testFeedbackRef): void + { + $this->getTestFeedbackRefs()->detach($testFeedbackRef); + } + + /** + * Create an ExtendedAssessmentTest object from an AssessmentTest object. + * + * @param AssessmentTest $assessmentTest + * @return ExtendedAssessmentTest + */ + public static function createFromAssessmentTest(AssessmentTest $assessmentTest): ExtendedAssessmentTest + { + $ref = new ExtendedAssessmentTest( + $assessmentTest->getIdentifier(), + $assessmentTest->getTitle(), + $assessmentTest->getTestParts() + ); + + $ref->setTimeLimits($assessmentTest->getTimeLimits()); + $ref->setOutcomeDeclarations($assessmentTest->getOutcomeDeclarations()); + $ref->setOutcomeProcessing($assessmentTest->getOutcomeProcessing()); + $ref->setTestFeedbacks($assessmentTest->getTestFeedbacks()); + $ref->setToolName($assessmentTest->getToolName()); + $ref->setToolVersion($assessmentTest->getToolVersion()); + + return $ref; + } + + /** + * @return QtiComponentCollection + */ + public function getComponents(): QtiComponentCollection + { + $components = array_merge( + parent::getComponents()->getArrayCopy(), + $this->getTestFeedbackRefs()->getArrayCopy() + ); + + return new QtiComponentCollection($components); + } +} diff --git a/qtism/data/ExtendedTestPart.php b/qtism/data/ExtendedTestPart.php new file mode 100644 index 000000000..20710cfcb --- /dev/null +++ b/qtism/data/ExtendedTestPart.php @@ -0,0 +1,135 @@ + + * @license GPLv2 + */ + +namespace qtism\data; + +use InvalidArgumentException; + +/** + * The ExtendedTestPart class is an extended representation of the QTI + * testPart class. It gathers together the testPart + additional references + * to testFeedback components. + */ +class ExtendedTestPart extends TestPart +{ + /** + * Create a new ExtendedTestPart object. + * + * @param string $identifier An identifier. + * @param SectionPartCollection $assessmentSections A collection of AssessmentSection and/or AssessmentSectionRef objects. + * @param int $navigationMode A value from the NavigationMode enumeration. + * @param int $submissionMode A value from the SubmissionMode enumeration. + * @throws InvalidArgumentException If any of the arguments is invalid. + */ + public function __construct($identifier, SectionPartCollection $assessmentSections, $navigationMode = NavigationMode::LINEAR, $submissionMode = SubmissionMode::INDIVIDUAL) + { + parent::__construct($identifier, $assessmentSections, $navigationMode, $submissionMode); + $this->setTestFeedbackRefs(new TestFeedbackRefCollection()); + } + + /** + * A collection of TestFeedbackRef objects. + * + * @var TestFeedbackRefCollection + * @qtism-bean-property + */ + private $testFeedbackRefs; + + /** + * Set the collection of TestFeedbackRef objects. + * + * @param TestFeedbackRefCollection $testFeedbackRefs + */ + public function setTestFeedbackRefs(TestFeedbackRefCollection $testFeedbackRefs): void + { + $this->testFeedbackRefs = $testFeedbackRefs; + } + + /** + * Get the collection of TestFeedbackRef objects. + * + * @return TestFeedbackRefCollection + */ + public function getTestFeedbackRefs(): TestFeedbackRefCollection + { + return $this->testFeedbackRefs; + } + + /** + * Add a TestFeedbackRef to the ExtendedTestPart. + * + * @param TestFeedbackRef $testFeedbackRef + */ + public function addTestFeedbackRef(TestFeedbackRef $testFeedbackRef): void + { + $this->getTestFeedbackRefs()->attach($testFeedbackRef); + } + + /** + * Remove a TestFeedbackRef from the ExtendedTestPart. + * + * @param TestFeedbackRef $testFeedbackRef + */ + public function removeTestFeedbackRef(TestFeedbackRef $testFeedbackRef): void + { + $this->getTestFeedbackRefs()->detach($testFeedbackRef); + } + + /** + * Create a new ExtendedTestPart object from another TestPart object. + * + * @param TestPart $testPart + * @return ExtendedTestPart + */ + public static function createFromTestPart(TestPart $testPart): ExtendedTestPart + { + $ref = new self( + $testPart->getIdentifier(), + $testPart->getAssessmentSections(), + $testPart->getNavigationMode(), + $testPart->getSubmissionMode() + ); + + $ref->setAssessmentSections($testPart->getAssessmentSections()); + $ref->setTimeLimits($testPart->getTimeLimits()); + $ref->setPreConditions($testPart->getPreConditions()); + $ref->setBranchRules($testPart->getBranchRules()); + $ref->setItemSessionControl($testPart->getItemSessionControl()); + $ref->setTestFeedbacks($testPart->getTestFeedbacks()); + + return $ref; + } + + /** + * @return QtiComponentCollection + */ + public function getComponents(): QtiComponentCollection + { + $components = array_merge( + parent::getComponents()->getArrayCopy(), + $this->getTestFeedbackRefs()->getArrayCopy() + ); + + return new QtiComponentCollection($components); + } +} diff --git a/qtism/data/IAssessmentItem.php b/qtism/data/IAssessmentItem.php index 247e418a7..56c2f8321 100644 --- a/qtism/data/IAssessmentItem.php +++ b/qtism/data/IAssessmentItem.php @@ -15,7 +15,7 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * - * Copyright (c) 2013-2020 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * Copyright (c) 2013-2024 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); * * @author Jérôme Bogaerts * @license GPLv2 @@ -114,5 +114,5 @@ public function setResponseProcessing(ResponseProcessing $responseProcessing = n * * @return ResponseValidityConstraintCollection */ - public function getResponseValidityConstraints(): ResponseValidityConstraintCollection; + public function getResponseValidityConstraints(); } diff --git a/qtism/data/QtiComponent.php b/qtism/data/QtiComponent.php index 6275958f8..be4b1958f 100644 --- a/qtism/data/QtiComponent.php +++ b/qtism/data/QtiComponent.php @@ -15,7 +15,7 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * - * Copyright (c) 2013-2020 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * Copyright (c) 2013-2024 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); * * @author Jérôme Bogaerts * @license GPLv2 @@ -157,4 +157,27 @@ public function getIdentifiableComponents($recursive = true) return ($collision === true) ? new QtiComponentCollection($foundComponents) : new QtiIdentifiableCollection($foundComponents); } + + /** + * Whether the component contains child components with class $classNames. + * + * @param string|array $classNames + * @param bool $recursive Whether to search recursively in contained QtiComponent objects. + * @return bool + */ + public function containsComponentWithClassName($classNames, $recursive = true): bool + { + if (is_array($classNames) === false) { + $classNames = [$classNames]; + } + + $iterator = ($recursive === true) ? $this->getIterator($classNames) : $this->getComponents(); + foreach ($iterator as $component) { + if (in_array($component->getQtiClassName(), $classNames)) { + return true; + } + } + + return false; + } } diff --git a/qtism/data/TestFeedbackRef.php b/qtism/data/TestFeedbackRef.php new file mode 100644 index 000000000..76c4b0f50 --- /dev/null +++ b/qtism/data/TestFeedbackRef.php @@ -0,0 +1,257 @@ + + * @license GPLv2 + */ + +namespace qtism\data; + +use InvalidArgumentException; +use qtism\common\utils\Format; + +/** + * The TestFeedback class. + */ +class TestFeedbackRef extends QtiComponent +{ + /** + * From IMS QTI: + * + * Test feedback is shown to the candidate either directly following outcome processing + * (during the test) or at the end of the testPart or assessmentTest as appropriate + * (referred to as atEnd). + * + * The value of an outcome variable is used in conjunction with the showHide and + * identifier attributes to determine whether or not the feedback is actually + * shown in a similar way to feedbackElement (Item Model). + * + * @var int + * @qtism-bean-property + */ + private $access = TestFeedbackAccess::DURING; + + /** + * The QTI Identifier of the outcome variable bound to this feedback. + * + * @var string + * @qtism-bean-property + */ + private $outcomeIdentifier; + + /** + * From IMS QTI: + * + * The showHide attribute determines how the visibility of the feedbackElement is controlled. + * If set to show then the feedback is hidden by default and shown only if the associated + * outcome variable matches, or contains, the value of the identifier attribute. If set + * to hide then the feedback is shown by default and hidden if the associated outcome + * variable matches, or contains, the value of the identifier attribute. + * + * @var int + * @qtism-bean-property + */ + private $showHide = ShowHide::SHOW; + + /** + * The QTI identifier of the TestFeedback. + * + * @var string + * @qtism-bean-property + */ + private $identifier; + + /** + * A URI referencing the actual real TestFeedback QTI class. + * + * @var string + * @qtism-bean-property + */ + private $href; + + /** + * Create a new TestFeedbackRef object. + * + * @param string $identifier An identifier. + * @param string $outcomeIdentifier An identifier. + * @param int $access A value from the TestFeedbackAccess enumeration. + * @param int $showHide A value from the ShowHide enumeration. + * @param string $href A URI. + * @throws InvalidArgumentException If one of the arguments is invalid. + */ + public function __construct($identifier, $outcomeIdentifier, $access, $showHide, $href) + { + $this->setIdentifier($identifier); + $this->setOutcomeIdentifier($outcomeIdentifier); + $this->setHref($href); + $this->setAccess($access); + $this->setShowHide($showHide); + } + + /** + * Get how the feedback is shown to the candidate. + * + * * TestFeedbackAccess::DURING = At outcome processing time. + * * TestFeedbackAccess::AT_END = At the end of the TestPart or AssessmentTest. + * + * @return int A value of the TestFeedbackAccess enumeration. + */ + public function getAccess(): int + { + return $this->access; + } + + /** + * Set how the feedback is shown to the candidate. + * + * * TestFeedbackAccess::DURING = At outcome processing time. + * * TestFeedbackAccess:AT_END = At the end of the TestPart or AssessmentTest. + * + * @param int $access A value of the TestFeedbackAccess enumeration. + * @throws InvalidArgumentException If $access is not a value from the TestFeedbackAccess enumeration. + */ + public function setAccess($access): void + { + if (in_array($access, TestFeedbackAccess::asArray(), true)) { + $this->access = $access; + } else { + $msg = "'{$access}' is not a value from the TestFeedbackAccess enumeration."; + throw new InvalidArgumentException($msg); + } + } + + /** + * Get the QTI Identifier of the outcome variable bound to this TestFeedback. + * + * @return string A QTI Identifier. + */ + public function getOutcomeIdentifier(): string + { + return $this->outcomeIdentifier; + } + + /** + * Set the QTI Identifier of the outcome variable bound to this TestFeedback. + * + * @param string $outcomeIdentifier A QTI Identifier. + * @throws InvalidArgumentException If $outcomeIdentifier is not a valid QTI Identifier. + */ + public function setOutcomeIdentifier($outcomeIdentifier): void + { + if (Format::isIdentifier((string)$outcomeIdentifier)) { + $this->outcomeIdentifier = $outcomeIdentifier; + } else { + $msg = "'{$outcomeIdentifier}' is not a valid QTI Identifier."; + throw new InvalidArgumentException($msg); + } + } + + /** + * Get the QTI identifier of this TestFeedback. + * + * @return string A QTI identifier. + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * Set the QTI identifier of this TestFeedback. + * + * @param string $identifier A QTI Identifier. + * @throws InvalidArgumentException If $identifier is not a valid QTI Identifier. + */ + public function setIdentifier($identifier): void + { + if (Format::isIdentifier((string)$identifier, false)) { + $this->identifier = $identifier; + } else { + $msg = "'{$identifier}' is not a valid QTI Identifier."; + throw new InvalidArgumentException($msg); + } + } + + /** + * Get how the feedback should be displayed. + * + * @return int A value from the ShowHide enumeration. + */ + public function getShowHide(): int + { + return $this->showHide; + } + + /** + * Set how the feedback should be displayed. + * + * @param bool $showHide A value from the ShowHide enumeration. + * @throws InvalidArgumentException If $showHide is not a value from the ShowHide enumeration. + */ + public function setShowHide($showHide): void + { + if (in_array($showHide, ShowHide::asArray(), true)) { + $this->showHide = $showHide; + } else { + $msg = "'{$showHide}' is not a value from the ShowHide enumeration."; + throw new InvalidArgumentException($msg); + } + } + + /** + * Set the hyper-text reference to the actual content of the TestFeedback. + * + * @return string + */ + public function getHref(): string + { + return $this->href; + } + + /** + * Get the hyper-text reference to the actual content of the TestFeedback. + * + * @param string $href + */ + public function setHref($href): void + { + if (Format::isUri($href) === true) { + $this->href = $href; + } else { + $msg = "'{$href}' is not a valid URI."; + throw new InvalidArgumentException($msg); + } + } + + /** + * @return string + */ + public function getQtiClassName(): string + { + return 'testFeedbackRef'; + } + + /** + * @return QtiComponentCollection + */ + public function getComponents(): QtiComponentCollection + { + return new QtiComponentCollection(); + } +} diff --git a/qtism/data/TestFeedbackRefCollection.php b/qtism/data/TestFeedbackRefCollection.php new file mode 100644 index 000000000..b7e9566a6 --- /dev/null +++ b/qtism/data/TestFeedbackRefCollection.php @@ -0,0 +1,47 @@ + + * @license GPLv2 + */ + +namespace qtism\data; + +use InvalidArgumentException; + +/** + * A specialized QtiIdentifiableCollection aiming at storing + * TestFeedbackRef objects only. + */ +class TestFeedbackRefCollection extends QtiComponentCollection +{ + /** + * Checks whether $value is an instance of TestFeedbackRef. + * + * @param mixed $value + * @throws InvalidArgumentException If $value is not an instance of TestFeedbackRef. + */ + protected function checkType($value): void + { + if (!$value instanceof TestFeedbackRef) { + $msg = 'A TestFeedbackRefCollection object only accepts to store TestFeedbackRef objects.'; + throw new InvalidArgumentException($msg); + } + } +} diff --git a/qtism/data/state/ResponseValidityConstraint.php b/qtism/data/state/ResponseValidityConstraint.php new file mode 100644 index 000000000..dec459c0f --- /dev/null +++ b/qtism/data/state/ResponseValidityConstraint.php @@ -0,0 +1,301 @@ + + * @license GPLv2 + */ + +namespace qtism\data\state; + +use InvalidArgumentException; +use qtism\data\QtiComponent; +use qtism\data\QtiComponentCollection; + +/** + * The ResponseValidityConstraint class represent a cardinality constraint to be applied on a given response variable. + */ +class ResponseValidityConstraint extends QtiComponent +{ + /** + * The identifier of the response the validity constraint applies to. + * + * @var string + * @qtism-bean-property + */ + private $responseIdentifier; + + /** + * The minimum cardinality the value to be set to the response must have. + * + * @var int + * @qtism-bean-property + */ + private $minConstraint; + + /** + * The maximum cardinality the value to be set the response must have. + * + * @var int + * @qtism-bean-property + */ + private $maxConstraint; + + /** + * An XML Schema regular expression representing a constraint to be applied on all string values contained by the variable described in the $responseIdentifier. + * + * @var string + * @qtism-bean-property + */ + private $patternMask; + + /** + * The collection of nested AssociationValidityConstraints objects. + * + * @var AssociationValidityConstraintCollection + */ + private $associationValidityConstraints; + + /** + * Metadata defined by @see \qtism\data\content\interactions\Interaction instantiating this ResponseValidityConstraint + */ + private $extraData = []; + + /** + * Create a new ResponseValidityConstraint object. + * + * If the $patternMask attribute is provided, it represents a constraint to be applied on all string + * values contained by the variable described in the $responseÏdentifier variable. + * + * @param string $responseIdentifier The identifier of the response the validity constraint applies to. + * @param int $minConstraint The minimum cardinality the value to be set to the response must have. + * @param int $maxConstraint The maximum cardinality the value to be set the response must have. + * @param string $patternMask (optional) A XML Schema regular expression. + * @param array $extraData (optional) Metadata defined by the Interaction instantiating this ResponseValidityConstraint + * @see \qtism\data\content\interactions\Interaction + * @throws InvalidArgumentException If one or more of the arguments above are invalid. + */ + public function __construct($responseIdentifier, $minConstraint, $maxConstraint, $patternMask = '', $extraData = []) + { + $this->setResponseIdentifier($responseIdentifier); + $this->setMinConstraint($minConstraint); + $this->setMaxConstraint($maxConstraint); + $this->setPatternMask($patternMask); + $this->setAssociationValidityConstraints(new AssociationValidityConstraintCollection()); + $this->setExtraData($extraData); + } + + /** + * Set the identifier of the response the validity constraint applies to. + * + * @param int $responseIdentifier + * @throws InvalidArgumentException If $responseIdentifier is not a non-empty string. + */ + public function setResponseIdentifier($responseIdentifier): void + { + if (is_string($responseIdentifier) === false || empty($responseIdentifier)) { + throw new InvalidArgumentException( + "The 'responseIdentifier' argument must be a non-empty string." + ); + } + + $this->responseIdentifier = $responseIdentifier; + } + + /** + * Get the identifier of the response the validity constraint applies to. + * + * @return string + */ + public function getResponseIdentifier(): string + { + return $this->responseIdentifier; + } + + /** + * Set the minimum cardinality the value to be set to the response must have. + * + * @param int $minConstraint A non negative integer (>= 0) integer value. + * @throws InvalidArgumentException If $minConstraint is not a non negative (>= 0) integer value. + */ + public function setMinConstraint($minConstraint): void + { + if (is_int($minConstraint) === false || $minConstraint < 0) { + throw new InvalidArgumentException( + "The 'minConstraint' argument must be a non negative (>= 0) integer." + ); + } + + $this->minConstraint = $minConstraint; + } + + /** + * Get the minimum cardinality the value to be set to the response must have. + * + * @return int A non negative (>= 0) integer value. + */ + public function getMinConstraint(): int + { + return $this->minConstraint; + } + + /** + * Set the maximum cardinality the value to be set the response must have. + * + * Please note that 0 indicates no constraint. + * + * @param int $maxConstraint An integer value which is greater than the 'minConstraint' in place. + * @throws InvalidArgumentException If $maxConstraint is not an integer greater or equal to the 'minConstraint' in place. + */ + public function setMaxConstraint($maxConstraint): void + { + if (is_int($maxConstraint) === false) { + throw new InvalidArgumentException( + "The 'maxConstraint' argument must be an integer." + ); + } elseif ($maxConstraint < 0) { + throw new InvalidArgumentException( + "The 'maxConstraint' argument must be a non negative (>= 0) integer." + ); + } elseif ($maxConstraint !== 0 && $maxConstraint < ($minConstraint = $this->getMinConstraint())) { + throw new InvalidArgumentException( + "The 'maxConstraint' argument must be greather or equal to than the 'minConstraint' in place." + ); + } + + $this->maxConstraint = $maxConstraint; + } + + /** + * Get the maximum cardinality the value to be set the response must have. + * + * Please note that 0 indicates no constraint. + * + * @return int + */ + public function getMaxConstraint(): int + { + return $this->maxConstraint; + } + + /** + * Set the PatternMask for the ResponseValidityConstraint. + * + * Set the XML Schema regular expression representing a constraint to be applied on all string + * values contained by the variable described in the $responseÏdentifier variable. Providing an empty + * string as the $patternMask means there is no constraint to be applied. + * + * @param string $patternMask An XML Schema regular expression. + */ + public function setPatternMask($patternMask): void + { + if (is_string($patternMask) === false) { + throw new InvalidArgumentException( + "The 'patternMask' argument must be a string, '" . gettype($patternMask) . "' given." + ); + } + + $this->patternMask = $patternMask; + } + + /** + * Get the PatternMask for the ResponseValidityConstraint. + * + * Get the XML Schema regular expression representing a constraint to be applied on all string + * values contained by the variable described in the $responseIdentifier variable. The method + * returns an empty string when there is no constraint to be applied. + * + * @return string an XML Schema regulax expression. + */ + public function getPatternMask(): string + { + return $this->patternMask; + } + + /** + * Set the collection of nested AssociationValidityConstraints objects. + * + * @param AssociationValidityConstraintCollection $associationValidityConstraints + */ + public function setAssociationValidityConstraints( + AssociationValidityConstraintCollection $associationValidityConstraints + ): void { + $this->associationValidityConstraints = $associationValidityConstraints; + } + + /** + * Get the collection of nested AssociationValidityConstraints objects. + * + * @return AssociationValidityConstraintCollection + */ + public function getAssociationValidityConstraints(): AssociationValidityConstraintCollection + { + return $this->associationValidityConstraints; + } + + /** + * Attach a given $associationValidityConstraint object. + * + * @param AssociationValidityConstraint $associationValidityConstraint + */ + public function addAssociationValidityConstraint(AssociationValidityConstraint $associationValidityConstraint): void + { + $this->getAssociationValidityConstraints()->attach($associationValidityConstraint); + } + + /** + * Remove a given $associationValidityConstraint object. + * + * If $associationValidityConstraint is not part of the ResponseValidityConstraint, nothing happens. + * + * @param AssociationValidityConstraint $associationValidityConstraint + */ + public function removeAssociationValidityConstraint( + AssociationValidityConstraint $associationValidityConstraint + ): void { + $this->getAssociationValidityConstraints()->remove($associationValidityConstraint); + } + + /** + * @return string + */ + public function getQtiClassName(): string + { + return 'responseValidityConstraint'; + } + + /** + * @return QtiComponentCollection + */ + public function getComponents(): QtiComponentCollection + { + return new QtiComponentCollection( + $this->getAssociationValidityConstraints()->getArrayCopy() + ); + } + + public function getExtraData(): array + { + return $this->extraData; + } + + public function setExtraData(array $extraData) + { + $this->extraData = $extraData; + } +} diff --git a/qtism/data/storage/xml/XmlCompactDocument.php b/qtism/data/storage/xml/XmlCompactDocument.php index 32191ae64..a5710a184 100644 --- a/qtism/data/storage/xml/XmlCompactDocument.php +++ b/qtism/data/storage/xml/XmlCompactDocument.php @@ -15,7 +15,7 @@ * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * - * Copyright (c) 2013-2020 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); + * Copyright (c) 2013-2024 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT); * * @author Jérôme Bogaerts * @author Julien Sébire @@ -33,6 +33,8 @@ use qtism\data\content\RubricBlockRef; use qtism\data\ExtendedAssessmentItemRef; use qtism\data\ExtendedAssessmentSection; +use qtism\data\ExtendedAssessmentTest; +use qtism\data\ExtendedTestPart; use qtism\data\QtiComponent; use qtism\data\QtiComponentIterator; use qtism\data\storage\FileResolver; @@ -237,9 +239,12 @@ public static function createFromXmlAssessmentTestDocument(XmlDocument $xmlAsses break; } } + } elseif ($component instanceof TestPart) { + $testPart = ExtendedTestPart::createFromTestPart($component); + $root->getTestParts()->replace($component, $testPart); } elseif ($component === $root) { // 2nd pass on the root, we have to stop. - $compactAssessmentTest->setDocumentComponent($assessmentTest); + $compactAssessmentTest->setDocumentComponent(ExtendedAssessmentTest::createFromAssessmentTest($assessmentTest)); return $compactAssessmentTest; } @@ -276,7 +281,6 @@ protected static function resolveAssessmentItemRef(ExtendedAssessmentItemRef $co } $compactAssessmentItemRef->setResponseValidityConstraints($doc->getDocumentComponent()->getResponseValidityConstraints()); - $compactAssessmentItemRef->setAdaptive($doc->getDocumentComponent()->isAdaptive()); $compactAssessmentItemRef->setTimeDependent($doc->getDocumentComponent()->isTimeDependent()); } catch (Exception $e) { $msg = "An error occurred while unreferencing item reference with identifier '" . $compactAssessmentItemRef->getIdentifier() . "'."; From c460022684928afc8312ee7acb29770dfd293d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Marsza=C5=82?= Date: Wed, 4 Dec 2024 14:40:40 +0100 Subject: [PATCH 3/6] feat: add containsNullOnly --- qtism/runtime/common/State.php | 24 +++++++++++++++++++ qtism/runtime/tests/AssessmentItemSession.php | 3 +++ 2 files changed, 27 insertions(+) diff --git a/qtism/runtime/common/State.php b/qtism/runtime/common/State.php index cd3646d09..a569d428f 100644 --- a/qtism/runtime/common/State.php +++ b/qtism/runtime/common/State.php @@ -190,4 +190,28 @@ protected function checkType($value) throw new InvalidArgumentException($msg); } } + + /** + * Whether or not the State contains NULL only values. + * + * Please note that in QTI terms, empty containers and empty strings are considered + * to be NULL as well. Moreover, if the State is empty of any variable, the method + * will return true. + * + * @return bool + */ + public function containsNullOnly(): bool + { + $data = $this->getDataPlaceHolder(); + + foreach ($data as $variable) { + $variable->getValue(); + + if ($variable->isNull() === false) { + return false; + } + } + + return true; + } } diff --git a/qtism/runtime/tests/AssessmentItemSession.php b/qtism/runtime/tests/AssessmentItemSession.php index 18eb9a9b4..fa5ac775b 100644 --- a/qtism/runtime/tests/AssessmentItemSession.php +++ b/qtism/runtime/tests/AssessmentItemSession.php @@ -1184,6 +1184,9 @@ public function __clone() $this->setDataPlaceHolder($newData); } + /** + * @throws AssessmentItemSessionException + */ public function checkResponseValidityConstraints(State $responses): void { if ($this->getSubmissionMode() === SubmissionMode::INDIVIDUAL && $this->getItemSessionControl()->mustValidateResponses() === true) { From 1af578ec9a787ac878006db549fcc58527fc963b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Marsza=C5=82?= Date: Wed, 4 Dec 2024 18:08:25 +0100 Subject: [PATCH 4/6] feat: add unit tests --- .../data/ExtendedAssessmentItemRefTest.php | 109 ++++++++++++++++++ test/qtismtest/data/ExtendedTestPartTest.php | 81 +++++++++++++ .../data/TestFeedbackRefCollectionTest.php | 63 ++++++++++ .../interactions/AssociateInteractionTest.php | 12 ++ .../interactions/ChoiceInteractionTest.php | 7 ++ .../ExtendedTextInteractionTest.php | 15 +++ .../storage/ExtendedAssessmentTestTest.php | 73 ++++++++++++ 7 files changed, 360 insertions(+) create mode 100644 test/qtismtest/data/ExtendedAssessmentItemRefTest.php create mode 100644 test/qtismtest/data/ExtendedTestPartTest.php create mode 100644 test/qtismtest/data/TestFeedbackRefCollectionTest.php create mode 100644 test/qtismtest/data/storage/ExtendedAssessmentTestTest.php diff --git a/test/qtismtest/data/ExtendedAssessmentItemRefTest.php b/test/qtismtest/data/ExtendedAssessmentItemRefTest.php new file mode 100644 index 000000000..c07963b42 --- /dev/null +++ b/test/qtismtest/data/ExtendedAssessmentItemRefTest.php @@ -0,0 +1,109 @@ +assertInstanceOf(ExtendedAssessmentItemRef::class, $itemRef); + $this->assertEquals($identifier, $itemRef->getIdentifier()); + $this->assertEquals($href, $itemRef->getHref()); + $this->assertInstanceOf(OutcomeDeclarationCollection::class, $itemRef->getOutcomeDeclarations()); + $this->assertInstanceOf(ResponseDeclarationCollection::class, $itemRef->getResponseDeclarations()); + $this->assertInstanceOf(ResponseValidityConstraintCollection::class, $itemRef->getResponseValidityConstraints()); + } + + public function testSetAndGetOutcomeDeclarations() + { + $itemRef = new ExtendedAssessmentItemRef('testIdentifier', 'http://example.com'); + $outcomeDeclarations = new OutcomeDeclarationCollection(); + + $itemRef->setOutcomeDeclarations($outcomeDeclarations); + $this->assertSame($outcomeDeclarations, $itemRef->getOutcomeDeclarations()); + } + + public function testSetAndGetResponseProcessing() + { + $itemRef = new ExtendedAssessmentItemRef('testIdentifier', 'http://example.com'); + $responseProcessing = new ResponseProcessing(); + + $itemRef->setResponseProcessing($responseProcessing); + $this->assertSame($responseProcessing, $itemRef->getResponseProcessing()); + } + + public function testSetAndGetAdaptive() + { + $itemRef = new ExtendedAssessmentItemRef('testIdentifier', 'http://example.com'); + + $itemRef->setAdaptive(true); + $this->assertTrue($itemRef->isAdaptive()); + + $itemRef->setAdaptive(false); + $this->assertFalse($itemRef->isAdaptive()); + } + + public function testSetAndGetTimeDependent() + { + $itemRef = new ExtendedAssessmentItemRef('testIdentifier', 'http://example.com'); + + $itemRef->setTimeDependent(true); + $this->assertTrue($itemRef->isTimeDependent()); + + $itemRef->setTimeDependent(false); + $this->assertFalse($itemRef->isTimeDependent()); + } + + public function testAddAndRemoveOutcomeDeclaration() + { + $itemRef = new ExtendedAssessmentItemRef('testIdentifier', 'http://example.com'); + $outcomeDeclaration = $this->createMock(OutcomeDeclaration::class); + + $itemRef->addOutcomeDeclaration($outcomeDeclaration); + $this->assertTrue($itemRef->getOutcomeDeclarations()->contains($outcomeDeclaration)); + + $itemRef->removeOutcomeDeclaration($outcomeDeclaration); + $this->assertFalse($itemRef->getOutcomeDeclarations()->contains($outcomeDeclaration)); + } + + public function testAddAndRemoveResponseDeclaration() + { + $itemRef = new ExtendedAssessmentItemRef('testIdentifier', 'http://example.com'); + $responseDeclaration = $this->createMock(ResponseDeclaration::class); + + $itemRef->addResponseDeclaration($responseDeclaration); + $this->assertTrue($itemRef->getResponseDeclarations()->contains($responseDeclaration)); + + $itemRef->removeResponseDeclaration($responseDeclaration); + $this->assertFalse($itemRef->getResponseDeclarations()->contains($responseDeclaration)); + } + + public function testAddAndRemoveResponseValidityConstraint() + { + $itemRef = new ExtendedAssessmentItemRef('testIdentifier', 'http://example.com'); + $responseValidityConstraint = $this->createMock(ResponseValidityConstraint::class); + + $itemRef->addResponseValidityConstraint($responseValidityConstraint); + $this->assertTrue($itemRef->getResponseValidityConstraints()->contains($responseValidityConstraint)); + + $itemRef->removeResponseValidityConstraint($responseValidityConstraint); + $this->assertFalse($itemRef->getResponseValidityConstraints()->contains($responseValidityConstraint)); + } +} diff --git a/test/qtismtest/data/ExtendedTestPartTest.php b/test/qtismtest/data/ExtendedTestPartTest.php new file mode 100644 index 000000000..c29d35dc7 --- /dev/null +++ b/test/qtismtest/data/ExtendedTestPartTest.php @@ -0,0 +1,81 @@ +createMock(AssessmentSection::class); + $preConditionCollection = $this->createMock(PreConditionCollection::class); + $branchRuleCollection = $this->createMock(BranchRuleCollection::class); + $itemSessionControl = $this->createMock(ItemSessionControl::class); + $testFeedbackRefCollection = $this->createMock(TestFeedbackCollection::class); + $testPart = $this->createMock(TestPart::class); + + $sectionPartCollection = new SectionPartCollection(); + $sectionPartCollection->attach($assessmentSection); + + + $testPart->method('getIdentifier')->willReturn('testIdentifier'); + $testPart->method('getAssessmentSections')->willReturn($sectionPartCollection); + $testPart->method('getNavigationMode')->willReturn(1); + $testPart->method('getSubmissionMode')->willReturn(1); + $testPart->method('getPreConditions')->willReturn($preConditionCollection); + $testPart->method('getBranchRules')->willReturn($branchRuleCollection); + $testPart->method('getItemSessionControl')->willReturn($itemSessionControl); + $testPart->method('getTestFeedbacks')->willReturn($testFeedbackRefCollection); + + $extendedTestPart = ExtendedTestPart::createFromTestPart($testPart); + + $this->assertInstanceOf(ExtendedTestPart::class, $extendedTestPart); + $this->assertEquals('testIdentifier', $extendedTestPart->getIdentifier()); + } + + public function testAddAndRemoveTestFeedbackRef() + { + $assessmentSection = $this->createMock(AssessmentSection::class); + $sectionPartCollection = new SectionPartCollection(); + $sectionPartCollection->attach($assessmentSection); + + $extendedTestPart = new ExtendedTestPart('testIdentifier', $sectionPartCollection); + $testFeedbackRef = $this->createMock(TestFeedbackRef::class); + + $extendedTestPart->addTestFeedbackRef($testFeedbackRef); + $this->assertCount(1, $extendedTestPart->getTestFeedbackRefs()); + + $extendedTestPart->removeTestFeedbackRef($testFeedbackRef); + $this->assertCount(0, $extendedTestPart->getTestFeedbackRefs()); + } +} diff --git a/test/qtismtest/data/TestFeedbackRefCollectionTest.php b/test/qtismtest/data/TestFeedbackRefCollectionTest.php new file mode 100644 index 000000000..ce0e9f41b --- /dev/null +++ b/test/qtismtest/data/TestFeedbackRefCollectionTest.php @@ -0,0 +1,63 @@ +createMock(TestFeedbackRef::class); + + $collection->attach($testFeedbackRef); + $this->assertTrue($collection->contains($testFeedbackRef)); + } + + public function testAddInvalidTypeThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A TestFeedbackRefCollection object only accepts to store TestFeedbackRef objects.'); + + $collection = new TestFeedbackRefCollection(); + $invalidObject = new \stdClass(); + + $collection->attach($invalidObject); + } + + public function testRemoveTestFeedbackRef() + { + $collection = new TestFeedbackRefCollection(); + $testFeedbackRef = $this->createMock(TestFeedbackRef::class); + + $collection->attach($testFeedbackRef); + $this->assertTrue($collection->contains($testFeedbackRef)); + + $collection->detach($testFeedbackRef); + $this->assertFalse($collection->contains($testFeedbackRef)); + } +} diff --git a/test/qtismtest/data/content/interactions/AssociateInteractionTest.php b/test/qtismtest/data/content/interactions/AssociateInteractionTest.php index d872f1510..838fb59d9 100644 --- a/test/qtismtest/data/content/interactions/AssociateInteractionTest.php +++ b/test/qtismtest/data/content/interactions/AssociateInteractionTest.php @@ -6,6 +6,7 @@ use qtism\data\content\interactions\AssociateInteraction; use qtism\data\content\interactions\SimpleAssociableChoice; use qtism\data\content\interactions\SimpleAssociableChoiceCollection; +use qtism\data\state\ResponseValidityConstraint; use qtismtest\QtiSmTestCase; /** @@ -64,4 +65,15 @@ public function testSetShuffleWrongType() $associateInteraction = new AssociateInteraction('RESPONSE', new SimpleAssociableChoiceCollection([new SimpleAssociableChoice('identifier', 1)])); $associateInteraction->setShuffle('true'); } + + public function testGetResponseValidityConstraint(): void + { + $associateInteraction = new AssociateInteraction( + 'RESPONSE', + new SimpleAssociableChoiceCollection([new SimpleAssociableChoice('identifier', 1)]) + ); + + $responseValidityConstraint = $associateInteraction->getResponseValidityConstraint(); + $this::assertInstanceOf(ResponseValidityConstraint::class, $responseValidityConstraint); + } } diff --git a/test/qtismtest/data/content/interactions/ChoiceInteractionTest.php b/test/qtismtest/data/content/interactions/ChoiceInteractionTest.php index 7d5a9083f..cea36735a 100644 --- a/test/qtismtest/data/content/interactions/ChoiceInteractionTest.php +++ b/test/qtismtest/data/content/interactions/ChoiceInteractionTest.php @@ -6,6 +6,7 @@ use qtism\data\content\interactions\ChoiceInteraction; use qtism\data\content\interactions\SimpleChoice; use qtism\data\content\interactions\SimpleChoiceCollection; +use qtism\data\state\ResponseValidityConstraint; use qtismtest\QtiSmTestCase; /** @@ -56,4 +57,10 @@ public function testSetOrientationWrongType() $choiceInteraction = new ChoiceInteraction('RESPONSE', new SimpleChoiceCollection([new SimpleChoice('identifier')])); $choiceInteraction->setOrientation(true); } + + public function testGetResponseValidityConstraint(): void + { + $choiceInteraction = new ChoiceInteraction('RESPONSE', new SimpleChoiceCollection([new SimpleChoice('identifier')])); + $this->assertInstanceOf(ResponseValidityConstraint::class, $choiceInteraction->getResponseValidityConstraint()); + } } diff --git a/test/qtismtest/data/content/interactions/ExtendedTextInteractionTest.php b/test/qtismtest/data/content/interactions/ExtendedTextInteractionTest.php index d38010407..e4285c444 100644 --- a/test/qtismtest/data/content/interactions/ExtendedTextInteractionTest.php +++ b/test/qtismtest/data/content/interactions/ExtendedTextInteractionTest.php @@ -4,6 +4,7 @@ use InvalidArgumentException; use qtism\data\content\interactions\ExtendedTextInteraction; +use qtism\data\state\ResponseValidityConstraint; use qtismtest\QtiSmTestCase; /** @@ -178,4 +179,18 @@ public function testSetFormatWrongType() $extendedTextInteraction->setFormat(999); } + + public function testGetResponseValidityConstraint() + { + $extendedTextInteraction = new ExtendedTextInteraction('RESPONSE'); + $extendedTextInteraction->setMinStrings(10); + $extendedTextInteraction->setMaxStrings(20); + $extendedTextInteraction->setPatternMask('mask'); + + $responseValidityConstraint = $extendedTextInteraction->getResponseValidityConstraint(); + $this->assertInstanceOf(ResponseValidityConstraint::class, $responseValidityConstraint); + $this->assertEquals($responseValidityConstraint->getMinConstraint(), 10); + $this->assertEquals($responseValidityConstraint->getMaxConstraint(), 20); + $this->assertEquals($responseValidityConstraint->getPatternMask(), 'mask'); + } } diff --git a/test/qtismtest/data/storage/ExtendedAssessmentTestTest.php b/test/qtismtest/data/storage/ExtendedAssessmentTestTest.php new file mode 100644 index 000000000..4caa880a6 --- /dev/null +++ b/test/qtismtest/data/storage/ExtendedAssessmentTestTest.php @@ -0,0 +1,73 @@ +createMock(AssessmentTest::class); + $outcomeProcessing = $this->createMock(OutcomeProcessing::class); + $testFeedbackCollection = $this->createMock(TestFeedbackCollection::class); + $outcomeDeclarationCollection = $this->createMock(OutcomeDeclarationCollection::class); + $assessmentTest->method('getIdentifier')->willReturn($identifier); + $assessmentTest->method('getTitle')->willReturn($title); + $assessmentTest->method('getTestParts')->willReturn($testParts); + $assessmentTest->method('getOutcomeProcessing')->willReturn($outcomeProcessing); + $assessmentTest->method('getOutcomeDeclarations')->willReturn($outcomeDeclarationCollection); + $assessmentTest->method('getTestFeedbacks')->willReturn($testFeedbackCollection); + $assessmentTest->method('getToolName')->willReturn('Tool Name'); + $assessmentTest->method('getToolVersion')->willReturn('1.0'); + + $extendedTest = ExtendedAssessmentTest::createFromAssessmentTest($assessmentTest); + + $this->assertInstanceOf(ExtendedAssessmentTest::class, $extendedTest); + $this->assertEquals($identifier, $extendedTest->getIdentifier()); + $this->assertEquals($title, $extendedTest->getTitle()); + $this->assertEquals($testParts, $extendedTest->getTestParts()); + } + + public function testAddAndRemoveTestFeedbackRef() + { + $extendedTest = new ExtendedAssessmentTest('test-id', 'Test Title'); + $testFeedbackRef = $this->createMock(TestFeedbackRef::class); + + $extendedTest->addTestFeedbackRef($testFeedbackRef); + $this->assertCount(1, $extendedTest->getTestFeedbackRefs()); + + $extendedTest->removeTestFeedbackRef($testFeedbackRef); + $this->assertCount(0, $extendedTest->getTestFeedbackRefs()); + } +} From af57a98f6a05eaecad8dafa8517ad58c3652a554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Marsza=C5=82?= Date: Mon, 9 Dec 2024 09:40:46 +0100 Subject: [PATCH 5/6] feat: tests/Utils --- qtism/runtime/tests/Utils.php | 164 ++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 qtism/runtime/tests/Utils.php diff --git a/qtism/runtime/tests/Utils.php b/qtism/runtime/tests/Utils.php new file mode 100644 index 000000000..28895ab82 --- /dev/null +++ b/qtism/runtime/tests/Utils.php @@ -0,0 +1,164 @@ + + * @license GPLv2 + */ + +namespace qtism\runtime\tests; + +use qtism\common\datatypes\QtiDatatype; +use qtism\common\enums\BaseType; +use qtism\common\enums\Cardinality; +use qtism\data\state\ResponseValidityConstraint; +use qtism\runtime\common\Utils as RuntimeUtils; +use qtism\runtime\expressions\operators\Utils as OperatorUtils; +use RuntimeException; + +/** + * Utility methods for Tests. + */ +class Utils +{ + private const MAX_ENTRY_RESTRICTION_PATTERN = '/^\/\^(?P\([^{]+)\{(?P\d+)(?:(?P,)|,(?P\d+))?\}\$\/\w*$/'; + private const SOURCE_MAX_WORDS_SPLIT_PATTERN = '(?:(?:[^\s\:\!\?\;\…\€]+)[\s\:\!\?\;\…\€]*)'; + private const MAX_WORDS_SPLIT_PATTERN = '/[\s.,:;?!&#%\/*+=]+/'; + private const CLOSE_MATCH_GROUP_TOKEN = ')'; + private const OPEN_MATCH_GROUP_TOKEN = '('; + + /** + * Whether a QtiDatatype object is considered valid against a given ResponseValidityConstraint object $constraint. + * + * Min and Max constraints will be checked first, followed by the patternMask check. + * + * Please note that pattern masks described by the $constraint will only be applied on variables with the + * QTI String baseType. In case of a patternMask to be applied on a Multiple or Ordered Container, the patternMask + * will be applied on all String values within the Container. All String values have to comply with the patternMask + * to see the whole Container being validated. In case of an Empty Multiple or Ordered Container with a PatternMask, + * the method will return true as there is no String values to be validated. PatternMask are not checked against Record + * Containers. + * + * Moreover, null values given as a $response will be considered to have no cardinality i.e. count($response) = 0. + * + * @param QtiDatatype|null $response + * @param ResponseValidityConstraint $constraint + * @return bool + */ + public static function isResponseValid(QtiDatatype $response = null, ResponseValidityConstraint $constraint): bool + { + $min = $constraint->getMinConstraint(); + $max = $constraint->getMaxConstraint(); + $cardinality = ($response === null) ? Cardinality::SINGLE : $response->getCardinality(); + + if (($isNull = RuntimeUtils::isNull($response)) === true) { + $count = 0; + } elseif ($cardinality === Cardinality::SINGLE || $cardinality === Cardinality::RECORD) { + $count = 1; + } else { + $count = count($response); + } + + // Cardinality check... + if ($count < $min || ($max !== 0 && $count > $max)) { + return false; + } + + // Pattern Mask check... + if (($patternMask = $constraint->getPatternMask()) !== '' && $isNull === false && ($response->getBaseType() === BaseType::STRING || $response->getBaseType() === -1 && isset($response['stringValue']))) { + if ($response->getCardinality() === Cardinality::RECORD) { + // Record cadinality, only used in conjunction with stringInteraction in core QTI (edge-case). + $values = [$response['stringValue']]; + } else { + // Single, Multiple, or Ordered cardinality. + $values = ($cardinality === Cardinality::SINGLE) ? [$response->getValue()] : $response->getArrayCopy(); + } + + $patternMask = OperatorUtils::prepareXsdPatternForPcre($patternMask); + + $isMaxEntryRestriction = preg_match(self::MAX_ENTRY_RESTRICTION_PATTERN, $patternMask, $matches) + && self::isSingleMatchGroup($patternMask) + && $matches['splitPattern'] === self::SOURCE_MAX_WORDS_SPLIT_PATTERN; + + + foreach ($values as $value) { + if ($isMaxEntryRestriction) { + [$min, $max] = self::extractMaxEntryRestrictions($matches); + $entries = count(array_filter(preg_split(self::MAX_WORDS_SPLIT_PATTERN, $value))); + if ($entries > $max || $entries < $min) { + return false; + } + } else { + $result = @preg_match($patternMask, (string)$value); + + if ($result === 0) { + return false; + } elseif ($result === false) { + throw new RuntimeException(OperatorUtils::lastPregErrorMessage()); + } + } + } + } + + // Associations check... + if ($response !== null && $cardinality !== Cardinality::RECORD && ($response->getBaseType() === BaseType::PAIR || $response->getBaseType() === BaseType::DIRECTED_PAIR)) { + $toCheck = ($cardinality === Cardinality::SINGLE) ? [$response] : $response->getArrayCopy(); + + foreach ($constraint->getAssociationValidityConstraints() as $associationConstraint) { + $associations = 0; + $identifier = $associationConstraint->getIdentifier(); + + foreach ($toCheck as $pair) { + if ($pair->getFirst() === $identifier) { + $associations++; + } + + if ($pair->getSecond() === $identifier) { + $associations++; + } + } + + $min = $associationConstraint->getMinConstraint(); + $max = $associationConstraint->getMaxConstraint(); + if ($associations < $min || ($max !== 0 && $associations > $max)) { + return false; + } + } + } + + return true; + } + + private static function isSingleMatchGroup(string $patternMask): bool + { + $closeBracketPosition = strpos($patternMask, self::CLOSE_MATCH_GROUP_TOKEN); + return strpos(substr($patternMask, $closeBracketPosition), self::OPEN_MATCH_GROUP_TOKEN) === false; + } + + /** + * @return array [(string)$splitPattern, (int)$min, (int)$max] + */ + private static function extractMaxEntryRestrictions(array $matches): array + { + extract($matches); + $isRange = !empty($isRange); + $max ??= $isRange ? PHP_INT_MAX : $min; + + return [(int)$min, (int)$max]; + } +} From 8a4c1eeb04b65b6d4af4794af3437761acdfe576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Marsza=C5=82?= Date: Thu, 19 Dec 2024 17:45:04 +0100 Subject: [PATCH 6/6] feat: move interaction classes to const. --- qtism/data/AssessmentItem.php | 38 +++++++++---------- .../interactions/GapMatchInteraction.php | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/qtism/data/AssessmentItem.php b/qtism/data/AssessmentItem.php index 6b79a8cd2..11dbbb014 100644 --- a/qtism/data/AssessmentItem.php +++ b/qtism/data/AssessmentItem.php @@ -44,6 +44,24 @@ class AssessmentItem extends QtiComponent implements QtiIdentifiable, IAssessmen { use QtiIdentifiableTrait; + private const INTERACTION_CLASS_NAMES = [ + 'choiceInteraction', + 'orderInteraction', + 'associateInteraction', + 'matchInteraction', + 'inlineChoiceInteraction', + 'textEntryInteraction', + 'extendedTextInteraction', + 'hottextInteraction', + 'hotspotInteraction', + 'selectPointInteraction', + 'graphicOrderInteraction', + 'graphicAssociateInteraction', + 'positionObjectInteraction', + 'gapMatchInteraction', + 'graphicGapMatchInteraction', + ]; + /** * From IMS QTI: * @@ -661,26 +679,8 @@ public function getModalFeedbacks() public function getResponseValidityConstraints(): ResponseValidityConstraintCollection { - $classNames = [ - 'choiceInteraction', - 'orderInteraction', - 'associateInteraction', - 'matchInteraction', - 'inlineChoiceInteraction', - 'textEntryInteraction', - 'extendedTextInteraction', - 'hottextInteraction', - 'hotspotInteraction', - 'selectPointInteraction', - 'graphicOrderInteraction', - 'graphicAssociateInteraction', - 'positionObjectInteraction', - 'gapMatchInteraction', - 'graphicGapMatchInteraction', - ]; - $responseValidityConstraints = new ResponseValidityConstraintCollection(); - foreach ($this->getComponentsByClassName($classNames) as $component) { + foreach ($this->getComponentsByClassName(self::INTERACTION_CLASS_NAMES) as $component) { $responseValidityConstraints[] = $component->getResponseValidityConstraint(); } diff --git a/qtism/data/content/interactions/GapMatchInteraction.php b/qtism/data/content/interactions/GapMatchInteraction.php index fef71c019..2a63a5f4d 100644 --- a/qtism/data/content/interactions/GapMatchInteraction.php +++ b/qtism/data/content/interactions/GapMatchInteraction.php @@ -190,7 +190,7 @@ public function getComponents() return new QtiComponentCollection(array_merge($parentComponents->getArrayCopy(), $this->getGapChoices()->getArrayCopy(), $this->getContent()->getArrayCopy())); } - public function getResponseValidityConstraint(): ?ResponseValidityConstraint + public function getResponseValidityConstraint(): ResponseValidityConstraint { $responseValidityConstraint = new ResponseValidityConstraint( $this->getResponseIdentifier(),