tao-test/app/taoQtiTest/models/classes/runner/time/QtiTimeLine.php

453 lines
14 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\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 <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();
}
/**
* 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 <p>
* The string representation of the object.
* </p>
* @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;
}
}