*/ namespace oat\taoQtiTest\models\runner\time; use oat\taoTests\models\runner\time\ExtraTime; use oat\taoTests\models\runner\time\InconsistentCriteriaException; use oat\taoTests\models\runner\time\InconsistentRangeException; use oat\taoTests\models\runner\time\InvalidDataException; use oat\taoTests\models\runner\time\InvalidStorageException; use oat\taoTests\models\runner\time\InvalidTimerStrategyException; use oat\taoTests\models\runner\time\TimeException; use oat\taoTests\models\runner\time\TimeLine; use oat\taoTests\models\runner\time\TimePoint; use oat\taoTests\models\runner\time\TimerAdjustmentMapInterface; use oat\taoTests\models\runner\time\TimerStrategyInterface; use oat\taoTests\models\runner\time\TimeStorage; use oat\taoTests\models\runner\time\Timer; /** * Class QtiTimer * @package oat\taoQtiTest\models\runner\time */ class QtiTimer implements Timer, ExtraTime, \JsonSerializable { /** * The TimeLine used to compute the duration * @var TimeLine */ protected $timeLine; /** * The storage used to maintain the data * @var TimeStorage */ protected $storage; /** * @var TimerStrategyInterface */ protected $timerStrategy; /** * The total added extra time * @var float */ protected $extraTime = 0.0; /** * The extended time * @var float */ protected $extendedTime = 0.0; /** * The already consumed extra time * @var float */ protected $consumedExtraTime = 0.0; /** * @var AdjustmentMap */ protected $adjustmentMap; /** * QtiTimer constructor. */ public function __construct() { $this->timeLine = new QtiTimeLine(); $this->adjustmentMap = new AdjustmentMap(); } /** * Adds a "server start" TimePoint at a particular timestamp for the provided ItemRef * @param string|array $tags * @param float $timestamp * @return Timer * @throws TimeException */ public function start($tags, $timestamp) { // check the provided arguments if (!is_numeric($timestamp) || $timestamp < 0) { throw new InvalidDataException('start() needs a valid timestamp!'); } // extract the TimePoint identification from the provided item, and find existing range $range = $this->getRange($tags); // validate the data consistence if ($this->isRangeOpen($range)) { // unclosed range found, auto closing // auto generate the timestamp for the missing END point, one microsecond earlier \common_Logger::t('Missing END TimePoint in QtiTimer, auto add an arbitrary value'); $point = new TimePoint($tags, $timestamp - (1 / TimePoint::PRECISION), TimePoint::TYPE_END, TimePoint::TARGET_SERVER); $this->timeLine->add($point); $range[] = $point; } $this->checkTimestampCoherence($range, $timestamp); // append the new START TimePoint $point = new TimePoint($tags, $timestamp, TimePoint::TYPE_START, TimePoint::TARGET_SERVER); $this->timeLine->add($point); return $this; } /** * Adds a "server end" TimePoint at a particular timestamp for the provided ItemRef * @param string|array $tags * @param float $timestamp * @return Timer * @throws TimeException */ public function end($tags, $timestamp) { // check the provided arguments if (!is_numeric($timestamp) || $timestamp < 0) { throw new InvalidDataException('end() needs a valid timestamp!'); } // extract the TimePoint identification from the provided item, and find existing range $range = $this->getRange($tags); // validate the data consistence if ($this->isRangeOpen($range)) { $this->checkTimestampCoherence($range, $timestamp); // append the new END TimePoint $point = new TimePoint($tags, $timestamp, TimePoint::TYPE_END, TimePoint::TARGET_SERVER); $this->timeLine->add($point); } else { // already closed range found, just log the info \common_Logger::t('Range already closed, or missing START TimePoint in QtiTimer, continue anyway'); } return $this; } /** * Gets the first timestamp of the range for the provided tags * @param string|array $tags * @return float $timestamp */ public function getFirstTimestamp($tags) { // extract the TimePoint identification from the provided item, and find existing range $range = $this->getRange($tags); $last = false; if (count($range)) { $last = $range[0]->getTimestamp(); } return $last; } /** * Gets the last timestamp of the range for the provided tags * @param string|array $tags * @return bool|float $timestamp Returns the last timestamp of the range or false if none */ public function getLastTimestamp($tags) { // extract the TimePoint identification from the provided item, and find existing range $range = $this->getRange($tags); $length = count($range); $last = false; if ($length) { $last = $range[$length - 1]->getTimestamp(); } return $last; } /** * Gets the last registered timestamp * @return bool|float $timestamp Returns the last timestamp or false if none */ public function getLastRegisteredTimestamp() { $points = $this->timeLine->getPoints(); $length = count($points); $last = false; if ($length) { $last = end($points)->getTimestamp(); } return $last; } /** * Adds "client start" and "client end" TimePoint based on the provided duration for a particular ItemRef * @param string|array $tags * @param float $duration * @return Timer * @throws TimeException */ public function adjust($tags, $duration) { // check the provided arguments if (!is_null($duration) && (!is_numeric($duration) || $duration < 0)) { throw new InvalidDataException('adjust() needs a valid duration!'); } // extract the TimePoint identification from the provided item, and find existing range $itemTimeLine = $this->timeLine->filter($tags, TimePoint::TARGET_SERVER); $range = $itemTimeLine->getPoints(); // validate the data consistence $rangeLength = count($range); if (!$rangeLength || ($rangeLength % 2)) { throw new InconsistentRangeException('The time range does not seem to be consistent, the range is not complete!'); } $serverDuration = $itemTimeLine->compute(); // take care of existing client range $clientTimeLine = $this->timeLine->filter($tags, TimePoint::TARGET_CLIENT); $clientRange = $clientTimeLine->getPoints(); $clientRangeLength = count($clientRange); if ($clientRangeLength) { $clientDuration = 0; try { $clientDuration = $clientTimeLine->compute(); } catch (TimeException $e) { \common_Logger::t('Handled client range error'); } if (is_null($duration)) { if ($clientDuration) { $duration = $clientDuration; \common_Logger::t("No client duration provided to adjust the timer, but a range already exist: ${duration}"); } else { $duration = $serverDuration; \common_Logger::t("No client duration provided to adjust the timer, fallback to server duration: ${duration}"); } } $removed = $this->timeLine->remove($tags, TimePoint::TARGET_CLIENT); if ($removed == $clientRangeLength) { \common_Logger::t("Replace client duration in timer: ${clientDuration} to ${duration}"); } else { \common_Logger::w("Unable to replace client duration in timer: ${clientDuration} to ${duration}"); } } // check if the client side duration is bound by the server side duration if (is_null($duration)) { $duration = $serverDuration; \common_Logger::t("No client duration provided to adjust the timer, fallback to server duration: ${duration}"); } elseif ($duration > $serverDuration) { \common_Logger::w("A client duration must not be larger than the server time range! (${duration} > ${serverDuration})"); $duration = $serverDuration; } // extract range boundaries TimePoint::sort($range); $serverStart = $range[0]; $serverEnd = $range[$rangeLength - 1]; // adjust the range by inserting the client duration between the server overall time range boundaries $overallDuration = $serverEnd->getTimestamp() - $serverStart->getTimestamp(); $delay = ($overallDuration - $duration) / 2; $start = new TimePoint($tags, $serverStart->getTimestamp() + $delay, TimePoint::TYPE_START, TimePoint::TARGET_CLIENT); $this->timeLine->add($start); $end = new TimePoint($tags, $serverEnd->getTimestamp() - $delay, TimePoint::TYPE_END, TimePoint::TARGET_CLIENT); $this->timeLine->add($end); return $this; } /** * Computes the total duration represented by the filtered TimePoints * @param string|array $tags A tag or a list of tags to filter * @param int $target The type of target TimePoint to filter * @return float Returns the total computed duration * @throws TimeException */ public function compute($tags, $target) { // cannot compute a duration across different targets if (!$this->onlyOneFlag($target)) { throw new InconsistentCriteriaException('Cannot compute a duration across different targets!'); } return $this->timeLine->compute($tags, $target); } /** * Checks if the duration of a TimeLine subset reached the timeout * @param float $timeLimit The time limit against which compare the duration * @param string|array $tags A tag or a list of tags to filter * @param int $target The type of target TimePoint to filter * @return bool Returns true if the timeout is reached * @throws TimeException */ public function timeout($timeLimit, $tags, $target) { $duration = $this->compute($tags, $target); return $duration >= $timeLimit; } /** * Sets the storage used to maintain the data * @param TimeStorage $storage * @return Timer */ public function setStorage(TimeStorage $storage) { $this->storage = $storage; return $this; } /** * @inheritDoc */ public function setStrategy(TimerStrategyInterface $strategy) { $this->timerStrategy = $strategy; return $this; } /** * Gets the storage used to maintain the data * @return TimeStorage */ public function getStorage() { return $this->storage; } /** * Exports the internal state to an array * @return array */ public function toArray() { return [ QtiTimeStorageFormat::STORAGE_KEY_TIME_LINE => $this->timeLine, QtiTimeStorageFormat::STORAGE_KEY_EXTRA_TIME => $this->extraTime, QtiTimeStorageFormat::STORAGE_KEY_EXTENDED_TIME => $this->extendedTime, QtiTimeStorageFormat::STORAGE_KEY_CONSUMED_EXTRA_TIME => $this->consumedExtraTime, QtiTimeStorageFormat::STORAGE_KEY_TIMER_ADJUSTMENT_MAP => $this->adjustmentMap, ]; } /** * Specify data which should be serialized to JSON * @link http://php.net/manual/en/jsonserializable.jsonserialize.php * @return mixed data which can be serialized by json_encode, * which is a value of any type other than a resource. * @since 5.4.0 */ public function jsonSerialize() { return $this->toArray(); } /** * Saves the data to the storage * @return Timer * @throws InvalidStorageException */ public function save() { if (!$this->storage) { throw new InvalidStorageException('A storage must be defined in order to store the data!'); } $this->storage->store($this->toArray()); return $this; } /** * @param mixed $data * @return QtiTimeLine */ protected function unserializeTimeLine($data) { if (is_array($data)) { $timeLine = new QtiTimeLine(); $timeLine->fromArray($data); } else { $timeLine = $data; } return $timeLine; } protected function unserializeAdjustmentMap($data) { $map = new AdjustmentMap(); if (is_array($data)) { $map->fromArray($data); } elseif ($data instanceof TimerAdjustmentMapInterface) { $map = $data; } return $map; } /** * Loads the data from the storage * @return Timer * @throws InvalidStorageException * @throws InvalidDataException */ public function load() { if (!$this->storage) { throw new InvalidStorageException('A storage must be defined in order to store the data!'); } $data = $this->storage->load(); if (isset($data)) { if (!is_array($data)) { $data = [ QtiTimeStorageFormat::STORAGE_KEY_TIME_LINE => $data, ]; } if (isset($data[QtiTimeStorageFormat::STORAGE_KEY_TIME_LINE])) { $this->timeLine = $this->unserializeTimeLine($data[QtiTimeStorageFormat::STORAGE_KEY_TIME_LINE]); } else { $this->timeLine = new QtiTimeLine(); } if (isset($data[QtiTimeStorageFormat::STORAGE_KEY_EXTRA_TIME])) { $this->extraTime = $data[QtiTimeStorageFormat::STORAGE_KEY_EXTRA_TIME]; } else { $this->extraTime = 0; } if (isset($data[QtiTimeStorageFormat::STORAGE_KEY_EXTENDED_TIME])) { $this->extendedTime = $data[QtiTimeStorageFormat::STORAGE_KEY_EXTENDED_TIME]; } else { $this->extendedTime = 0; } if (isset($data[QtiTimeStorageFormat::STORAGE_KEY_CONSUMED_EXTRA_TIME])) { $this->consumedExtraTime = $data[QtiTimeStorageFormat::STORAGE_KEY_CONSUMED_EXTRA_TIME]; } else { $this->consumedExtraTime = 0; } if (isset($data[QtiTimeStorageFormat::STORAGE_KEY_TIMER_ADJUSTMENT_MAP])) { $this->adjustmentMap = $this->unserializeAdjustmentMap( $data[QtiTimeStorageFormat::STORAGE_KEY_TIMER_ADJUSTMENT_MAP] ); } else { $this->adjustmentMap = new AdjustmentMap(); } if (!$this->timeLine instanceof TimeLine) { throw new InvalidDataException('The storage did not provide acceptable data when loading!'); } if (!$this->timerStrategy) { throw new InvalidTimerStrategyException('A timer strategy must be defined!'); } } return $this; } /** * Gets the added extra time * @return float */ public function getExtraTime() { return $this->extraTime; } /** * @return float */ public function getExtendedTime() { return $this->extendedTime; } /** * @param $extendedTime * @return $this */ public function setExtendedTime($extendedTime) { $this->extendedTime = $extendedTime; return $this; } /** * Sets the added extra time * @param float $time * @return $this */ public function setExtraTime($time) { $this->extraTime = max(0, floatval($time)); return $this; } /** * Sets the added extra time * @param float $time * @return $this */ public function setConsumedExtraTime($time) { $this->consumedExtraTime = max($this->consumedExtraTime, floatval($time)); return $this; } /** * Gets the amount of already consumed extra time. If tags are provided, only take care of the related time. * @param string|array $tags A tag or a list of tags to filter * @param integer $maxTime initial (total) timer value without extra time * @param integer $target (server/client) * @return float * @throws */ public function getConsumedExtraTime($tags = null, $maxTime = 0, $target = TimePoint::TARGET_SERVER) { if ($maxTime) { $totalConsumed = $this->compute($tags, $target); $consumedExtraTime = $totalConsumed - $maxTime < 0 ? 0 : $totalConsumed - $maxTime; $this->setConsumedExtraTime($consumedExtraTime)->save(); } return $this->consumedExtraTime; } /** * Gets the amount of remaining extra time * @param string|array $tags A tag or a list of tags to filter * @param integer $maxTime initial (total) timer value without extra time * @param integer $target (server/client) * @return float */ public function getRemainingExtraTime($tags = null, $maxTime = 0, $target = TimePoint::TARGET_SERVER) { return max(0, $this->getExtraTime() - $this->getConsumedExtraTime($tags, $maxTime, $target)); } /** * @return AdjustmentMap */ public function getAdjustmentMap() { return $this->adjustmentMap; } /** * @inheritdoc */ public function delete() { $storage = $this->getStorage(); return $storage->delete(); } /** * Checks if a timestamp is consistent with existing TimePoint within a range * @param array $points * @param float $timestamp * @throws InconsistentRangeException */ protected function checkTimestampCoherence($points, $timestamp) { foreach ($points as $point) { if ($point->getTimestamp() > $timestamp) { throw new InconsistentRangeException('A new TimePoint cannot be set before an existing one!'); } } } /** * Check if the provided range is open (START TimePoint and no related END) * @param array $range * @return bool */ protected function isRangeOpen($range) { $nb = count($range); return $nb && ($nb % 2) && ($range[$nb - 1]->getType() == TimePoint::TYPE_START); } /** * Extracts a sorted range of TimePoint * * @param array $tags * @return array */ protected function getRange($tags) { $range = $this->timeLine->find($tags, TimePoint::TARGET_SERVER); TimePoint::sort($range); return $range; } /** * Checks if a binary flag contains exactly one flag set * @param $value * @return bool */ protected function onlyOneFlag($value) { return $this->binaryPopCount($value) == 1; } /** * Count the number of bits set in a 32bits integer * @param int $value * @return int */ protected function binaryPopCount($value) { $value -= (($value >> 1) & 0x55555555); $value = ((($value >> 2) & 0x33333333) + ($value & 0x33333333)); $value = ((($value >> 4) + $value) & 0x0f0f0f0f); $value += ($value >> 8); $value += ($value >> 16); return $value & 0x0000003f; } }