From f3c95c1f21ae49741daf0516db7b1f0f7999efa1 Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Tue, 30 Jul 2024 13:01:13 -0600 Subject: [PATCH] new features in RangeCollection --- src/Ranges/AbstractRange.php | 12 +- src/Ranges/IntegerRange.php | 11 +- src/Ranges/RangeCollection.php | 194 +++++++++++++-- tests/Ranges/IntegerRangeTest.php | 201 ++++++++-------- tests/Ranges/RangeCollectionTest.php | 346 +++++++++++++++++++++++++++ 5 files changed, 628 insertions(+), 136 deletions(-) create mode 100644 tests/Ranges/RangeCollectionTest.php diff --git a/src/Ranges/AbstractRange.php b/src/Ranges/AbstractRange.php index d393956..4b155a6 100644 --- a/src/Ranges/AbstractRange.php +++ b/src/Ranges/AbstractRange.php @@ -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) + ); + } } diff --git a/src/Ranges/IntegerRange.php b/src/Ranges/IntegerRange.php index 02f6df2..aaf71d1 100644 --- a/src/Ranges/IntegerRange.php +++ b/src/Ranges/IntegerRange.php @@ -38,7 +38,7 @@ use Stringable; * * @extends AbstractRange */ -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 - ); - } } diff --git a/src/Ranges/RangeCollection.php b/src/Ranges/RangeCollection.php index 22aac8a..2905e8b 100644 --- a/src/Ranges/RangeCollection.php +++ b/src/Ranges/RangeCollection.php @@ -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 * @implements IteratorAggregate */ -class RangeCollection implements Countable, ArrayAccess, IteratorAggregate +class RangeCollection implements Countable, ArrayAccess, IteratorAggregate, Stringable { + /** @var class-string */ protected string $class; - /** @var T[] */ + /** @var array */ 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 $class * @return RangeCollection @@ -66,7 +81,151 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate } /** - * @return T[] + * @param class-string $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 + */ + 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 + */ + 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 + */ + 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 + */ + public function mergeIntersectingRanges(): RangeCollection + { + /** @var array */ + $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 + */ + protected function mergeAdjacentRanges(): RangeCollection + { + /** @var array */ + $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 + */ + 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|null) $callback + * @return RangeCollection + */ + 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 */ public function toArray(): array { @@ -75,28 +234,12 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate /** * @param T ...$ranges + * @return RangeCollection */ - 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 $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); + } } diff --git a/tests/Ranges/IntegerRangeTest.php b/tests/Ranges/IntegerRangeTest.php index c3f0c41..f3296e8 100644 --- a/tests/Ranges/IntegerRangeTest.php +++ b/tests/Ranges/IntegerRangeTest.php @@ -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) diff --git a/tests/Ranges/RangeCollectionTest.php b/tests/Ranges/RangeCollectionTest.php new file mode 100644 index 0000000..1c398f3 --- /dev/null +++ b/tests/Ranges/RangeCollectionTest.php @@ -0,0 +1,346 @@ +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 + ); + } +}