657 lines
20 KiB
PHP
657 lines
20 KiB
PHP
<?php
|
|
|
|
/**
|
|
* This program is free software; you can redistribute it and/or
|
|
* modify it under the terms of the GNU General Public License
|
|
* as published by the Free Software Foundation; under version 2
|
|
* of the License (non-upgradable).
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
*
|
|
* Copyright (c) 2016 (original work) Open Assessment Technologies SA ;
|
|
*/
|
|
|
|
/**
|
|
* @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
|
|
*/
|
|
|
|
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 <b>json_encode</b>,
|
|
* 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;
|
|
}
|
|
}
|