This commit is contained in:
Joby Elliott 2024-10-11 12:09:05 -06:00
commit 3db4f9cddb
8 changed files with 2393 additions and 4 deletions

View file

@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v4
- uses: php-actions/composer@v6
- uses: php-actions/phpstan@v3
- uses: php-actions/phpunit@v3
- uses: php-actions/phpunit@v4
with:
version: 10
test_suffix: "Test.php"

View file

@ -1,5 +1,7 @@
# Joby's PHP Toolbox
[![CI](https://github.com/joby-lol/php-toolbox/actions/workflows/ci.yml/badge.svg)](https://github.com/joby-lol/php-toolbox/actions/workflows/ci.yml)
A lightweight collection of useful general purpose PHP tools with no dependencies. Committed to always at least having minimal dependencies.
## Development status

View file

@ -0,0 +1,417 @@
<?php
/**
* Joby's PHP Toolbox: https://code.byjoby.com/php-toolbox/
* MIT License: Copyright (c) 2024 Joby Elliott
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
namespace Joby\Toolbox\Ranges;
use RuntimeException;
use Stringable;
/**
* Class to represent a range of values, which consists of a start and an end
* value, each of which may be null to indicate an open range in that direction.
* Any class tat extends this must implement some kind of ordering/hashing
* mechanism to convert the values you want to express into integers, so that
* they can be compared and determined to be "adjacent".
*
* For example, a range of dates could be represented by converting the date
* into a timestamp for if you wanted a resolution of 1 second, or by converting
* it to the number of days since some early epoch start if you wanted day
* resolution. The details of how things are converted mostly only matter if you
* are going to be checking for adjacency. If you don't need adjacency checks it
* only matters that your method maintains proper ordering.
*
* @template T of mixed
*/
abstract class AbstractRange implements Stringable
{
protected int|float $start;
protected int|float $end;
protected mixed $start_value;
protected mixed $end_value;
/**
* This must be essentially a hash function, which converts a given value
* into an integer, which represents its ordering somehow.
* @param T $value
* @return int
*/
abstract protected static function valueToInteger(mixed $value): int;
/**
* This must be the inverse of the valueToInteger method, which converts an
* integer back into the original value.
* @param int $integer
* @return T
*/
abstract protected static function integerToValue(int $integer): mixed;
/**
* This must prepare a value to be stored in this object, which may just be
* passing it blindly, cloning an object, or rounding it, etc.
* @param T $value
* @return T
*/
abstract protected static function prepareValue(mixed $value): mixed;
/**
* This must return the value that is immediately before a given integer.
* Returns null if number is infinite.
* @return T|null
*/
protected static function valueBefore(int|float $number): mixed
{
if ($number == INF) return null;
if ($number == -INF) return null;
return static::integerToValue((int)$number - 1);
}
/**
* This must return the value that is immediately after a given integer.
* Returns null if number is infinite.
* @return T|null
*/
protected static function valueAfter(int|float $number): mixed
{
if ($number == INF) return null;
if ($number == -INF) return null;
return static::integerToValue((int)$number + 1);
}
/**
* @param T|null $start
* @param T|null $end
*/
final public function __construct($start, $end)
{
$this->setStart($start);
$this->setEnd($end);
}
/**
* Perform a boolean AND operation on this range and another, returning the
* range that they both cover, returns null if they do not overlap.
* @param static $other
*/
public function booleanAnd(AbstractRange $other): static|null
{
if ($this->contains($other)) return new static($other->start(), $other->end());
elseif ($other->contains($this)) return new static($this->start(), $this->end());
elseif ($this->intersects($other)) {
return new static(
$this->extendsBefore($other) ? $other->start() : $this->start(),
$this->extendsAfter($other) ? $other->end() : $this->end()
);
} else return null;
}
/**
* Perform a boolean OR operation on this range and another, returning an
* array of all areas that are covered by either range (if the ranges do not
* overlap this array will contain both ranges separately). Separate objects
* must be returned in ascending order.
* @param static $other
* @return RangeCollection<static>
*/
public function booleanOr(AbstractRange $other): RangeCollection
{
if ($this->intersects($other) || $this->adjacent($other)) {
return RangeCollection::create(
new static(
$this->extendsBefore($other) ? $this->start() : $other->start(),
$this->extendsAfter($other) ? $this->end() : $other->end()
)
);
} else {
if ($this->extendsBefore($other)) {
return RangeCollection::create(
new static($this->start(), $this->end()),
new static($other->start(), $other->end())
);
} else {
return RangeCollection::create(
new static($other->start(), $other->end()),
new static($this->start(), $this->end())
);
}
}
}
/**
* Perform a boolean XOR operation on this range and another, returning an
* array of all areas that are covered by either range but not both. If the
* ranges do not overlap, this array will contain both ranges separately.
* Separate objects must be returned in ascending order.
* @param static $other
* @return RangeCollection<static>
*/
public function booleanXor(AbstractRange $other): RangeCollection
{
// if the ranges are equal, return an empty array
if ($this->equals($other)) return RangeCollection::createEmpty($other);
// if the ranges are adjacent return a single range
if ($this->adjacent($other)) return $this->booleanOr($other);
// if the ranges do not overlap, return both ranges
if (!$this->intersects($other)) {
return RangeCollection::create(new static($this->start(), $this->end()), new static($other->start(), $other->end()));
}
// otherwise get the maximum bounds minus wherever these intersect
$range = new static(
$this->extendsBefore($other) ? $this->start() : $other->start(),
$this->extendsAfter($other) ? $this->end() : $other->end()
);
if ($intersect = $this->booleanAnd($other)) {
return $range->booleanNot($intersect);
} else {
return RangeCollection::create($range);
}
}
/**
* Find all areas that are covered by both this range and another, sliced
* into up to three different ranges along the boundaries other boolean
* operations would break on. Areas will be returned in ascending order, and
* some information about the relationships between the ranges can be inferred
* from the number fo ranges returned here:
* - 1 range: the entered ranges are equal
* - 2 ranges: the entered ranges are adjacent, disjoint, or overlap with a shared boundary
* - 3 ranges: the entered ranges overlap with space on each end
* @param static $other
* @return RangeCollection<static>
*/
public function booleanSlice(AbstractRange $other): RangeCollection
{
// if the ranges are equal, return a single range
if ($this->equals($other)) return RangeCollection::create(new static($this->start(), $this->end()));
// if the ranges do not overlap, return two ranges
if (!$this->intersects($other)) {
return RangeCollection::create(
new static($this->start(), $this->end()),
new static($other->start(), $other->end())
);
}
// otherwise get the maximum bounds minus wherever these intersect
$overall_range = new static(
$this->extendsBefore($other) ? $this->start() : $other->start(),
$this->extendsAfter($other) ? $this->end() : $other->end()
);
$intersection = $this->booleanAnd($other);
assert($intersection !== null);
$xor = $overall_range->booleanNot($intersection);
if (count($xor) == 2) {
assert(isset($xor[0], $xor[1]));
return RangeCollection::create($xor[0], $intersection, $xor[1]);
} elseif (count($xor) == 1) {
assert(isset($xor[0]));
return RangeCollection::create($intersection, $xor[0]);
}
// throw an exception if we get in an unexpected state
throw new RuntimeException(sprintf("Unexpected state (%s,%s) (%s,%s)", $this->start, $this->end, $other->start, $other->end));
}
/**
* Perform a boolean NOT operation on this range and another, returning an
* array of all areas that are covered by this range but not the other. If
* the other range completely covers this range, an empty array will be
* returned. Separate objects must be returned in ascending order.
* @param static $other
* @return RangeCollection<static>
*/
public function booleanNot(AbstractRange $other): RangeCollection
{
// if this range is completely contained by the other, return an empty array
if ($other->contains($this)) {
return RangeCollection::createEmpty($other);
}
// if the ranges do not overlap, return this range
if (!$this->intersects($other)) {
return RangeCollection::create(new static($this->start(), $this->end()));
}
// if this range completely contains the other, return the range from the start of this range to the start of the other
if ($this->contains($other)) {
if ($this->start == $other->start) {
return RangeCollection::create(new static(static::valueAfter($other->end), $this->end()));
} elseif ($this->end == $other->end) {
return RangeCollection::create(new static($this->start(), static::valueBefore($other->start)));
} else {
return RangeCollection::create(
new static($this->start(), static::valueBefore($other->start)),
new static(static::valueAfter($other->end), $this->end())
);
}
}
// if this range extends before the other, return the range from the start of this range to the start of the other
if ($this->extendsBefore($other)) {
return RangeCollection::create(new static($this->start(), static::valueBefore($other->start)));
}
// if this range extends after the other, return the range from the end of the other to the end of this range
if ($this->extendsAfter($other)) {
return RangeCollection::create(new static(static::valueAfter($other->end), $this->end()));
}
// throw an exception if we get in an unexpected state
throw new RuntimeException(sprintf("Unexpected state (%s,%s) (%s,%s)", $this->start, $this->end, $other->start, $other->end));
}
/**
* Check if this range has the same start and end as another range.
* @param static $other
*/
public function equals(AbstractRange $other): bool
{
return $this->start == $other->start && $this->end == $other->end;
}
/**
* Check if any part of this range overlaps with another range.
* @param static $other
*/
public function intersects(AbstractRange $other): bool
{
if ($this->start > $other->end) return false;
if ($this->end < $other->start) return false;
return true;
}
/**
* Check if this range completely contains another range.
* @param static $other
*/
public function contains(AbstractRange $other): bool
{
if ($this->start > $other->start) return false;
if ($this->end < $other->end) return false;
return true;
}
/**
* Check if the end of this range is after the end of another range
* @param static $other
*/
public function extendsAfter(AbstractRange $other): bool
{
return $this->end > $other->end;
}
/**
* Check if the start of this range is before the start of another range
* @param static $other
*/
public function extendsBefore(AbstractRange $other): bool
{
return $this->start < $other->start;
}
/**
* Check if this range is directly adjacent to but not overlapping another range. This
* is equivalent to checking both abutsStartOf and abutsEndOf.
* @param static $other
*/
public function adjacent(AbstractRange $other): bool
{
return $this->adjacentRightOf($other) || $this->adjacentLeftOf($other);
}
/**
* Check if the start of this range directly abuts the end of another range.
* This means that they do not overlap, but are directly adjacent.
* @param static $other
*/
public function adjacentRightOf(AbstractRange $other): bool
{
if ($this->start == -INF || $other->end == INF) return false;
return $this->start == $other->end + 1;
}
/**
* Check if the end of this range directly abuts the start of another range.
* This means that they do not overlap, but are directly adjacent.
* @param static $other
*/
public function adjacentLeftOf(AbstractRange $other): bool
{
if ($this->end == INF || $other->start == -INF) return false;
return $this->end == $other->start - 1;
}
/**
* @param T|null $start
* @return static
*/
public function setStart(mixed $start): static
{
$this->start = is_null($start) ? -INF
: static::valueToInteger($start);
$this->start_value = is_null($start) ? null
: static::prepareValue($start);
return $this;
}
/**
* @param T|null $end
* @return static
*/
public function setEnd(mixed $end): static
{
$this->end = is_null($end) ? INF
: static::valueToInteger($end);
$this->end_value = is_null($end) ? null
: static::prepareValue($end);
return $this;
}
/**
* @return T|null
*/
public function start(): mixed
{
return $this->start_value;
}
/**
* @return T|null
*/
public function end(): mixed
{
return $this->end_value;
}
public function startAsNumber(): int|float
{
return $this->start;
}
public function endAsNumber(): int|float
{
return $this->end;
}
public function __toString(): string
{
return sprintf(
'[%s...%s]',
$this->start === -INF ? '' : (is_string($this->start_value) ? $this->start_value : $this->start),
$this->end === INF ? '' : (is_string($this->end_value) ? $this->end_value : $this->end)
);
}
}

View file

@ -0,0 +1,57 @@
<?php
/**
* Joby's PHP Toolbox: https://code.byjoby.com/php-toolbox/
* MIT License: Copyright (c) 2024 Joby Elliott
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
namespace Joby\Toolbox\Ranges;
use Stringable;
/**
* The simplest possible implementation of AbstractRange, which uses integers as
* its values as well as it's internal hashes. All it does to convert values is
* cast them to integers.
*
* This class is also effectively the test harness for AbstractRange. So it has
* extremely comprehensive tests while other implementations might only test
* for basic functionality and common off-by-one error locations.
*
* @extends AbstractRange<int>
*/
class IntegerRange extends AbstractRange
{
protected static function valueToInteger(mixed $value): int
{
return (int)$value;
}
protected static function integerToValue(int $integer): mixed
{
return $integer;
}
protected static function prepareValue(mixed $value): mixed
{
return (int)$value;
}
}

View file

@ -0,0 +1,314 @@
<?php
/**
* Joby's PHP Toolbox: https://code.byjoby.com/php-toolbox/
* MIT License: Copyright (c) 2024 Joby Elliott
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
namespace Joby\Toolbox\Ranges;
use ArrayAccess;
use ArrayIterator;
use Countable;
use InvalidArgumentException;
use IteratorAggregate;
use Joby\Toolbox\Sorting\Sorter;
use Stringable;
/**
* Stores a collection of ranges, which must be all of the same type, and
* ensures that they are always sorted in ascencing chronological order. Also
* provides a number of methods for manipulating the colleciton, such as merging
* it with another collection, mapping, filtering, boolean operations with other
* collections or ranges, etc.
*
* It also allows counting, array access, and iteration in foreach loops.
*
* @template T of AbstractRange
* @implements ArrayAccess<int,T>
* @implements IteratorAggregate<int,T>
*/
class RangeCollection implements Countable, ArrayAccess, IteratorAggregate, Stringable
{
/** @var class-string<T> */
protected string $class;
/** @var array<int,T> */
protected $ranges = [];
/**
* Create a new collection from any number of ranges. Must have at least one
* argument, which is used to determine the type of range to store.
*
* @template RangeType of AbstractRange
* @param RangeType $range
* @param RangeType ...$ranges
* @return RangeCollection<RangeType>
*/
public static function create(AbstractRange $range, AbstractRange ...$ranges): RangeCollection
{
return new RangeCollection($range::class, $range, ...$ranges);
}
/**
* Create an empty collection of a specific range type.
*
* @template RangeType of AbstractRange
* @param RangeType|class-string<RangeType> $class
* @return RangeCollection<RangeType>
*/
public static function createEmpty(AbstractRange|string $class): RangeCollection
{
if (is_object($class)) return new RangeCollection($class::class);
else return new RangeCollection($class);
}
/**
* @param class-string<T> $class
* @param T ...$ranges
* @return void
*/
protected function __construct(string $class, AbstractRange ...$ranges)
{
foreach ($ranges as $range) {
if (!($range instanceof $class)) {
throw new InvalidArgumentException("Ranges must be of type $class");
}
}
$this->class = $class;
$this->ranges = array_values($ranges);
$this->sort();
}
/**
* Subtract the given range from all ranges in this collection.
* @param T $other
* @return RangeCollection<T>
*/
public function booleanNot(AbstractRange $other): RangeCollection
{
return $this->map(function (AbstractRange $range) use ($other) {
return $range->booleanNot($other);
});
}
/**
* Return only the ranges that intersect with the given range from any
* range in this colelction.
* @param T $other
* @return RangeCollection<T>
*/
public function booleanAnd(AbstractRange $other): RangeCollection
{
return $this->map(function (AbstractRange $range) use ($other) {
return $range->booleanAnd($other);
});
}
/**
* Transform this collection into the set of ranges that fully contain all
* ranges in this collection. This is done by merging overlapping or adjacent
* ranges until no more merges are possible.
*
* @return RangeCollection<T>
*/
public function mergeRanges(): RangeCollection
{
return $this
->mergeIntersectingRanges()
->mergeAdjacentRanges();
}
/**
* Merge all ranges that intersect with each other into single continuous
* ranges instead of a buch of separate chunks.
*
* @return RangeCollection<T>
*/
public function mergeIntersectingRanges(): RangeCollection
{
/** @var array<int,T> */
$merged = [];
foreach ($this->ranges as $range) {
$found = false;
foreach ($merged as $k => $m) {
if ($range->intersects($m)) {
$found = true;
$merged[$k] = $m->booleanOr($range)[0];
break;
}
}
if (!$found) $merged[] = $range;
}
return new RangeCollection($this->class, ...$merged);
}
/**
* Merge all ranges that are adjacent to each other into single continuous
* ranges instead of a buch of separate chunks. Note that this does not
* merge ranges that overlap, and strictly merges only ranges that are
* adjacent. If ranges are adjacent to multiple other ranges only one will
* be merged, and the others will remain separate. This method is protected
* because its behavior is complex in the case of multiple adjacent ranges
* and most users are probably looking for the behavior of mergeRanges()
* or mergeIntersectingRanges() anyway.
* @return RangeCollection<T>
*/
protected function mergeAdjacentRanges(): RangeCollection
{
/** @var array<int,T> */
$merged = [];
foreach ($this->ranges as $range) {
$found = false;
foreach ($merged as $k => $m) {
if ($range->adjacent($m)) {
$found = true;
$merged[$k] = $m->booleanOr($range)[0];
break;
}
}
if (!$found) $merged[] = $range;
}
return new RangeCollection($this->class, ...$merged);
}
/**
* Filter this collection to only include ranges that return true when
* passed to the provided callback.
* @param callable(T):bool $callback
* @return RangeCollection<T>
*/
public function filter(callable $callback): RangeCollection
{
return new RangeCollection($this->class, ...array_filter($this->ranges, $callback));
}
/**
* Inspect and modify each range in the collection using the provided callback. If the callback returns a collection it will be merged. If the callback returns null, the range will be removed.
* @param callable(T):(T|RangeCollection<T>|null) $callback
* @return RangeCollection<T>
*/
public function map(callable $callback): RangeCollection
{
$new_ranges = [];
foreach ($this->ranges as $range) {
$new_range = $callback($range);
if ($new_range instanceof RangeCollection) {
$new_ranges = array_merge($new_ranges, $new_range->toArray());
} elseif ($new_range !== null) {
$new_ranges[] = $new_range;
}
}
return new RangeCollection($this->class, ...$new_ranges);
}
public function isEmpty(): bool
{
return empty($this->ranges);
}
/**
* @return array<int,T>
*/
public function toArray(): array
{
return $this->ranges;
}
/**
* @param T ...$ranges
* @return RangeCollection<T>
*/
public function add(AbstractRange ...$ranges): RangeCollection
{
$ranges = array_merge($this->ranges, $ranges);
return new RangeCollection($this->class, ...$ranges);
}
protected function sort(): void
{
static $sorter;
$sorter = $sorter ?? $sorter = new Sorter(
fn (AbstractRange $a, AbstractRange $b): int => $a->startAsNumber() <=> $b->startAsNumber(),
fn (AbstractRange $a, AbstractRange $b): int => $a->endAsNumber() <=> $b->endAsNumber(),
);
$sorter->sort($this->ranges);
}
public function count(): int
{
return count($this->ranges);
}
/**
* @param int $offset
* @return bool
*/
public function offsetExists($offset): bool
{
return isset($this->ranges[$offset]);
}
/**
* @param int $offset
* @return T|null
*/
public function offsetGet($offset): ?AbstractRange
{
return $this->ranges[$offset] ?? null;
}
/**
* @param int|null $offset
* @param T $value
*/
public function offsetSet($offset, $value): void
{
if (is_null($offset)) $this->add($value);
else {
if (!($value instanceof $this->class)) {
throw new InvalidArgumentException("Ranges must be of type $this->class");
}
$this->ranges[$offset] = $value;
$this->sort();
}
}
/**
* @param int $offset
*/
public function offsetUnset($offset): void
{
unset($this->ranges[$offset]);
}
/**
* @return ArrayIterator<int,T>
*/
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->ranges);
}
public function __toString(): string
{
return implode(', ', $this->ranges);
}
}

View file

@ -37,7 +37,7 @@ namespace Joby\Toolbox\Sorting;
*/
class Sorter
{
/** @var array<callable(mixed, mixed): int> */
/** @var array<callable> */
protected array $comparisons = [];
/**
@ -45,7 +45,7 @@ class Sorter
* The sorters will be called in order, and the array will be sorted based
* on the first one to return a non-zero value.
*
* @param callable(mixed, mixed): int ...$comparisons
* @param callable ...$comparisons
*/
public function __construct(callable ...$comparisons)
{
@ -56,7 +56,7 @@ class Sorter
* Add one or more sorting callbacks to this sorter. The new callbacks will
* be appended to the end of the existing list of sorters.
*
* @param callable(mixed, mixed): int ...$comparisons
* @param callable ...$comparisons
*/
public function addComparison(callable ...$comparisons): static
{

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,346 @@
<?php
/**
* Joby's PHP Toolbox: https://code.byjoby.com/php-toolbox/
* MIT License: Copyright (c) 2024 Joby Elliott
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
namespace Joby\Toolbox\Sorting;
use Joby\Toolbox\Ranges\IntegerRange;
use Joby\Toolbox\Ranges\RangeCollection;
use PHPUnit\Framework\TestCase;
class RangeCollectionTest extends TestCase
{
public function testEmpty()
{
$collection = RangeCollection::createEmpty(IntegerRange::class);
$this->assertEquals(
'',
(string)$collection
);
$this->assertEquals(
0,
count($collection)
);
$this->assertEquals(
[],
$collection->toArray()
);
}
public function testSorting()
{
// basic in-order sorting, sorted by start date
$collection = RangeCollection::create(
new IntegerRange(1, 2),
new IntegerRange(3, 4),
new IntegerRange(2, 4)
);
$this->assertEquals(
'[1...2], [2...4], [3...4]',
(string)$collection
);
// ties in the start date are broken by which has an earlier end date
$collection = RangeCollection::create(
new IntegerRange(1, 2),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
new IntegerRange(2, 3)
);
$this->assertEquals(
'[1...2], [2...3], [2...4], [3...4]',
(string)$collection
);
// infinite start dates go first
$collection = RangeCollection::create(
new IntegerRange(3, 4),
new IntegerRange(2, 4),
new IntegerRange(2, 3),
new IntegerRange(null, 2)
);
$this->assertEquals(
'[...2], [2...3], [2...4], [3...4]',
(string)$collection
);
// infinite end dates go last
$collection = RangeCollection::create(
new IntegerRange(2, 4),
new IntegerRange(2, null),
new IntegerRange(2, 3),
);
$this->assertEquals(
'[2...3], [2...4], [2...]',
(string)$collection
);
}
public function testFilter()
{
// filter a collection to only include ranges that start with 2
$collection = RangeCollection::create(
new IntegerRange(1, 2),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
new IntegerRange(2, 3)
);
$filtered = $collection->filter(function ($range) {
return $range->start() == 2;
});
$this->assertEquals(
'[2...3], [2...4]',
(string)$filtered
);
}
public function testMap()
{
// map a collection to include only the end date of each range
$collection = RangeCollection::create(
new IntegerRange(1, 2),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
new IntegerRange(2, 3)
);
$mapped = $collection->map(function ($range) {
return new IntegerRange(null, $range->end());
});
$this->assertEquals(
'[...2], [...3], [...4], [...4]',
(string)$mapped
);
// map a collection to make a one-unit collection of the start and end of each range
$collection = RangeCollection::create(
new IntegerRange(1, 2),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
new IntegerRange(2, 3)
);
$mapped = $collection->map(function ($range) {
return RangeCollection::create(
new IntegerRange($range->start(), $range->start()),
new IntegerRange($range->end(), $range->end())
);
});
$this->assertEquals(
'[1...1], [2...2], [2...2], [2...2], [3...3], [3...3], [4...4], [4...4]',
(string)$mapped
);
// map a collection to remove any range that starts with 2
$collection = RangeCollection::create(
new IntegerRange(1, 2),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
new IntegerRange(2, 3)
);
$mapped = $collection->map(function ($range) {
if ($range->start() == 2) return null;
return $range;
});
$this->assertEquals(
'[1...2], [3...4]',
(string)$mapped
);
}
public function testMergeIntersectingRanges()
{
// simple intersecting ranges
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$merged = $collection->mergeIntersectingRanges();
$this->assertEquals(
'[1...4]',
(string)$merged
);
// two groups of intersecting ranges that are adjacent but not overlapping
$collection = RangeCollection::create(
new IntegerRange(1, 2),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
new IntegerRange(2, 3),
new IntegerRange(5, 6),
new IntegerRange(7, 8),
new IntegerRange(6, 8),
new IntegerRange(6, 7)
);
$merged = $collection->mergeIntersectingRanges();
$this->assertEquals(
'[1...4], [5...8]',
(string)$merged
);
}
public function testMergeRanges()
{
// this method does everything mergeIntersectingRanges does, plus it
// merges adjacent ranges effectively this method turns a collection of
// ranges into the smallest possible set of ranges that fully contain
// all the original ranges
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$merged = $collection->mergeRanges();
$this->assertEquals(
'[1...4]',
(string)$merged
);
// two groups of intersecting ranges that are adjacent but not overlapping
$collection = RangeCollection::create(
new IntegerRange(1, 2),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
new IntegerRange(2, 3),
new IntegerRange(5, 6),
new IntegerRange(7, 8),
new IntegerRange(6, 8),
new IntegerRange(6, 7)
);
$merged = $collection->mergeRanges();
$this->assertEquals(
'[1...8]',
(string)$merged
);
// two groups that are not adjacent
$collection = RangeCollection::create(
new IntegerRange(1, 2),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
new IntegerRange(2, 3),
new IntegerRange(5, 6),
new IntegerRange(7, 8),
new IntegerRange(6, 8),
new IntegerRange(6, 7),
new IntegerRange(10, 11),
new IntegerRange(12, 13),
new IntegerRange(11, 13),
new IntegerRange(11, 12)
);
$merged = $collection->mergeRanges();
$this->assertEquals(
'[1...8], [10...13]',
(string)$merged
);
}
public function testBooleanNot()
{
// subtract a range from a collection
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$subtracted = $collection->booleanNot(new IntegerRange(2, 3));
$this->assertEquals(
'[1...1], [4...4], [4...4]',
(string)$subtracted
);
// subtract a range from a collection that is fully contained
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$subtracted = $collection->booleanNot(new IntegerRange(2, 4));
$this->assertEquals(
'[1...1]',
(string)$subtracted
);
// subtract a range from a collection that fully contains the range
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$subtracted = $collection->booleanNot(new IntegerRange(1, 4));
$this->assertEquals(
'',
(string)$subtracted
);
// subtract a range from a collection that is fully contained
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$subtracted = $collection->booleanNot(new IntegerRange(1, 3));
$this->assertEquals(
'[4...4], [4...4]',
(string)$subtracted
);
}
public function testBooleanAnd()
{
// intersect a range with a collection
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$intersected = $collection->booleanAnd(new IntegerRange(2, 3));
$this->assertEquals(
'[2...3], [2...3], [3...3]',
(string)$intersected
);
// intersect a range with a collection that is fully contained
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$intersected = $collection->booleanAnd(new IntegerRange(2, 4));
$this->assertEquals(
'[2...3], [2...4], [3...4]',
(string)$intersected
);
// intersect a range with a collection that fully contains the range
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$intersected = $collection->booleanAnd(new IntegerRange(1, 4));
$this->assertEquals(
'[1...3], [2...4], [3...4]',
(string)$intersected
);
// intersect a range with a collection that is fully contained
$collection = RangeCollection::create(
new IntegerRange(1, 3),
new IntegerRange(3, 4),
new IntegerRange(2, 4),
);
$intersected = $collection->booleanAnd(new IntegerRange(1, 3));
$this->assertEquals(
'[1...3], [2...3], [3...3]',
(string)$intersected
);
}
}