*/ namespace oat\taoQtiTest\models\runner\time; use oat\taoTests\models\runner\time\ArraySerializable; use oat\taoTests\models\runner\time\IncompleteRangeException; use oat\taoTests\models\runner\time\InconsistentRangeException; use oat\taoTests\models\runner\time\InvalidDataException; use oat\taoTests\models\runner\time\MalformedRangeException; use oat\taoTests\models\runner\time\TimeException; use oat\taoTests\models\runner\time\TimeLine; use oat\taoTests\models\runner\time\TimePoint; /** * Class QtiTimeLine * @package oat\taoQtiTest\models\runner\time */ class QtiTimeLine implements TimeLine, ArraySerializable, \Serializable, \JsonSerializable { /** * The list of TimePoint representing the TimeLine * @var array */ protected $points = []; /** * QtiTimeLine constructor. * @param array $points */ public function __construct($points = null) { if (isset($points)) { foreach ($points as $point) { $this->add($point); } } } /** * Exports the internal state to an array * @return array */ public function toArray() { $data = []; foreach ($this->points as $point) { $data[] = $point->toArray(); } return $data; } /** * Imports the internal state from an array * @param array $data */ public function fromArray($data) { $this->points = []; if (is_array($data)) { foreach ($data as $dataPoint) { $point = new TimePoint(); $point->fromArray($dataPoint); $this->points[] = $point; } } } /** * 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(); } /** * String representation of object * @link http://php.net/manual/en/serializable.serialize.php * @return string the string representation of the object or null * @since 5.1.0 */ public function serialize() { return serialize($this->points); } /** * Constructs the object * @link http://php.net/manual/en/serializable.unserialize.php * @param string $serialized
* The string representation of the object. *
* @return void * @since 5.1.0 * @throws InvalidDataException */ public function unserialize($serialized) { $this->points = unserialize($serialized); if (!is_array($this->points)) { throw new InvalidDataException('The provided serialized data are invalid!'); } } /** * Gets the list of TimePoint present in the TimeLine * @return array */ public function getPoints() { return $this->points; } /** * Adds another TimePoint inside the TimeLine * @param TimePoint $point * @return TimeLine */ public function add(TimePoint $point) { $this->points[] = $point; return $this; } /** * Removes all TimePoint corresponding to the provided criteria * @param string|array $tag A tag or a list of tags to filter * @param int $target The type of target TimePoint to filter * @param int $type The tyoe of TimePoint to filter * @return int Returns the number of removed TimePoints */ public function remove($tag, $target = TimePoint::TARGET_ALL, $type = TimePoint::TYPE_ALL) { $tags = is_array($tag) ? $tag : [$tag]; $removed = 0; foreach ($this->points as $idx => $point) { if ($point->match($tags, $target, $type)) { unset($this->points[$idx]); $removed++; } } return $removed; } /** * Clears the TimeLine from all its TimePoint * @return TimeLine */ public function clear() { $this->points = []; return $this; } /** * Gets a filtered TimeLine, containing the TimePoint corresponding to the provided criteria * @param string|array $tag A tag or a list of tags to filter * @param int $target The type of target TimePoint to filter * @param int $type The type of TimePoint to filter * @return TimeLine Returns a subset corresponding to the found TimePoints */ public function filter($tag = null, $target = TimePoint::TARGET_ALL, $type = TimePoint::TYPE_ALL) { // the tag criteria can be omitted $tags = null; if (isset($tag)) { $tags = is_array($tag) ? $tag : [$tag]; } // create a another instance of the same class $subset = new static(); // fill the new instance with filtered TimePoint foreach ($this->points as $idx => $point) { if ($point->match($tags, $target, $type)) { $subset->add($point); } } return $subset; } /** * Finds all TimePoint corresponding to the provided criteria * @param string|array $tag A tag or a list of tags to filter * @param int $target The type of target TimePoint to filter * @param int $type The type of TimePoint to filter * @return array Returns a list of the found TimePoints */ public function find($tag = null, $target = TimePoint::TARGET_ALL, $type = TimePoint::TYPE_ALL) { // the tag criteria can be omitted $tags = null; if (isset($tag)) { $tags = is_array($tag) ? $tag : [$tag]; } // gather filterer TimePoint $points = []; foreach ($this->points as $point) { if ($point->match($tags, $target, $type)) { $points [] = $point; } } return $points; } /** * Computes the total duration represented by the filtered TimePoints * @param string|array $tag A tag or a list of tags to filter * @param int $target The type of target TimePoint to filter * @param int $lastTimestamp An optional timestamp that will be utilized to close the last open range, if any * @return float Returns the total computed duration * @throws TimeException */ public function compute($tag = null, $target = TimePoint::TARGET_ALL, $lastTimestamp = 0) { // default value for the last timestamp if (!$lastTimestamp) { $lastTimestamp = microtime(true); } // either get all points or only a subset according to the provided criteria if (!$tag && $target == TimePoint::TARGET_ALL) { $points = $this->getPoints(); } else { $points = $this->find($tag, $target, TimePoint::TYPE_ALL); } // we need a ordered list of points TimePoint::sort($points); // gather points by ranges, relying on the points references $ranges = []; foreach ($points as $point) { $ranges[$point->getRef()][] = $point; } $this->sortRanges($ranges); // compute the total duration by summing all gathered ranges // this loop can throw exceptions $duration = 0; foreach ($ranges as $rangeKey => $range) { $nextTimestamp = $lastTimestamp; if (isset($ranges[$rangeKey + 1])) { $nextTimestamp = $ranges[$rangeKey + 1][0]->getTimestamp(); } // the last range could be still open, or some range could be malformed due to connection issues... $range = $this->fixRange($range, $nextTimestamp); // compute the duration of the range, an exception may be thrown if the range is malformed // possible errors are (but should be avoided by the `fixRange()` method): // - unclosed range: should be autoclosed by fixRange // - unsorted points or nested/blended ranges: should be corrected by fixRange $duration += $this->computeRange($range); } return $duration; } /** * Compute the duration of a range of TimePoint * @param array $range * @return float * @throws IncompleteRangeException * @throws InconsistentRangeException * @throws MalformedRangeException */ protected function computeRange($range) { // a range must be built from pairs of TimePoint if (count($range) % 2) { throw new IncompleteRangeException(); } $duration = 0; $start = null; $end = null; foreach ($range as $point) { // grab the START TimePoint if ($this->isStartPoint($point)) { // we cannot have the START TimePoint twice if ($start) { throw new MalformedRangeException('A time range must be defined by a START and a END TimePoint! Twice START found.'); } $start = $point; } // grab the END TimePoint if ($this->isEndPoint($point)) { // we cannot have the END TimePoint twice if ($end) { throw new MalformedRangeException('A time range must be defined by a START and a END TimePoint! Twice END found.'); } $end = $point; } // when we have got START and END TimePoint, compute the duration if ($start && $end) { $duration += $this->getRangeDuration($start, $end); $start = null; $end = null; } } return $duration; } /** * Ensures the ranges are well formed. They should have been sorted before, otherwise the process won't work. * Tries to fix a range by adding missing points * @param array $range * @param float $lastTimestamp - An optional timestamp to apply on the last TimePoint if missing * @return array */ protected function fixRange($range, $lastTimestamp = null) { $fixedRange = []; $last = null; $open = false; foreach ($range as $point) { if ($this->isStartPoint($point)) { // start of range // the last range could be still open... if ($last && $open) { $fixedRange[] = $this->cloneTimePoint($point, TimePoint::TYPE_END); } $open = true; } elseif ($this->isEndPoint($point)) { // end of range // this range could not be started... if (!$open) { $fixedRange[] = $this->cloneTimePoint($last ? $last : $point, TimePoint::TYPE_START); } $open = false; } $fixedRange[] = $point; $last = $point; } // the last range could be still open... if ($last && $open) { $lastTimestamp = $lastTimestamp < $last->getTimestamp() ? $last->getTimestamp() : $lastTimestamp; $fixedRange[] = $this->cloneTimePoint($last, TimePoint::TYPE_END, $lastTimestamp); } return $fixedRange; } /** * Makes a copy of a TimePoint and forces a particular type * @param TimePoint $point - The point to duplicate * @param int $type - The type of the new point. It should be different! * @param float $timestamp - An optional timestamp to set on the new point. By default keep the source timestamp. * @return TimePoint */ protected function cloneTimePoint(TimePoint $point, $type, $timestamp = null) { if (is_null($timestamp)) { $timestamp = $point->getTimestamp(); } \common_Logger::d("Create missing TimePoint at " . $timestamp); return new TimePoint($point->getTags(), $timestamp, $type, $point->getTarget()); } /** * Tells if this is a start TimePoint * @param TimePoint $point * @return bool */ protected function isStartPoint(TimePoint $point) { return $point->match(null, TimePoint::TARGET_ALL, TimePoint::TYPE_START); } /** * Tells if this is a end TimePoint * @param TimePoint $point * @return bool */ protected function isEndPoint(TimePoint $point) { return $point->match(null, TimePoint::TARGET_ALL, TimePoint::TYPE_END); } /** * Computes the duration between two TimePoint * @param TimePoint $start * @param TimePoint $end * @return float * @throws InconsistentRangeException */ protected function getRangeDuration($start, $end) { // the two TimePoint must have the same target to be consistent if ($start->getTarget() != $end->getTarget()) { throw new InconsistentRangeException('A time range must be defined by two TimePoint with the same target'); } // the two TimePoint must be correctly ordered $rangeDuration = $end->getTimestamp() - $start->getTimestamp(); if ($rangeDuration < 0) { throw new InconsistentRangeException('A START TimePoint cannot take place after the END!'); } return $rangeDuration; } /** * @param array $ranges * @return array */ private function sortRanges(array &$ranges) { usort($ranges, function (array $a, array $b) { if ($a[0]->getTimestamp() === $b[0]->getTimestamp()) { return 0; } return ($a[0]->getTimestamp() < $b[0]->getTimestamp()) ? -1 : 1; }); return $ranges; } }