diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f03fa25..0786b49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: php-actions/composer@v6 - uses: php-actions/phpstan@v3 - - uses: php-actions/phpunit@v3 + - uses: php-actions/phpunit@v4 with: version: 10 test_suffix: "Test.php" diff --git a/README.md b/README.md index 695432a..f361b2d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Joby's PHP Toolbox +[![CI](https://github.com/joby-lol/php-toolbox/actions/workflows/ci.yml/badge.svg)](https://github.com/joby-lol/php-toolbox/actions/workflows/ci.yml) + A lightweight collection of useful general purpose PHP tools with no dependencies. Committed to always at least having minimal dependencies. ## Development status diff --git a/src/Ranges/AbstractRange.php b/src/Ranges/AbstractRange.php new file mode 100644 index 0000000..4b155a6 --- /dev/null +++ b/src/Ranges/AbstractRange.php @@ -0,0 +1,417 @@ +setStart($start); + $this->setEnd($end); + } + + /** + * Perform a boolean AND operation on this range and another, returning the + * range that they both cover, returns null if they do not overlap. + * @param static $other + */ + public function booleanAnd(AbstractRange $other): static|null + { + if ($this->contains($other)) return new static($other->start(), $other->end()); + elseif ($other->contains($this)) return new static($this->start(), $this->end()); + elseif ($this->intersects($other)) { + return new static( + $this->extendsBefore($other) ? $other->start() : $this->start(), + $this->extendsAfter($other) ? $other->end() : $this->end() + ); + } else return null; + } + + /** + * Perform a boolean OR operation on this range and another, returning an + * array of all areas that are covered by either range (if the ranges do not + * overlap this array will contain both ranges separately). Separate objects + * must be returned in ascending order. + * @param static $other + * @return RangeCollection + */ + public function booleanOr(AbstractRange $other): RangeCollection + { + if ($this->intersects($other) || $this->adjacent($other)) { + return RangeCollection::create( + new static( + $this->extendsBefore($other) ? $this->start() : $other->start(), + $this->extendsAfter($other) ? $this->end() : $other->end() + ) + ); + } else { + if ($this->extendsBefore($other)) { + return RangeCollection::create( + new static($this->start(), $this->end()), + new static($other->start(), $other->end()) + ); + } else { + return RangeCollection::create( + new static($other->start(), $other->end()), + new static($this->start(), $this->end()) + ); + } + } + } + + /** + * Perform a boolean XOR operation on this range and another, returning an + * array of all areas that are covered by either range but not both. If the + * ranges do not overlap, this array will contain both ranges separately. + * Separate objects must be returned in ascending order. + * @param static $other + * @return RangeCollection + */ + public function booleanXor(AbstractRange $other): RangeCollection + { + // if the ranges are equal, return an empty array + if ($this->equals($other)) return RangeCollection::createEmpty($other); + // if the ranges are adjacent return a single range + if ($this->adjacent($other)) return $this->booleanOr($other); + // if the ranges do not overlap, return both ranges + if (!$this->intersects($other)) { + return RangeCollection::create(new static($this->start(), $this->end()), new static($other->start(), $other->end())); + } + // otherwise get the maximum bounds minus wherever these intersect + $range = new static( + $this->extendsBefore($other) ? $this->start() : $other->start(), + $this->extendsAfter($other) ? $this->end() : $other->end() + ); + if ($intersect = $this->booleanAnd($other)) { + return $range->booleanNot($intersect); + } else { + return RangeCollection::create($range); + } + } + + /** + * Find all areas that are covered by both this range and another, sliced + * into up to three different ranges along the boundaries other boolean + * operations would break on. Areas will be returned in ascending order, and + * some information about the relationships between the ranges can be inferred + * from the number fo ranges returned here: + * - 1 range: the entered ranges are equal + * - 2 ranges: the entered ranges are adjacent, disjoint, or overlap with a shared boundary + * - 3 ranges: the entered ranges overlap with space on each end + * @param static $other + * @return RangeCollection + */ + public function booleanSlice(AbstractRange $other): RangeCollection + { + // if the ranges are equal, return a single range + if ($this->equals($other)) return RangeCollection::create(new static($this->start(), $this->end())); + // if the ranges do not overlap, return two ranges + if (!$this->intersects($other)) { + return RangeCollection::create( + new static($this->start(), $this->end()), + new static($other->start(), $other->end()) + ); + } + // otherwise get the maximum bounds minus wherever these intersect + $overall_range = new static( + $this->extendsBefore($other) ? $this->start() : $other->start(), + $this->extendsAfter($other) ? $this->end() : $other->end() + ); + $intersection = $this->booleanAnd($other); + assert($intersection !== null); + $xor = $overall_range->booleanNot($intersection); + if (count($xor) == 2) { + assert(isset($xor[0], $xor[1])); + return RangeCollection::create($xor[0], $intersection, $xor[1]); + } elseif (count($xor) == 1) { + assert(isset($xor[0])); + return RangeCollection::create($intersection, $xor[0]); + } + // throw an exception if we get in an unexpected state + throw new RuntimeException(sprintf("Unexpected state (%s,%s) (%s,%s)", $this->start, $this->end, $other->start, $other->end)); + } + + /** + * Perform a boolean NOT operation on this range and another, returning an + * array of all areas that are covered by this range but not the other. If + * the other range completely covers this range, an empty array will be + * returned. Separate objects must be returned in ascending order. + * @param static $other + * @return RangeCollection + */ + public function booleanNot(AbstractRange $other): RangeCollection + { + // if this range is completely contained by the other, return an empty array + if ($other->contains($this)) { + return RangeCollection::createEmpty($other); + } + // if the ranges do not overlap, return this range + if (!$this->intersects($other)) { + return RangeCollection::create(new static($this->start(), $this->end())); + } + // if this range completely contains the other, return the range from the start of this range to the start of the other + if ($this->contains($other)) { + if ($this->start == $other->start) { + return RangeCollection::create(new static(static::valueAfter($other->end), $this->end())); + } elseif ($this->end == $other->end) { + return RangeCollection::create(new static($this->start(), static::valueBefore($other->start))); + } else { + return RangeCollection::create( + new static($this->start(), static::valueBefore($other->start)), + new static(static::valueAfter($other->end), $this->end()) + ); + } + } + // if this range extends before the other, return the range from the start of this range to the start of the other + if ($this->extendsBefore($other)) { + return RangeCollection::create(new static($this->start(), static::valueBefore($other->start))); + } + // if this range extends after the other, return the range from the end of the other to the end of this range + if ($this->extendsAfter($other)) { + return RangeCollection::create(new static(static::valueAfter($other->end), $this->end())); + } + // throw an exception if we get in an unexpected state + throw new RuntimeException(sprintf("Unexpected state (%s,%s) (%s,%s)", $this->start, $this->end, $other->start, $other->end)); + } + + /** + * Check if this range has the same start and end as another range. + * @param static $other + */ + public function equals(AbstractRange $other): bool + { + return $this->start == $other->start && $this->end == $other->end; + } + + /** + * Check if any part of this range overlaps with another range. + * @param static $other + */ + public function intersects(AbstractRange $other): bool + { + if ($this->start > $other->end) return false; + if ($this->end < $other->start) return false; + return true; + } + + /** + * Check if this range completely contains another range. + * @param static $other + */ + public function contains(AbstractRange $other): bool + { + if ($this->start > $other->start) return false; + if ($this->end < $other->end) return false; + return true; + } + + /** + * Check if the end of this range is after the end of another range + * @param static $other + */ + public function extendsAfter(AbstractRange $other): bool + { + return $this->end > $other->end; + } + + /** + * Check if the start of this range is before the start of another range + * @param static $other + */ + public function extendsBefore(AbstractRange $other): bool + { + return $this->start < $other->start; + } + + /** + * Check if this range is directly adjacent to but not overlapping another range. This + * is equivalent to checking both abutsStartOf and abutsEndOf. + * @param static $other + */ + public function adjacent(AbstractRange $other): bool + { + return $this->adjacentRightOf($other) || $this->adjacentLeftOf($other); + } + + /** + * Check if the start of this range directly abuts the end of another range. + * This means that they do not overlap, but are directly adjacent. + * @param static $other + */ + public function adjacentRightOf(AbstractRange $other): bool + { + if ($this->start == -INF || $other->end == INF) return false; + return $this->start == $other->end + 1; + } + + /** + * Check if the end of this range directly abuts the start of another range. + * This means that they do not overlap, but are directly adjacent. + * @param static $other + */ + public function adjacentLeftOf(AbstractRange $other): bool + { + if ($this->end == INF || $other->start == -INF) return false; + return $this->end == $other->start - 1; + } + + /** + * @param T|null $start + * @return static + */ + public function setStart(mixed $start): static + { + $this->start = is_null($start) ? -INF + : static::valueToInteger($start); + $this->start_value = is_null($start) ? null + : static::prepareValue($start); + return $this; + } + + /** + * @param T|null $end + * @return static + */ + public function setEnd(mixed $end): static + { + $this->end = is_null($end) ? INF + : static::valueToInteger($end); + $this->end_value = is_null($end) ? null + : static::prepareValue($end); + return $this; + } + + /** + * @return T|null + */ + public function start(): mixed + { + return $this->start_value; + } + + /** + * @return T|null + */ + public function end(): mixed + { + return $this->end_value; + } + + public function startAsNumber(): int|float + { + return $this->start; + } + + public function endAsNumber(): int|float + { + return $this->end; + } + + public function __toString(): string + { + return sprintf( + '[%s...%s]', + $this->start === -INF ? '' : (is_string($this->start_value) ? $this->start_value : $this->start), + $this->end === INF ? '' : (is_string($this->end_value) ? $this->end_value : $this->end) + ); + } +} diff --git a/src/Ranges/IntegerRange.php b/src/Ranges/IntegerRange.php new file mode 100644 index 0000000..aaf71d1 --- /dev/null +++ b/src/Ranges/IntegerRange.php @@ -0,0 +1,57 @@ + + */ +class IntegerRange extends AbstractRange +{ + protected static function valueToInteger(mixed $value): int + { + return (int)$value; + } + + protected static function integerToValue(int $integer): mixed + { + return $integer; + } + + protected static function prepareValue(mixed $value): mixed + { + return (int)$value; + } +} diff --git a/src/Ranges/RangeCollection.php b/src/Ranges/RangeCollection.php new file mode 100644 index 0000000..2905e8b --- /dev/null +++ b/src/Ranges/RangeCollection.php @@ -0,0 +1,314 @@ + + * @implements IteratorAggregate + */ +class RangeCollection implements Countable, ArrayAccess, IteratorAggregate, Stringable +{ + /** @var class-string */ + protected string $class; + /** @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 + * @return RangeCollection + */ + public static function create(AbstractRange $range, AbstractRange ...$ranges): RangeCollection + { + return new RangeCollection($range::class, $range, ...$ranges); + } + + /** + * Create an empty collection of a specific range type. + * + * @template RangeType of AbstractRange + * @param RangeType|class-string $class + * @return RangeCollection + */ + public static function createEmpty(AbstractRange|string $class): RangeCollection + { + if (is_object($class)) return new RangeCollection($class::class); + else return new RangeCollection($class); + } + + /** + * @param class-string $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 + { + return $this->ranges; + } + + /** + * @param T ...$ranges + * @return RangeCollection + */ + public function add(AbstractRange ...$ranges): RangeCollection + { + $ranges = array_merge($this->ranges, $ranges); + return new RangeCollection($this->class, ...$ranges); + } + + protected function sort(): void + { + static $sorter; + $sorter = $sorter ?? $sorter = new Sorter( + fn (AbstractRange $a, AbstractRange $b): int => $a->startAsNumber() <=> $b->startAsNumber(), + fn (AbstractRange $a, AbstractRange $b): int => $a->endAsNumber() <=> $b->endAsNumber(), + ); + $sorter->sort($this->ranges); + } + + public function count(): int + { + return count($this->ranges); + } + + /** + * @param int $offset + * @return bool + */ + public function offsetExists($offset): bool + { + return isset($this->ranges[$offset]); + } + + /** + * @param int $offset + * @return T|null + */ + public function offsetGet($offset): ?AbstractRange + { + return $this->ranges[$offset] ?? null; + } + + /** + * @param int|null $offset + * @param T $value + */ + public function offsetSet($offset, $value): void + { + if (is_null($offset)) $this->add($value); + else { + if (!($value instanceof $this->class)) { + throw new InvalidArgumentException("Ranges must be of type $this->class"); + } + $this->ranges[$offset] = $value; + $this->sort(); + } + } + + /** + * @param int $offset + */ + public function offsetUnset($offset): void + { + unset($this->ranges[$offset]); + } + + /** + * @return ArrayIterator + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->ranges); + } + + public function __toString(): string + { + return implode(', ', $this->ranges); + } +} diff --git a/src/Sorting/Sorter.php b/src/Sorting/Sorter.php index 0b2d30e..5d9d516 100644 --- a/src/Sorting/Sorter.php +++ b/src/Sorting/Sorter.php @@ -37,7 +37,7 @@ namespace Joby\Toolbox\Sorting; */ class Sorter { - /** @var array */ + /** @var array */ protected array $comparisons = []; /** @@ -45,7 +45,7 @@ class Sorter * The sorters will be called in order, and the array will be sorted based * on the first one to return a non-zero value. * - * @param callable(mixed, mixed): int ...$comparisons + * @param callable ...$comparisons */ public function __construct(callable ...$comparisons) { @@ -56,7 +56,7 @@ class Sorter * Add one or more sorting callbacks to this sorter. The new callbacks will * be appended to the end of the existing list of sorters. * - * @param callable(mixed, mixed): int ...$comparisons + * @param callable ...$comparisons */ public function addComparison(callable ...$comparisons): static { diff --git a/tests/Ranges/IntegerRangeTest.php b/tests/Ranges/IntegerRangeTest.php new file mode 100644 index 0000000..f3296e8 --- /dev/null +++ b/tests/Ranges/IntegerRangeTest.php @@ -0,0 +1,1253 @@ +assertInstanceOf(IntegerRange::class, $range); + $this->assertEquals(1, $range->start()); + $this->assertEquals(10, $range->end()); + // test open start + $range = new IntegerRange(null, 10); + $this->assertInstanceOf(IntegerRange::class, $range); + $this->assertNull($range->start()); + $this->assertEquals(10, $range->end()); + // test open end + $range = new IntegerRange(1, null); + $this->assertInstanceOf(IntegerRange::class, $range); + $this->assertEquals(1, $range->start()); + $this->assertNull($range->end()); + // test open both + $range = new IntegerRange(null, null); + $this->assertInstanceOf(IntegerRange::class, $range); + $this->assertNull($range->start()); + $this->assertNull($range->end()); + } + + public function testEquals() + { + // fully bounded + $this->assertEquals( + [ + 'same' => true, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => false, + 'intersecting open end center' => false, + 'intersecting open start left' => false, + 'intersecting open start right' => false, + 'intersecting open start center' => false, + 'disjoint open start' => false, + 'disjoint open end' => false, + 'disjoint left' => false, + 'disjoint right' => false, + 'adjacent open start' => false, + 'adjacent open end' => false, + 'adjacent left' => false, + 'adjacent right' => false, + 'contained' => false, + 'contained same start' => false, + 'contained same end' => false, + 'containing' => false, + 'containing unbounded start' => false, + 'containing unbounded end' => false, + 'containing same start' => false, + 'containing same end' => false, + 'containing same start unbounded end' => false, + 'containing same end unbounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'equals', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => true, + 'unbounded' => false, + 'intersecting open start left' => false, + 'intersecting open start right' => false, + 'intersecting bounded start left' => false, + 'intersecting bounded start right' => false, + 'intersecting bounded start center' => false, + 'intersecting open end same' => false, + 'intersecting open end left' => false, + 'intersecting bounded end same' => false, + 'intersecting bounded end left' => false, + 'adjacent open end' => false, + 'adjacent bounded end' => false, + 'disjoint open end' => false, + 'disjoint bounded end' => false, + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'equals', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => true, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => false, + 'intersecting bounded end left' => false, + 'intersecting bounded end right' => false, + 'intersecting bounded end center' => false, + 'intersecting open start same' => false, + 'intersecting open start right' => false, + 'intersecting bounded start same' => false, + 'intersecting bounded start right' => false, + 'adjacent open start' => false, + 'adjacent bounded start' => false, + 'disjoint open start' => false, + 'disjoint bounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'equals', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => true, + 'open start' => false, + 'open end' => false, + 'bounded' => false, + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'equals', + ) + ); + } + + public function testIntersects() + { + // fully bounded + $this->assertEquals( + [ + 'same' => true, + 'unbounded' => true, + 'intersecting open end left' => true, + 'intersecting open end right' => true, + 'intersecting open end center' => true, + 'intersecting open start left' => true, + 'intersecting open start right' => true, + 'intersecting open start center' => true, + 'disjoint open start' => false, + 'disjoint open end' => false, + 'disjoint left' => false, + 'disjoint right' => false, + 'adjacent open start' => false, + 'adjacent open end' => false, + 'adjacent left' => false, + 'adjacent right' => false, + 'contained' => true, + 'contained same start' => true, + 'contained same end' => true, + 'containing' => true, + 'containing unbounded start' => true, + 'containing unbounded end' => true, + 'containing same start' => true, + 'containing same end' => true, + 'containing same start unbounded end' => true, + 'containing same end unbounded start' => true, + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'intersects', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => true, + 'unbounded' => true, + 'intersecting open start left' => true, + 'intersecting open start right' => true, + 'intersecting bounded start left' => true, + 'intersecting bounded start right' => true, + 'intersecting bounded start center' => true, + 'intersecting open end same' => true, + 'intersecting open end left' => true, + 'intersecting bounded end same' => true, + 'intersecting bounded end left' => true, + 'adjacent open end' => false, + 'adjacent bounded end' => false, + 'disjoint open end' => false, + 'disjoint bounded end' => false, + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'intersects', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => true, + 'unbounded' => true, + 'intersecting open end left' => true, + 'intersecting open end right' => true, + 'intersecting bounded end left' => true, + 'intersecting bounded end right' => true, + 'intersecting bounded end center' => true, + 'intersecting open start same' => true, + 'intersecting open start right' => true, + 'intersecting bounded start same' => true, + 'intersecting bounded start right' => true, + 'adjacent open start' => false, + 'adjacent bounded start' => false, + 'disjoint open start' => false, + 'disjoint bounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'intersects', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => true, + 'open start' => true, + 'open end' => true, + 'bounded' => true, + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'intersects', + ) + ); + } + + public function testContains() + { + // fully bounded + $this->assertEquals( + [ + 'same' => true, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => false, + 'intersecting open end center' => false, + 'intersecting open start left' => false, + 'intersecting open start right' => false, + 'intersecting open start center' => false, + 'disjoint open start' => false, + 'disjoint open end' => false, + 'disjoint left' => false, + 'disjoint right' => false, + 'adjacent open start' => false, + 'adjacent open end' => false, + 'adjacent left' => false, + 'adjacent right' => false, + 'contained' => true, + 'contained same start' => true, + 'contained same end' => true, + 'containing' => false, + 'containing unbounded start' => false, + 'containing unbounded end' => false, + 'containing same start' => false, + 'containing same end' => false, + 'containing same start unbounded end' => false, + 'containing same end unbounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'contains', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => true, + 'unbounded' => false, + 'intersecting open start left' => true, + 'intersecting open start right' => false, + 'intersecting bounded start left' => true, + 'intersecting bounded start right' => false, + 'intersecting bounded start center' => true, + 'intersecting open end same' => false, + 'intersecting open end left' => false, + 'intersecting bounded end same' => false, + 'intersecting bounded end left' => false, + 'adjacent open end' => false, + 'adjacent bounded end' => false, + 'disjoint open end' => false, + 'disjoint bounded end' => false, + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'contains', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => true, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => true, + 'intersecting bounded end left' => false, + 'intersecting bounded end right' => true, + 'intersecting bounded end center' => true, + 'intersecting open start same' => false, + 'intersecting open start right' => false, + 'intersecting bounded start same' => false, + 'intersecting bounded start right' => false, + 'adjacent open start' => false, + 'adjacent bounded start' => false, + 'disjoint open start' => false, + 'disjoint bounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'contains', + ) + ); + // fully unbounded contains anything + $this->assertEquals( + [ + 'same' => true, + 'open start' => true, + 'open end' => true, + 'bounded' => true, + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'contains', + ) + ); + } + + public function testAdjacentRightOf() + { + // fully bounded only abuts the end of things that are adjacent left + $this->assertEquals( + [ + 'same' => false, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => false, + 'intersecting open end center' => false, + 'intersecting open start left' => false, + 'intersecting open start right' => false, + 'intersecting open start center' => false, + 'disjoint open start' => false, + 'disjoint open end' => false, + 'disjoint left' => false, + 'disjoint right' => false, + 'adjacent open start' => true, + 'adjacent open end' => false, + 'adjacent left' => true, + 'adjacent right' => false, + 'contained' => false, + 'contained same start' => false, + 'contained same end' => false, + 'containing' => false, + 'containing unbounded start' => false, + 'containing unbounded end' => false, + 'containing same start' => false, + 'containing same end' => false, + 'containing same start unbounded end' => false, + 'containing same end unbounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'adjacentRightOf', + ) + ); + // open start can't abut the end of anything + $this->assertEquals( + [ + 'same' => false, + 'unbounded' => false, + 'intersecting open start left' => false, + 'intersecting open start right' => false, + 'intersecting bounded start left' => false, + 'intersecting bounded start right' => false, + 'intersecting bounded start center' => false, + 'intersecting open end same' => false, + 'intersecting open end left' => false, + 'intersecting bounded end same' => false, + 'intersecting bounded end left' => false, + 'adjacent open end' => false, + 'adjacent bounded end' => false, + 'disjoint open end' => false, + 'disjoint bounded end' => false, + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'adjacentRightOf', + ) + ); + // open end only abuts the end of things that are adjacent left + $this->assertEquals( + [ + 'same' => false, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => false, + 'intersecting bounded end left' => false, + 'intersecting bounded end right' => false, + 'intersecting bounded end center' => false, + 'intersecting open start same' => false, + 'intersecting open start right' => false, + 'intersecting bounded start same' => false, + 'intersecting bounded start right' => false, + 'adjacent open start' => true, + 'adjacent bounded start' => true, + 'disjoint open start' => false, + 'disjoint bounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'adjacentRightOf', + ) + ); + // fully unbounded can't abut anything + $this->assertEquals( + [ + 'same' => false, + 'open start' => false, + 'open end' => false, + 'bounded' => false, + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'adjacentRightOf', + ) + ); + } + + public function testAdjacentLeftOf() + { + // fully bounded only abuts the start of things that are adjacent right + $this->assertEquals( + [ + 'same' => false, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => false, + 'intersecting open end center' => false, + 'intersecting open start left' => false, + 'intersecting open start right' => false, + 'intersecting open start center' => false, + 'disjoint open start' => false, + 'disjoint open end' => false, + 'disjoint left' => false, + 'disjoint right' => false, + 'adjacent open start' => false, + 'adjacent open end' => true, + 'adjacent left' => false, + 'adjacent right' => true, + 'contained' => false, + 'contained same start' => false, + 'contained same end' => false, + 'containing' => false, + 'containing unbounded start' => false, + 'containing unbounded end' => false, + 'containing same start' => false, + 'containing same end' => false, + 'containing same start unbounded end' => false, + 'containing same end unbounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'adjacentLeftOf', + ) + ); + // open start only abuts the start of things that are adjacent right + $this->assertEquals( + [ + 'same' => false, + 'unbounded' => false, + 'intersecting open start left' => false, + 'intersecting open start right' => false, + 'intersecting bounded start left' => false, + 'intersecting bounded start right' => false, + 'intersecting bounded start center' => false, + 'intersecting open end same' => false, + 'intersecting open end left' => false, + 'intersecting bounded end same' => false, + 'intersecting bounded end left' => false, + 'adjacent open end' => true, + 'adjacent bounded end' => true, + 'disjoint open end' => false, + 'disjoint bounded end' => false, + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'adjacentLeftOf', + ) + ); + // open end can't abut the start of anything + $this->assertEquals( + [ + 'same' => false, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => false, + 'intersecting bounded end left' => false, + 'intersecting bounded end right' => false, + 'intersecting bounded end center' => false, + 'intersecting open start same' => false, + 'intersecting open start right' => false, + 'intersecting bounded start same' => false, + 'intersecting bounded start right' => false, + 'adjacent open start' => false, + 'adjacent bounded start' => false, + 'disjoint open start' => false, + 'disjoint bounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'adjacentLeftOf', + ) + ); + // fully unbounded can't abut anything + $this->assertEquals( + [ + 'same' => false, + 'open start' => false, + 'open end' => false, + 'bounded' => false, + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'adjacentLeftOf', + ) + ); + } + + public function testAdjacent() + { + // fully bounded only abuts things that are adjacent + $this->assertEquals( + [ + 'same' => false, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => false, + 'intersecting open end center' => false, + 'intersecting open start left' => false, + 'intersecting open start right' => false, + 'intersecting open start center' => false, + 'disjoint open start' => false, + 'disjoint open end' => false, + 'disjoint left' => false, + 'disjoint right' => false, + 'adjacent open start' => true, + 'adjacent open end' => true, + 'adjacent left' => true, + 'adjacent right' => true, + 'contained' => false, + 'contained same start' => false, + 'contained same end' => false, + 'containing' => false, + 'containing unbounded start' => false, + 'containing unbounded end' => false, + 'containing same start' => false, + 'containing same end' => false, + 'containing same start unbounded end' => false, + 'containing same end unbounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'adjacent', + ) + ); + // open start abuts adjacent right + $this->assertEquals( + [ + 'same' => false, + 'unbounded' => false, + 'intersecting open start left' => false, + 'intersecting open start right' => false, + 'intersecting bounded start left' => false, + 'intersecting bounded start right' => false, + 'intersecting bounded start center' => false, + 'intersecting open end same' => false, + 'intersecting open end left' => false, + 'intersecting bounded end same' => false, + 'intersecting bounded end left' => false, + 'adjacent open end' => true, + 'adjacent bounded end' => true, + 'disjoint open end' => false, + 'disjoint bounded end' => false, + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'adjacent', + ) + ); + // open end abuts adjacent left + $this->assertEquals( + [ + 'same' => false, + 'unbounded' => false, + 'intersecting open end left' => false, + 'intersecting open end right' => false, + 'intersecting bounded end left' => false, + 'intersecting bounded end right' => false, + 'intersecting bounded end center' => false, + 'intersecting open start same' => false, + 'intersecting open start right' => false, + 'intersecting bounded start same' => false, + 'intersecting bounded start right' => false, + 'adjacent open start' => true, + 'adjacent bounded start' => true, + 'disjoint open start' => false, + 'disjoint bounded start' => false, + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'adjacent', + ) + ); + // fully unbounded can't abut anything + $this->assertEquals( + [ + 'same' => false, + 'open start' => false, + 'open end' => false, + 'bounded' => false, + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'adjacent', + ) + ); + } + + public function testBooleanAnd() + { + // fully bounded + $this->assertEquals( + [ + 'same' => '[10...20]', + 'unbounded' => '[10...20]', + 'intersecting open end left' => '[10...20]', + 'intersecting open end right' => '[11...20]', + 'intersecting open end center' => '[10...20]', + 'intersecting open start left' => '[10...19]', + 'intersecting open start right' => '[10...20]', + 'intersecting open start center' => '[10...20]', + 'disjoint open start' => null, + 'disjoint open end' => null, + 'disjoint left' => null, + 'disjoint right' => null, + 'adjacent open start' => null, + 'adjacent open end' => null, + 'adjacent left' => null, + 'adjacent right' => null, + 'contained' => '[11...19]', + 'contained same start' => '[10...19]', + 'contained same end' => '[11...20]', + 'containing' => '[10...20]', + 'containing unbounded start' => '[10...20]', + 'containing unbounded end' => '[10...20]', + 'containing same start' => '[10...20]', + 'containing same end' => '[10...20]', + 'containing same start unbounded end' => '[10...20]', + 'containing same end unbounded start' => '[10...20]', + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'booleanAnd', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => '[...20]', + 'unbounded' => '[...20]', + 'intersecting open start left' => '[...19]', + 'intersecting open start right' => '[...20]', + 'intersecting bounded start left' => '[9...19]', + 'intersecting bounded start right' => '[11...20]', + 'intersecting bounded start center' => '[10...20]', + 'intersecting open end same' => '[20...20]', + 'intersecting open end left' => '[19...20]', + 'intersecting bounded end same' => '[20...20]', + 'intersecting bounded end left' => '[19...20]', + 'adjacent open end' => null, + 'adjacent bounded end' => null, + 'disjoint open end' => null, + 'disjoint bounded end' => null, + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'booleanAnd', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => '[10...]', + 'unbounded' => '[10...]', + 'intersecting open end left' => '[10...]', + 'intersecting open end right' => '[11...]', + 'intersecting bounded end left' => '[10...19]', + 'intersecting bounded end right' => '[11...21]', + 'intersecting bounded end center' => '[10...20]', + 'intersecting open start same' => '[10...10]', + 'intersecting open start right' => '[10...11]', + 'intersecting bounded start same' => '[10...10]', + 'intersecting bounded start right' => '[10...11]', + 'adjacent open start' => null, + 'adjacent bounded start' => null, + 'disjoint open start' => null, + 'disjoint bounded start' => null, + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'booleanAnd', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => '[...]', + 'open start' => '[...20]', + 'open end' => '[10...]', + 'bounded' => '[10...20]', + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'booleanAnd', + ) + ); + } + + public function testBooleanOr() + { + // fully bounded + $this->assertEquals( + [ + 'same' => '[10...20]', + 'unbounded' => '[...]', + 'intersecting open end left' => '[9...]', + 'intersecting open end right' => '[10...]', + 'intersecting open end center' => '[10...]', + '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]', + 'adjacent open start' => '[...20]', + 'adjacent open end' => '[10...]', + 'adjacent left' => '[-1...20]', + 'adjacent right' => '[10...31]', + 'contained' => '[10...20]', + 'contained same start' => '[10...20]', + 'contained same end' => '[10...20]', + 'containing' => '[9...21]', + 'containing unbounded start' => '[...21]', + 'containing unbounded end' => '[9...]', + 'containing same start' => '[10...21]', + 'containing same end' => '[9...20]', + 'containing same start unbounded end' => '[10...]', + 'containing same end unbounded start' => '[...20]', + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'booleanOr', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => '[...20]', + 'unbounded' => '[...]', + 'intersecting open start left' => '[...20]', + 'intersecting open start right' => '[...21]', + 'intersecting bounded start left' => '[...20]', + 'intersecting bounded start right' => '[...21]', + 'intersecting bounded start center' => '[...20]', + 'intersecting open end same' => '[...]', + 'intersecting open end left' => '[...]', + 'intersecting bounded end same' => '[...30]', + 'intersecting bounded end left' => '[...29]', + 'adjacent open end' => '[...]', + 'adjacent bounded end' => '[...30]', + 'disjoint open end' => '[...20], [22...]', + 'disjoint bounded end' => '[...20], [22...32]', + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'booleanOr', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => '[10...]', + 'unbounded' => '[...]', + 'intersecting open end left' => '[9...]', + 'intersecting open end right' => '[10...]', + 'intersecting bounded end left' => '[9...]', + 'intersecting bounded end right' => '[10...]', + 'intersecting bounded end center' => '[10...]', + 'intersecting open start same' => '[...]', + 'intersecting open start right' => '[...]', + 'intersecting bounded start same' => '[0...]', + 'intersecting bounded start right' => '[1...]', + 'adjacent open start' => '[...]', + 'adjacent bounded start' => '[0...]', + 'disjoint open start' => '[...8], [10...]', + 'disjoint bounded start' => '[-2...8], [10...]', + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'booleanOr', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => '[...]', + 'open start' => '[...]', + 'open end' => '[...]', + 'bounded' => '[...]', + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'booleanOr', + ) + ); + } + + public function testBooleanNot() + { + // fully bounded + $this->assertEquals( + [ + 'same' => '', + 'unbounded' => '', + 'intersecting open end left' => '', + 'intersecting open end right' => '[10...10]', + 'intersecting open end center' => '', + 'intersecting open start left' => '[20...20]', + 'intersecting open start right' => '', + 'intersecting open start center' => '', + 'disjoint open start' => '[10...20]', + 'disjoint open end' => '[10...20]', + 'disjoint left' => '[10...20]', + 'disjoint right' => '[10...20]', + 'adjacent open start' => '[10...20]', + 'adjacent open end' => '[10...20]', + 'adjacent left' => '[10...20]', + 'adjacent right' => '[10...20]', + 'contained' => '[10...10], [20...20]', + 'contained same start' => '[20...20]', + 'contained same end' => '[10...10]', + 'containing' => '', + 'containing unbounded start' => '', + 'containing unbounded end' => '', + 'containing same start' => '', + 'containing same end' => '', + 'containing same start unbounded end' => '', + 'containing same end unbounded start' => '', + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'booleanNot', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => '', + 'unbounded' => '', + 'intersecting open start left' => '[20...20]', + 'intersecting open start right' => '', + 'intersecting bounded start left' => '[...8], [20...20]', + 'intersecting bounded start right' => '[...10]', + 'intersecting bounded start center' => '[...9]', + 'intersecting open end same' => '[...19]', + 'intersecting open end left' => '[...18]', + 'intersecting bounded end same' => '[...19]', + 'intersecting bounded end left' => '[...18]', + 'adjacent open end' => '[...20]', + 'adjacent bounded end' => '[...20]', + 'disjoint open end' => '[...20]', + 'disjoint bounded end' => '[...20]', + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'booleanNot', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => '', + 'unbounded' => '', + '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 center' => '[21...]', + 'intersecting open start same' => '[11...]', + 'intersecting open start right' => '[12...]', + 'intersecting bounded start same' => '[11...]', + 'intersecting bounded start right' => '[12...]', + 'adjacent open start' => '[10...]', + 'adjacent bounded start' => '[10...]', + 'disjoint open start' => '[10...]', + 'disjoint bounded start' => '[10...]', + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'booleanNot', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => '', + 'open start' => '[21...]', + 'open end' => '[...9]', + 'bounded' => '[...9], [21...]', + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'booleanNot', + ) + ); + } + + public function testBooleanXor() + { + // fully bounded + $this->assertEquals( + [ + 'same' => '', + '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 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]', + 'adjacent open start' => '[...20]', + 'adjacent open end' => '[10...]', + 'adjacent left' => '[-1...20]', + 'adjacent right' => '[10...31]', + '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 same start' => '[21...21]', + 'containing same end' => '[9...9]', + 'containing same start unbounded end' => '[21...]', + 'containing same end unbounded start' => '[...9]', + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'booleanXor', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => '', + '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 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]', + 'adjacent open end' => '[...]', + 'adjacent bounded end' => '[...30]', + 'disjoint open end' => '[...20], [22...]', + 'disjoint bounded end' => '[...20], [22...32]', + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'booleanXor', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => '', + '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 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...]', + 'adjacent open start' => '[...]', + 'adjacent bounded start' => '[0...]', + 'disjoint open start' => '[...8], [10...]', + 'disjoint bounded start' => '[-2...8], [10...]', + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'booleanXor', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => '', + 'open start' => '[21...]', + 'open end' => '[...9]', + 'bounded' => '[...9], [21...]', + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'booleanXor', + ) + ); + } + + public function testBooleanSlice() + { + // fully bounded + $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]', + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'booleanSlice', + ) + ); + // open start + $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]', + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'booleanSlice', + ) + ); + // open end + $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...]', + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'booleanSlice', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => '[...]', + 'open start' => '[...20], [21...]', + 'open end' => '[...9], [10...]', + 'bounded' => '[...9], [10...20], [21...]', + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'booleanSlice', + ) + ); + } + + protected function createScenarios(IntegerRange $range): array + { + if (is_null($range->start()) && is_null($range->end())) { + // scenarios for fully open range + return [ + 'same' => new IntegerRange(null, null), + 'open start' => new IntegerRange(null, 20), + 'open end' => new IntegerRange(10, null), + 'bounded' => new IntegerRange(10, 20), + ]; + } elseif (is_null($range->start())) { + // scenarios for unbounded start + return [ + 'same' => new IntegerRange(null, $range->end()), + 'unbounded' => new IntegerRange(null, null), + 'intersecting open start left' => new IntegerRange(null, $range->end() - 1), + 'intersecting open start right' => new IntegerRange(null, $range->end() + 1), + 'intersecting bounded start left' => new IntegerRange($range->end() - 11, $range->end() - 1), + 'intersecting bounded start right' => new IntegerRange($range->end() - 9, $range->end() + 1), + 'intersecting bounded start center' => new IntegerRange($range->end() - 10, $range->end()), + 'intersecting open end same' => new IntegerRange($range->end(), null), + 'intersecting open end left' => new IntegerRange($range->end() - 1, null), + 'intersecting bounded end same' => new IntegerRange($range->end(), $range->end() + 10), + 'intersecting bounded end left' => new IntegerRange($range->end() - 1, $range->end() + 9), + 'adjacent open end' => new IntegerRange($range->end() + 1, null), + 'adjacent bounded end' => new IntegerRange($range->end() + 1, $range->end() + 10), + 'disjoint open end' => new IntegerRange($range->end() + 2, null), + 'disjoint bounded end' => new IntegerRange($range->end() + 2, $range->end() + 12), + ]; + } elseif (is_null($range->end())) { + // scenarios for unbounded end + return [ + 'same' => new IntegerRange($range->start(), null), + 'unbounded' => new IntegerRange(null, null), + 'intersecting open end left' => new IntegerRange($range->start() - 1, null), + 'intersecting open end right' => new IntegerRange($range->start() + 1, null), + 'intersecting bounded end left' => new IntegerRange($range->start() - 1, $range->start() + 9), + 'intersecting bounded end right' => new IntegerRange($range->start() + 1, $range->start() + 11), + 'intersecting bounded end center' => new IntegerRange($range->start(), $range->start() + 10), + 'intersecting open start same' => new IntegerRange(null, $range->start()), + 'intersecting open start right' => new IntegerRange(null, $range->start() + 1), + 'intersecting bounded start same' => new IntegerRange($range->start() - 10, $range->start()), + 'intersecting bounded start right' => new IntegerRange($range->start() - 9, $range->start() + 1), + 'adjacent open start' => new IntegerRange(null, $range->start() - 1), + 'adjacent bounded start' => new IntegerRange($range->start() - 10, $range->start() - 1), + 'disjoint open start' => new IntegerRange(null, $range->start() - 2), + 'disjoint bounded start' => new IntegerRange($range->start() - 12, $range->start() - 2), + ]; + } else { + // scenarios for fully bounded range + return [ + 'same' => new IntegerRange($range->start(), $range->end()), + 'unbounded' => new IntegerRange(null, null), + 'intersecting open end left' => new IntegerRange($range->start() - 1, null), + 'intersecting open end right' => new IntegerRange($range->start() + 1, null), + 'intersecting open end center' => new IntegerRange($range->start(), null), + 'intersecting open start left' => new IntegerRange(null, $range->end() - 1), + 'intersecting open start right' => new IntegerRange(null, $range->end() + 1), + 'intersecting open start center' => new IntegerRange(null, $range->end()), + 'disjoint open start' => new IntegerRange(null, $range->start() - 2), + 'disjoint open end' => new IntegerRange($range->end() + 2, null), + 'disjoint left' => new IntegerRange($range->start() - 12, $range->start() - 2), + 'disjoint right' => new IntegerRange($range->end() + 2, $range->end() + 12), + 'adjacent open start' => new IntegerRange(null, $range->start() - 1), + 'adjacent open end' => new IntegerRange($range->end() + 1, null), + 'adjacent left' => new IntegerRange($range->start() - 11, $range->start() - 1), + 'adjacent right' => new IntegerRange($range->end() + 1, $range->end() + 11), + 'contained' => new IntegerRange($range->start() + 1, $range->end() - 1), + 'contained same start' => new IntegerRange($range->start(), $range->end() - 1), + 'contained same end' => new IntegerRange($range->start() + 1, $range->end()), + 'containing' => new IntegerRange($range->start() - 1, $range->end() + 1), + 'containing unbounded start' => new IntegerRange(null, $range->end() + 1), + 'containing unbounded end' => new IntegerRange($range->start() - 1, null), + 'containing same start' => new IntegerRange($range->start(), $range->end() + 1), + 'containing same end' => new IntegerRange($range->start() - 1, $range->end()), + 'containing same start unbounded end' => new IntegerRange($range->start(), null), + 'containing same end unbounded start' => new IntegerRange(null, $range->end()), + ]; + } + } + + protected function scenarioResults(IntegerRange $range, string $method): array + { + return array_map( + function ($s) use ($range, $method) { + $result = $range->$method($s); + if ($result instanceof IntegerRange || $result instanceof RangeCollection) { + $result = (string)$result; + } + 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 + ); + } +}