*/ class TestSession extends taoQtiTest_helpers_TestSession implements UserUriAware { use LoggerAwareTrait; /** * The Timer bound to the test session * @var QtiTimer */ protected $timer; /** * The target from which compute the durations * @var int */ protected $timerTarget; /** * A temporary cache for computed durations * @var array */ protected $durationCache = []; /** * The URI (Uniform Resource Identifier) of the user the Test Session belongs to. * * @var string */ private $userUri; /** * Get the URI (Uniform Resource Identifier) of the user the Test Session belongs to. * * @return string */ public function getUserUri() { if (is_null($this->userUri)) { return \common_session_SessionManager::getSession()->getUserUri(); } return $this->userUri; } /** * Set the URI (Uniform Resource Identifier) of the user the Test Session belongs to. * * @param string $userUri */ public function setUserUri($userUri) { $this->userUri = $userUri; } /** * Gets the Timer bound to the test session * @return QtiTimer */ public function getTimer() { if (!$this->timer) { $qtiTimerFactory = $this->getServiceLocator()->get(QtiTimerFactory::SERVICE_ID); $this->timer = $qtiTimerFactory->getTimer($this->getSessionId(), $this->getUserUri()); } return $this->timer; } /** * Gets the target from which compute the durations * @return int */ public function getTimerTarget() { if (is_null($this->timerTarget)) { $testConfig = $this->getServiceLocator()->get(QtiRunnerConfig::SERVICE_ID); $config = $testConfig->getConfigValue('timer'); switch (strtolower($config['target'])) { case 'client': $target = TimePoint::TARGET_CLIENT; break; case 'server': default: $target = TimePoint::TARGET_SERVER; } $this->setTimerTarget($target); } return $this->timerTarget; } /** * Set the target from which compute the durations * @param int $timerTarget */ public function setTimerTarget($timerTarget) { $this->timerTarget = intval($timerTarget); } /** * Gets the tags describing a particular item with an assessment test * @param RouteItem $routeItem * @return array */ public function getItemTags(RouteItem $routeItem) { $test = $routeItem->getAssessmentTest(); $testPart = $routeItem->getTestPart(); $sections = $routeItem->getAssessmentSections(); $sections->rewind(); $sectionId = key(current($sections)); $itemRef = $routeItem->getAssessmentItemRef(); $itemId = $itemRef->getIdentifier(); $occurrence = $routeItem->getOccurence(); $tags = [ $itemId, $itemId . '#' . $occurrence, $sectionId, $testPart->getIdentifier(), $test->getIdentifier(), ]; if ($this->isRunning() === true) { $tags[] = $this->getItemAttemptTag($routeItem); } return $tags; } /** * Gets the item tags for its last occurrence * @param RouteItem $routeItem * @return string */ public function getItemAttemptTag(RouteItem $routeItem) { $itemRef = $routeItem->getAssessmentItemRef(); $itemId = $itemRef->getIdentifier(); $occurrence = $routeItem->getOccurence(); $itemSession = $this->getAssessmentItemSessionStore()->getAssessmentItemSession($itemRef, $occurrence); return $itemId . '#' . $occurrence . '-' . $itemSession['numAttempts']->getValue(); } /** * Initializes the timer for the current item in the TestSession * * @param $timestamp * @throws \oat\taoTests\models\runner\time\InvalidDataException */ public function initItemTimer($timestamp = null) { if (is_null($timestamp)) { $timestamp = microtime(true); } // try to close existing time range if any, in order to be sure the test will start or restart a new range. // if the range is already closed, a message will be added to the log $tags = $this->getItemTags($this->getCurrentRouteItem()); $this->getTimer()->end($tags, $timestamp)->save(); } /** * Starts the timer for the current item in the TestSession * * @param $timestamp */ public function startItemTimer($timestamp = null) { if (is_null($timestamp)) { $timestamp = microtime(true); } $tags = $this->getItemTags($this->getCurrentRouteItem()); $this->getTimer()->start($tags, $timestamp)->save(); } /** * Ends the timer for the current item in the TestSession. * Sets the client duration for the current item in the TestSession. * * @param float $duration The client duration, or null to force server duration to be used as client duration * @param $timestamp * @throws \oat\taoTests\models\runner\time\InvalidStorageException * @throws \oat\taoTests\models\runner\time\TimeException */ public function endItemTimer($duration = null, $timestamp = null) { if (is_null($timestamp)) { $timestamp = microtime(true); } $timer = $this->getTimer(); $currentItem = $this->getCurrentRouteItem(); if ($currentItem) { $tags = $this->getItemTags($currentItem); } else { $tags = []; } $timer->end($tags, $timestamp); if (is_numeric($duration) || is_null($duration)) { if (!is_null($duration)) { $duration = floatval($duration); } try { $timer->adjust($tags, $duration); } catch (\oat\taoTests\models\runner\time\TimeException $e) { $this->logAlert($e->getMessage() . '; Test session identifier: ' . $this->getSessionId()); } } $constraints = $this->getTimeConstraints(); $maxTime = 0; /** @var QtiTimeConstraint $constraint */ foreach ($constraints as $constraint) { if (($maximumTime = $constraint->getAdjustedMaxTime()) !== null) { $maxTime = $maximumTime->getSeconds(true); } } $this->getTimer()->getConsumedExtraTime($tags, $maxTime); $this->updateCurrentDurationCache(); $timer->save(); } /** * Gets the timer duration for a particular identifier * @param string|array $identifier * @param int $target * @return QtiDuration * @throws \oat\taoTests\models\runner\time\TimeException */ public function getTimerDuration($identifier, $target = 0) { if (!$target) { $target = $this->getTimerTarget(); } $durationKey = $this->getDurationKey($identifier, $target); if (!isset($this->durationCache[$durationKey])) { $this->updateDurationCache($identifier, $target); } return $this->durationCache[$durationKey]; } /** * Gets the timer duration key for a particular identifier * @param string|array $identifier * @param int $target * @return string */ protected function getDurationKey($identifier, int $target): string { $durationKey = $target . '-'; if (is_array($identifier)) { sort($identifier); $durationKey .= implode('-', $identifier); } else { $durationKey .= $identifier; } return $durationKey; } /** * Updates the duration cache for a particular identifier * @param string|array $identifier * @param int $target * @return float * @throws \oat\taoTests\models\runner\time\TimeException */ protected function updateDurationCache($identifier, int $target): float { $duration = round($this->getTimer()->compute($identifier, $target), 6); $durationKey = $this->getDurationKey($identifier, $target); $this->durationCache[$durationKey] = new QtiDuration('PT' . $duration . 'S'); return $duration; } /** * Updates the duration cache for all identifiers from the current context * @throws \oat\taoTests\models\runner\time\TimeException */ protected function updateCurrentDurationCache() { $target = $this->getTimerTarget(); $routeItem = $this->getCurrentRouteItem(); $sources = [ $routeItem->getAssessmentTest(), $this->getCurrentTestPart(), $this->getCurrentAssessmentSection(), $routeItem->getAssessmentItemRef(), ]; foreach ($sources as $source) { $this->updateDurationCache($source->getIdentifier(), $target); } } /** * Gets the total duration for the current item in the TestSession * @param int $target * @return QtiDuration * @throws \oat\taoTests\models\runner\time\InconsistentCriteriaException */ public function computeItemTime($target = 0) { $currentItem = $this->getCurrentAssessmentItemRef(); return $this->getTimerDuration($currentItem->getIdentifier(), $target); } /** * Gets the total duration for the current section in the TestSession * @param int $target * @return QtiDuration * @throws \oat\taoTests\models\runner\time\InconsistentCriteriaException */ public function computeSectionTime($target = 0) { $routeItem = $this->getCurrentRouteItem(); $sections = $routeItem->getAssessmentSections(); $sections->rewind(); return $this->getTimerDuration(key(current($sections)), $target); } /** * Gets the total duration for the current test part in the TestSession * @param int $target * @return QtiDuration * @throws \oat\taoTests\models\runner\time\InconsistentCriteriaException */ public function computeTestPartTime($target = 0) { $routeItem = $this->getCurrentRouteItem(); $testPart = $routeItem->getTestPart(); return $this->getTimerDuration($testPart->getIdentifier(), $target); } /** * Gets the total duration for the whole assessment test * @param int $target * @return QtiDuration * @throws \oat\taoTests\models\runner\time\InconsistentCriteriaException */ public function computeTestTime($target = 0) { $routeItem = $this->getCurrentRouteItem(); $test = $routeItem->getAssessmentTest(); return $this->getTimerDuration($test->getIdentifier(), $target); } /** * Update the durations involved in the AssessmentTestSession to mirror the durations at the current time. * This method can be useful for stateless systems that make use of QtiSm. */ public function updateDuration() { // not needed anymore \common_Logger::t('Call to disabled updateDuration()'); } /** * Gets a TimeConstraint from a particular source * @param $source * @param $navigationMode * @param $considerMinTime * @param $applyExtraTime * @return TimeConstraint * @throws \oat\taoTests\models\runner\time\InconsistentCriteriaException */ protected function getTimeConstraint($source, $navigationMode, $considerMinTime, $applyExtraTime = true) { $constraint = new QtiTimeConstraint( $source, $this->getTimerDuration($source->getIdentifier()), $navigationMode, $considerMinTime, $applyExtraTime, $this->getTimerTarget() ); $constraint->setTimer($this->getTimer()); return $constraint; } /** * Builds the time constraints running for the current testPart or/and current assessmentSection * or/and assessmentItem. Takes care of the extra time if needed. * * @param integer $places A composition of values (use | operator) from the AssessmentTestPlace enumeration. If the null value is given, all places will be taken into account. * @param boolean $applyExtraTime Allow to take care of extra time * @return TimeConstraintCollection A collection of TimeConstraint objects. * @qtism-test-duration-update */ protected function buildTimeConstraints($places = null, $applyExtraTime = true) { if ($places === null) { // Get the constraints from all places in the Assessment Test. $places = (AssessmentTestPlace::ASSESSMENT_TEST | AssessmentTestPlace::TEST_PART | AssessmentTestPlace::ASSESSMENT_SECTION | AssessmentTestPlace::ASSESSMENT_ITEM); } $constraints = new TimeConstraintCollection(); $navigationMode = $this->getCurrentNavigationMode(); $routeItem = $this->getCurrentRouteItem(); $considerMinTime = $this->mustConsiderMinTime(); if (($places & AssessmentTestPlace::ASSESSMENT_TEST) && ($routeItem instanceof RouteItem)) { $constraints[] = $this->getTimeConstraint($routeItem->getAssessmentTest(), $navigationMode, $considerMinTime, $applyExtraTime); } $currentTestPart = $this->getCurrentTestPart(); if (($places & AssessmentTestPlace::TEST_PART) && ($currentTestPart instanceof TestPart)) { $constraints[] = $this->getTimeConstraint($currentTestPart, $navigationMode, $considerMinTime, $applyExtraTime); } $currentAssessmentSection = $this->getCurrentAssessmentSection(); if (($places & AssessmentTestPlace::ASSESSMENT_SECTION) && ($currentAssessmentSection instanceof AssessmentSection)) { $constraints[] = $this->getTimeConstraint($currentAssessmentSection, $navigationMode, $considerMinTime, $applyExtraTime); } if (($places & AssessmentTestPlace::ASSESSMENT_ITEM) && ($routeItem instanceof RouteItem)) { $constraints[] = $this->getTimeConstraint($routeItem->getAssessmentItemRef(), $navigationMode, $considerMinTime, $applyExtraTime); } return $constraints; } /** * Get the time constraints running for the current testPart or/and current assessmentSection * or/and assessmentItem. The extra time is taken into account. * * @param integer $places A composition of values (use | operator) from the AssessmentTestPlace enumeration. If the null value is given, all places will be taken into account. * @return TimeConstraintCollection A collection of TimeConstraint objects. * @qtism-test-duration-update */ public function getTimeConstraints($places = null) { return $this->buildTimeConstraints($places, true); } /** * Get the regular time constraints running for the current testPart or/and current assessmentSection * or/and assessmentItem, without taking care of the extra time. * * @param integer $places A composition of values (use | operator) from the AssessmentTestPlace enumeration. If the null value is given, all places will be taken into account. * @return TimeConstraintCollection A collection of TimeConstraint objects. * @qtism-test-duration-update */ public function getRegularTimeConstraints($places = null) { return $this->buildTimeConstraints($places, false); } /** * Whether or not the current Assessment Item to be presented to the candidate is timed-out. By timed-out * we mean: * * * current Assessment Test level time limits are not respected OR, * * current Test Part level time limits are not respected OR, * * current Assessment Section level time limits are not respected OR, * * current Assessment Item level time limits are not respected. * * @return boolean */ public function isTimeout() { try { $this->checkTimeLimits(false, true, false); } catch (AssessmentTestSessionException $e) { return true; } return false; } /** * AssessmentTestSession implementations must override this method in order * to submit item results from a given $assessmentItemSession to the appropriate * data source. * * This method is triggered each time response processing takes place. * * @param AssessmentItemSession $itemSession The lastly updated AssessmentItemSession. * @param integer $occurrence The occurrence number of the item bound to $assessmentItemSession. * @throws AssessmentTestSessionException With error code RESULT_SUBMISSION_ERROR if an error occurs while transmitting results. */ public function submitItemResults(AssessmentItemSession $itemSession, $occurrence = 0) { $itemRef = $itemSession->getAssessmentItem(); // Ensure that specific results from adaptive placeholders are not recorded. $catService = ServiceManager::getServiceManager()->get(CatService::SERVICE_ID); if (!$catService->isAdaptivePlaceholder($itemRef)) { $identifier = $itemRef->getIdentifier(); $duration = $this->getTimerDuration($identifier); $itemDurationVar = $itemSession->getVariable('duration'); $sessionDuration = $itemDurationVar->getValue(); \common_Logger::t("Force duration of item '${identifier}' to ${duration} instead of ${sessionDuration}"); $itemSession->getVariable('duration')->setValue($duration); parent::submitItemResults($itemSession, $occurrence); } } /** * QTISM endTestSession method overriding. * * It consists of including an additional processing when the test ends, * in order to send the LtiOutcome * * @see http://www.imsglobal.org/lis/ Outcome Management Service * @throws \taoQtiTest_helpers_TestSessionException If the session is already ended or if an error occurs whil transmitting/processing the result. */ public function endTestSession() { // try to close existing time range if any, in order to be sure the test will be closed with a consistent timer. // if the range is already closed, a message will be added to the log if ($this->isRunning() === true) { $route = $this->getRoute(); if ($route->valid()) { $routeItem = $this->getCurrentRouteItem(); } if (isset($routeItem)) { $this->endItemTimer(); } } parent::endTestSession(); } }