new features in RangeCollection
This commit is contained in:
parent
e7d6aa07fa
commit
f3c95c1f21
5 changed files with 628 additions and 136 deletions
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
346
tests/Ranges/RangeCollectionTest.php
Normal file
346
tests/Ranges/RangeCollectionTest.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue