new features in RangeCollection

This commit is contained in:
Joby 2024-07-30 13:01:13 -06:00
parent e7d6aa07fa
commit f3c95c1f21
5 changed files with 628 additions and 136 deletions

View file

@ -26,6 +26,7 @@
namespace Joby\Toolbox\Ranges;
use RuntimeException;
use Stringable;
/**
* Class to represent a range of values, which consists of a start and an end
@ -43,7 +44,7 @@ use RuntimeException;
*
* @template T of mixed
*/
abstract class AbstractRange
abstract class AbstractRange implements Stringable
{
protected int|float $start;
protected int|float $end;
@ -404,4 +405,13 @@ abstract class AbstractRange
{
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

@ -38,7 +38,7 @@ use Stringable;
*
* @extends AbstractRange<int>
*/
class IntegerRange extends AbstractRange implements Stringable
class IntegerRange extends AbstractRange
{
protected static function valueToInteger(mixed $value): int
{
@ -54,13 +54,4 @@ class IntegerRange extends AbstractRange implements Stringable
{
return (int)$value;
}
public function __toString(): string
{
return sprintf(
'[%s...%s]',
$this->start === -INF ? '' : $this->start,
$this->end === INF ? '' : $this->end
);
}
}

View file

@ -31,19 +31,32 @@ 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
class RangeCollection implements Countable, ArrayAccess, IteratorAggregate, Stringable
{
/** @var class-string<T> */
protected string $class;
/** @var T[] */
/** @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
@ -55,6 +68,8 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate
}
/**
* Create an empty collection of a specific range type.
*
* @template RangeType of AbstractRange
* @param RangeType|class-string<RangeType> $class
* @return RangeCollection<RangeType>
@ -66,7 +81,151 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate
}
/**
* @return T[]
* @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
{
@ -75,28 +234,12 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate
/**
* @param T ...$ranges
* @return RangeCollection<T>
*/
public function add(AbstractRange ...$ranges): static
public function add(AbstractRange ...$ranges): RangeCollection
{
foreach ($ranges as $range) {
if (!($range instanceof $this->class)) {
throw new InvalidArgumentException("Ranges must be of type $this->class");
}
}
$this->ranges = array_merge($this->ranges, $ranges);
$this->sort();
return $this;
}
/**
* @param class-string<T> $class
* @param T ...$ranges
* @return void
*/
protected function __construct(string $class, AbstractRange ...$ranges)
{
$this->class = $class;
$this->add(...$ranges);
$ranges = array_merge($this->ranges, $ranges);
return new RangeCollection($this->class, ...$ranges);
}
protected function sort(): void
@ -163,4 +306,9 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate
{
return new ArrayIterator($this->ranges);
}
public function __toString(): string
{
return implode(', ', $this->ranges);
}
}

View file

@ -769,10 +769,10 @@ class IntegerRangeTest extends TestCase
'intersecting open start left' => '[...20]',
'intersecting open start right' => '[...21]',
'intersecting open start center' => '[...20]',
'disjoint open start' => '[...8] [10...20]',
'disjoint open end' => '[10...20] [22...]',
'disjoint left' => '[-2...8] [10...20]',
'disjoint right' => '[10...20] [22...32]',
'disjoint open start' => '[...8], [10...20]',
'disjoint open end' => '[10...20], [22...]',
'disjoint left' => '[-2...8], [10...20]',
'disjoint right' => '[10...20], [22...32]',
'adjacent open start' => '[...20]',
'adjacent open end' => '[10...]',
'adjacent left' => '[-1...20]',
@ -809,8 +809,8 @@ class IntegerRangeTest extends TestCase
'intersecting bounded end left' => '[...29]',
'adjacent open end' => '[...]',
'adjacent bounded end' => '[...30]',
'disjoint open end' => '[...20] [22...]',
'disjoint bounded end' => '[...20] [22...32]',
'disjoint open end' => '[...20], [22...]',
'disjoint bounded end' => '[...20], [22...32]',
],
$this->scenarioResults(
new IntegerRange(null, 20),
@ -833,8 +833,8 @@ class IntegerRangeTest extends TestCase
'intersecting bounded start right' => '[1...]',
'adjacent open start' => '[...]',
'adjacent bounded start' => '[0...]',
'disjoint open start' => '[...8] [10...]',
'disjoint bounded start' => '[-2...8] [10...]',
'disjoint open start' => '[...8], [10...]',
'disjoint bounded start' => '[-2...8], [10...]',
],
$this->scenarioResults(
new IntegerRange(10, null),
@ -877,7 +877,7 @@ class IntegerRangeTest extends TestCase
'adjacent open end' => '[10...20]',
'adjacent left' => '[10...20]',
'adjacent right' => '[10...20]',
'contained' => '[10...10] [20...20]',
'contained' => '[10...10], [20...20]',
'contained same start' => '[20...20]',
'contained same end' => '[10...10]',
'containing' => '',
@ -900,7 +900,7 @@ class IntegerRangeTest extends TestCase
'unbounded' => '',
'intersecting open start left' => '[20...20]',
'intersecting open start right' => '',
'intersecting bounded start left' => '[...8] [20...20]',
'intersecting bounded start left' => '[...8], [20...20]',
'intersecting bounded start right' => '[...10]',
'intersecting bounded start center' => '[...9]',
'intersecting open end same' => '[...19]',
@ -925,7 +925,7 @@ class IntegerRangeTest extends TestCase
'intersecting open end left' => '',
'intersecting open end right' => '[10...10]',
'intersecting bounded end left' => '[20...]',
'intersecting bounded end right' => '[10...10] [22...]',
'intersecting bounded end right' => '[10...10], [22...]',
'intersecting bounded end center' => '[21...]',
'intersecting open start same' => '[11...]',
'intersecting open start right' => '[12...]',
@ -947,7 +947,7 @@ class IntegerRangeTest extends TestCase
'same' => '',
'open start' => '[21...]',
'open end' => '[...9]',
'bounded' => '[...9] [21...]',
'bounded' => '[...9], [21...]',
],
$this->scenarioResults(
new IntegerRange(null, null),
@ -962,27 +962,27 @@ class IntegerRangeTest extends TestCase
$this->assertEquals(
[
'same' => '',
'unbounded' => '[...9] [21...]',
'intersecting open end left' => '[9...9] [21...]',
'intersecting open end right' => '[10...10] [21...]',
'unbounded' => '[...9], [21...]',
'intersecting open end left' => '[9...9], [21...]',
'intersecting open end right' => '[10...10], [21...]',
'intersecting open end center' => '[21...]',
'intersecting open start left' => '[...9] [20...20]',
'intersecting open start right' => '[...9] [21...21]',
'intersecting open start left' => '[...9], [20...20]',
'intersecting open start right' => '[...9], [21...21]',
'intersecting open start center' => '[...9]',
'disjoint open start' => '[...8] [10...20]',
'disjoint open end' => '[10...20] [22...]',
'disjoint left' => '[-2...8] [10...20]',
'disjoint right' => '[10...20] [22...32]',
'disjoint open start' => '[...8], [10...20]',
'disjoint open end' => '[10...20], [22...]',
'disjoint left' => '[-2...8], [10...20]',
'disjoint right' => '[10...20], [22...32]',
'adjacent open start' => '[...20]',
'adjacent open end' => '[10...]',
'adjacent left' => '[-1...20]',
'adjacent right' => '[10...31]',
'contained' => '[10...10] [20...20]',
'contained' => '[10...10], [20...20]',
'contained same start' => '[20...20]',
'contained same end' => '[10...10]',
'containing' => '[9...9] [21...21]',
'containing unbounded start' => '[...9] [21...21]',
'containing unbounded end' => '[9...9] [21...]',
'containing' => '[9...9], [21...21]',
'containing unbounded start' => '[...9], [21...21]',
'containing unbounded end' => '[9...9], [21...]',
'containing same start' => '[21...21]',
'containing same end' => '[9...9]',
'containing same start unbounded end' => '[21...]',
@ -1000,17 +1000,17 @@ class IntegerRangeTest extends TestCase
'unbounded' => '[21...]',
'intersecting open start left' => '[20...20]',
'intersecting open start right' => '[21...21]',
'intersecting bounded start left' => '[...8] [20...20]',
'intersecting bounded start right' => '[...10] [21...21]',
'intersecting bounded start left' => '[...8], [20...20]',
'intersecting bounded start right' => '[...10], [21...21]',
'intersecting bounded start center' => '[...9]',
'intersecting open end same' => '[...19] [21...]',
'intersecting open end left' => '[...18] [21...]',
'intersecting bounded end same' => '[...19] [21...30]',
'intersecting bounded end left' => '[...18] [21...29]',
'intersecting open end same' => '[...19], [21...]',
'intersecting open end left' => '[...18], [21...]',
'intersecting bounded end same' => '[...19], [21...30]',
'intersecting bounded end left' => '[...18], [21...29]',
'adjacent open end' => '[...]',
'adjacent bounded end' => '[...30]',
'disjoint open end' => '[...20] [22...]',
'disjoint bounded end' => '[...20] [22...32]',
'disjoint open end' => '[...20], [22...]',
'disjoint bounded end' => '[...20], [22...32]',
],
$this->scenarioResults(
new IntegerRange(null, 20),
@ -1024,17 +1024,17 @@ class IntegerRangeTest extends TestCase
'unbounded' => '[...9]',
'intersecting open end left' => '[9...9]',
'intersecting open end right' => '[10...10]',
'intersecting bounded end left' => '[9...9] [20...]',
'intersecting bounded end right' => '[10...10] [22...]',
'intersecting bounded end left' => '[9...9], [20...]',
'intersecting bounded end right' => '[10...10], [22...]',
'intersecting bounded end center' => '[21...]',
'intersecting open start same' => '[...9] [11...]',
'intersecting open start right' => '[...9] [12...]',
'intersecting bounded start same' => '[0...9] [11...]',
'intersecting bounded start right' => '[1...9] [12...]',
'intersecting open start same' => '[...9], [11...]',
'intersecting open start right' => '[...9], [12...]',
'intersecting bounded start same' => '[0...9], [11...]',
'intersecting bounded start right' => '[1...9], [12...]',
'adjacent open start' => '[...]',
'adjacent bounded start' => '[0...]',
'disjoint open start' => '[...8] [10...]',
'disjoint bounded start' => '[-2...8] [10...]',
'disjoint open start' => '[...8], [10...]',
'disjoint bounded start' => '[-2...8], [10...]',
],
$this->scenarioResults(
new IntegerRange(10, null),
@ -1047,7 +1047,7 @@ class IntegerRangeTest extends TestCase
'same' => '',
'open start' => '[21...]',
'open end' => '[...9]',
'bounded' => '[...9] [21...]',
'bounded' => '[...9], [21...]',
],
$this->scenarioResults(
new IntegerRange(null, null),
@ -1062,31 +1062,31 @@ class IntegerRangeTest extends TestCase
$this->assertEquals(
[
'same' => '[10...20]',
'unbounded' => '[...9] [10...20] [21...]',
'intersecting open end left' => '[9...9] [10...20] [21...]',
'intersecting open end right' => '[10...10] [11...20] [21...]',
'intersecting open end center' => '[10...20] [21...]',
'intersecting open start left' => '[...9] [10...19] [20...20]',
'intersecting open start right' => '[...9] [10...20] [21...21]',
'intersecting open start center' => '[...9] [10...20]',
'disjoint open start' => '[...8] [10...20]',
'disjoint open end' => '[10...20] [22...]',
'disjoint left' => '[-2...8] [10...20]',
'disjoint right' => '[10...20] [22...32]',
'adjacent open start' => '[...9] [10...20]',
'adjacent open end' => '[10...20] [21...]',
'adjacent left' => '[-1...9] [10...20]',
'adjacent right' => '[10...20] [21...31]',
'contained' => '[10...10] [11...19] [20...20]',
'contained same start' => '[10...19] [20...20]',
'contained same end' => '[10...10] [11...20]',
'containing' => '[9...9] [10...20] [21...21]',
'containing unbounded start' => '[...9] [10...20] [21...21]',
'containing unbounded end' => '[9...9] [10...20] [21...]',
'containing same start' => '[10...20] [21...21]',
'containing same end' => '[9...9] [10...20]',
'containing same start unbounded end' => '[10...20] [21...]',
'containing same end unbounded start' => '[...9] [10...20]',
'unbounded' => '[...9], [10...20], [21...]',
'intersecting open end left' => '[9...9], [10...20], [21...]',
'intersecting open end right' => '[10...10], [11...20], [21...]',
'intersecting open end center' => '[10...20], [21...]',
'intersecting open start left' => '[...9], [10...19], [20...20]',
'intersecting open start right' => '[...9], [10...20], [21...21]',
'intersecting open start center' => '[...9], [10...20]',
'disjoint open start' => '[...8], [10...20]',
'disjoint open end' => '[10...20], [22...]',
'disjoint left' => '[-2...8], [10...20]',
'disjoint right' => '[10...20], [22...32]',
'adjacent open start' => '[...9], [10...20]',
'adjacent open end' => '[10...20], [21...]',
'adjacent left' => '[-1...9], [10...20]',
'adjacent right' => '[10...20], [21...31]',
'contained' => '[10...10], [11...19], [20...20]',
'contained same start' => '[10...19], [20...20]',
'contained same end' => '[10...10], [11...20]',
'containing' => '[9...9], [10...20], [21...21]',
'containing unbounded start' => '[...9], [10...20], [21...21]',
'containing unbounded end' => '[9...9], [10...20], [21...]',
'containing same start' => '[10...20], [21...21]',
'containing same end' => '[9...9], [10...20]',
'containing same start unbounded end' => '[10...20], [21...]',
'containing same end unbounded start' => '[...9], [10...20]',
],
$this->scenarioResults(
new IntegerRange(10, 20),
@ -1097,20 +1097,20 @@ class IntegerRangeTest extends TestCase
$this->assertEquals(
[
'same' => '[...20]',
'unbounded' => '[...20] [21...]',
'intersecting open start left' => '[...19] [20...20]',
'intersecting open start right' => '[...20] [21...21]',
'intersecting bounded start left' => '[...8] [9...19] [20...20]',
'intersecting bounded start right' => '[...10] [11...20] [21...21]',
'intersecting bounded start center' => '[...9] [10...20]',
'intersecting open end same' => '[...19] [20...20] [21...]',
'intersecting open end left' => '[...18] [19...20] [21...]',
'intersecting bounded end same' => '[...19] [20...20] [21...30]',
'intersecting bounded end left' => '[...18] [19...20] [21...29]',
'adjacent open end' => '[...20] [21...]',
'adjacent bounded end' => '[...20] [21...30]',
'disjoint open end' => '[...20] [22...]',
'disjoint bounded end' => '[...20] [22...32]',
'unbounded' => '[...20], [21...]',
'intersecting open start left' => '[...19], [20...20]',
'intersecting open start right' => '[...20], [21...21]',
'intersecting bounded start left' => '[...8], [9...19], [20...20]',
'intersecting bounded start right' => '[...10], [11...20], [21...21]',
'intersecting bounded start center' => '[...9], [10...20]',
'intersecting open end same' => '[...19], [20...20], [21...]',
'intersecting open end left' => '[...18], [19...20], [21...]',
'intersecting bounded end same' => '[...19], [20...20], [21...30]',
'intersecting bounded end left' => '[...18], [19...20], [21...29]',
'adjacent open end' => '[...20], [21...]',
'adjacent bounded end' => '[...20], [21...30]',
'disjoint open end' => '[...20], [22...]',
'disjoint bounded end' => '[...20], [22...32]',
],
$this->scenarioResults(
new IntegerRange(null, 20),
@ -1121,20 +1121,20 @@ class IntegerRangeTest extends TestCase
$this->assertEquals(
[
'same' => '[10...]',
'unbounded' => '[...9] [10...]',
'intersecting open end left' => '[9...9] [10...]',
'intersecting open end right' => '[10...10] [11...]',
'intersecting bounded end left' => '[9...9] [10...19] [20...]',
'intersecting bounded end right' => '[10...10] [11...21] [22...]',
'intersecting bounded end center' => '[10...20] [21...]',
'intersecting open start same' => '[...9] [10...10] [11...]',
'intersecting open start right' => '[...9] [10...11] [12...]',
'intersecting bounded start same' => '[0...9] [10...10] [11...]',
'intersecting bounded start right' => '[1...9] [10...11] [12...]',
'adjacent open start' => '[...9] [10...]',
'adjacent bounded start' => '[0...9] [10...]',
'disjoint open start' => '[...8] [10...]',
'disjoint bounded start' => '[-2...8] [10...]',
'unbounded' => '[...9], [10...]',
'intersecting open end left' => '[9...9], [10...]',
'intersecting open end right' => '[10...10], [11...]',
'intersecting bounded end left' => '[9...9], [10...19], [20...]',
'intersecting bounded end right' => '[10...10], [11...21], [22...]',
'intersecting bounded end center' => '[10...20], [21...]',
'intersecting open start same' => '[...9], [10...10], [11...]',
'intersecting open start right' => '[...9], [10...11], [12...]',
'intersecting bounded start same' => '[0...9], [10...10], [11...]',
'intersecting bounded start right' => '[1...9], [10...11], [12...]',
'adjacent open start' => '[...9], [10...]',
'adjacent bounded start' => '[0...9], [10...]',
'disjoint open start' => '[...8], [10...]',
'disjoint bounded start' => '[-2...8], [10...]',
],
$this->scenarioResults(
new IntegerRange(10, null),
@ -1145,9 +1145,9 @@ class IntegerRangeTest extends TestCase
$this->assertEquals(
[
'same' => '[...]',
'open start' => '[...20] [21...]',
'open end' => '[...9] [10...]',
'bounded' => '[...9] [10...20] [21...]',
'open start' => '[...20], [21...]',
'open end' => '[...9], [10...]',
'bounded' => '[...9], [10...20], [21...]',
],
$this->scenarioResults(
new IntegerRange(null, null),
@ -1242,12 +1242,9 @@ class IntegerRangeTest extends TestCase
return array_map(
function ($s) use ($range, $method) {
$result = $range->$method($s);
if ($result instanceof IntegerRange) {
if ($result instanceof IntegerRange || $result instanceof RangeCollection) {
$result = (string)$result;
}
if ($result instanceof RangeCollection) {
$result = implode(' ', $result->toArray());
}
return $result;
},
$this->createScenarios($range)

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
);
}
}