From 51bcf5f75ccfd75c47af267aa32a293bf925252d Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Fri, 26 Jul 2024 14:02:22 -0600 Subject: [PATCH 1/9] started range comparison tools --- src/Ranges/AbstractRange.php | 196 +++++++++++ src/Ranges/IntegerRange.php | 47 +++ tests/Ranges/IntegerRangeTest.php | 538 ++++++++++++++++++++++++++++++ 3 files changed, 781 insertions(+) create mode 100644 src/Ranges/AbstractRange.php create mode 100644 src/Ranges/IntegerRange.php create mode 100644 tests/Ranges/IntegerRangeTest.php diff --git a/src/Ranges/AbstractRange.php b/src/Ranges/AbstractRange.php new file mode 100644 index 0000000..d6290f2 --- /dev/null +++ b/src/Ranges/AbstractRange.php @@ -0,0 +1,196 @@ +setStart($start); + $this->setEnd($end); + } + + /** + * @param static $other + */ + public function equals(AbstractRange $other): bool + { + return $this->start == $other->start && $this->end == $other->end; + } + + /** + * @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; + } + + /** + * @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 abuts(AbstractRange $other): bool + { + return $this->abutsEndOf($other) || $this->abutsStartOf($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 abutsEndOf(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 abutsStartOf(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::convertToInt($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::convertToInt($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; + } +} diff --git a/src/Ranges/IntegerRange.php b/src/Ranges/IntegerRange.php new file mode 100644 index 0000000..0e81eb3 --- /dev/null +++ b/src/Ranges/IntegerRange.php @@ -0,0 +1,47 @@ + + */ +class IntegerRange extends AbstractRange +{ + + protected static function convertToInt(mixed $value): int + { + return (int)$value; + } + + protected static function prepareValue(mixed $value): mixed + { + return (int)$value; + } +} diff --git a/tests/Ranges/IntegerRangeTest.php b/tests/Ranges/IntegerRangeTest.php new file mode 100644 index 0000000..4284ad3 --- /dev/null +++ b/tests/Ranges/IntegerRangeTest.php @@ -0,0 +1,538 @@ +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->checkScenarioResults( + new IntegerRange(10, 20), + 'equals', + [ + '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, + ] + ); + // open start + $this->checkScenarioResults( + new IntegerRange(null, 20), + 'equals', + [ + '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, + ] + ); + // open end + $this->checkScenarioResults( + new IntegerRange(10, null), + 'equals', + [ + '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, + ] + ); + // fully unbounded + $this->checkScenarioResults( + new IntegerRange(null, null), + 'equals', + [ + 'same' => true, + 'open start' => false, + 'open end' => false, + 'bounded' => false, + ] + ); + } + + public function testIntersects() + { + // fully bounded + $this->checkScenarioResults( + new IntegerRange(10, 20), + 'intersects', + [ + '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, + ] + ); + // open start + $this->checkScenarioResults( + new IntegerRange(null, 20), + 'intersects', + [ + '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, + ] + ); + // open end + $this->checkScenarioResults( + new IntegerRange(10, null), + 'intersects', + [ + '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, + ] + ); + // fully unbounded + $this->checkScenarioResults( + new IntegerRange(null, null), + 'intersects', + [ + 'same' => true, + 'open start' => true, + 'open end' => true, + 'bounded' => true, + ] + ); + } + + public function testContains() + { + // fully bounded + $this->checkScenarioResults( + new IntegerRange(10, 20), + 'contains', + [ + '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, + ] + ); + // open start + $this->checkScenarioResults( + new IntegerRange(null, 20), + 'contains', + [ + '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, + ] + ); + // open end + $this->checkScenarioResults( + new IntegerRange(10, null), + 'contains', + [ + '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, + ] + ); + // fully unbounded + $this->checkScenarioResults( + new IntegerRange(null, null), + 'contains', + [ + 'same' => true, + 'open start' => true, + 'open end' => true, + 'bounded' => true, + ] + ); + } + + // TODO: testAbutsEndOf + + // TODO: see about making failures go to the right line/test + + public function testAbutsStartOf() + { + // fully bounded + $this->checkScenarioResults( + new IntegerRange(10, 20), + 'abutsStartOf', + [ + '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, + ] + ); + // open start + $this->checkScenarioResults( + new IntegerRange(null, 20), + 'abutsStartOf', + [ + '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, + ] + ); + // open end + $this->checkScenarioResults( + new IntegerRange(10, null), + 'abutsStartOf', + [ + '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, + ] + ); + // fully unbounded + $this->checkScenarioResults( + new IntegerRange(null, null), + 'abutsStartOf', + [ + 'same' => false, + 'open start' => false, + 'open end' => false, + 'bounded' => false, + ] + ); + } + + 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, 10), + '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() - 2), + 'intersecting open start right' => new IntegerRange(null, $range->end() + 2), + 'intersecting bounded start left' => new IntegerRange($range->end() - 12, $range->end() - 2), + 'intersecting bounded start right' => new IntegerRange($range->end() - 8, $range->end() + 2), + '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() - 2, null), + 'intersecting bounded end same' => new IntegerRange($range->end(), $range->end() + 10), + 'intersecting bounded end left' => new IntegerRange($range->end() - 2, $range->end() + 10), + '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() - 2, null), + 'intersecting open end right' => new IntegerRange($range->start() + 2, null), + 'intersecting bounded end left' => new IntegerRange($range->start() - 2, $range->start() + 8), + 'intersecting bounded end right' => new IntegerRange($range->start() + 2, $range->start() + 12), + '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() + 2), + 'intersecting bounded start same' => new IntegerRange($range->start() - 10, $range->start()), + 'intersecting bounded start right' => new IntegerRange($range->start() - 12, $range->start() + 2), + '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() - 2, null), + 'intersecting open end right' => new IntegerRange($range->start() + 2, null), + 'intersecting open end center' => new IntegerRange(null, $range->end() - 2), + 'intersecting open start left' => new IntegerRange(null, $range->end() - 2), + 'intersecting open start right' => new IntegerRange(null, $range->end() + 2), + 'intersecting open start center' => new IntegerRange($range->start() + 2, null), + 'disjoint open start' => new IntegerRange(null, $range->start() - 2), + 'disjoint open end' => new IntegerRange($range->end() + 2, null), + 'disjoint left' => new IntegerRange($range->start() - 10, $range->start() - 2), + 'disjoint right' => new IntegerRange($range->end() + 2, $range->end() + 10), + 'adjacent open start' => new IntegerRange(null, $range->start() - 1), + 'adjacent open end' => new IntegerRange($range->end() + 1, null), + 'adjacent left' => new IntegerRange($range->start() - 1, $range->start() - 1), + 'adjacent right' => new IntegerRange($range->end() + 1, $range->end() + 1), + '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() - 2, $range->end() + 2), + 'containing unbounded start' => new IntegerRange(null, $range->end() + 2), + 'containing unbounded end' => new IntegerRange($range->start() - 2, null), + 'containing same start' => new IntegerRange($range->start(), $range->end() + 2), + 'containing same end' => new IntegerRange($range->start() - 2, $range->end()), + 'containing same start unbounded end' => new IntegerRange($range->start(), null), + 'containing same end unbounded start' => new IntegerRange(null, $range->end()), + ]; + } + } + + protected function checkScenarioResults(IntegerRange $range, string $method, array $expected_results) + { + $scenarios = $this->createScenarios($range); + foreach ($scenarios as $scenario => $other) { + $result = $range->$method($other); + if (!isset($expected_results[$scenario])) { + throw new Exception("No expected result for scenario '$scenario'"); + } + $expected = $expected_results[$scenario]; + unset($expected_results[$scenario]); + $this->assertEquals($expected, $result, sprintf( + "[%s,%s] %s [%s,%s] expected %s but returned %s (scenario %s)", + $range->start() ?? '-INF', + $range->end() ?? 'INF', + $method, + $other->start() ?? '-INF', + $other->end() ?? 'INF', + $expected ? 'true' : 'false', + $result ? 'true' : 'false', + $scenario + )); + } + if (count($expected_results) > 0) { + throw new Exception("Expected results not used: " . implode(', ', array_keys($expected_results))); + } + } +} From 91a5545c73d11a16100ac0e653ce8981e46ab87d Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Mon, 29 Jul 2024 08:44:43 -0600 Subject: [PATCH 2/9] refactored tests to make results more useful --- tests/Ranges/IntegerRangeTest.php | 191 ++++++++++++++++-------------- 1 file changed, 103 insertions(+), 88 deletions(-) diff --git a/tests/Ranges/IntegerRangeTest.php b/tests/Ranges/IntegerRangeTest.php index 4284ad3..2041c34 100644 --- a/tests/Ranges/IntegerRangeTest.php +++ b/tests/Ranges/IntegerRangeTest.php @@ -59,9 +59,7 @@ class IntegerRangeTest extends TestCase public function testEquals() { // fully bounded - $this->checkScenarioResults( - new IntegerRange(10, 20), - 'equals', + $this->assertEquals( [ 'same' => true, 'unbounded' => false, @@ -89,12 +87,14 @@ class IntegerRangeTest extends TestCase '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->checkScenarioResults( - new IntegerRange(null, 20), - 'equals', + $this->assertEquals( [ 'same' => true, 'unbounded' => false, @@ -111,12 +111,14 @@ class IntegerRangeTest extends TestCase 'adjacent bounded end' => false, 'disjoint open end' => false, 'disjoint bounded end' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'equals', + ) ); // open end - $this->checkScenarioResults( - new IntegerRange(10, null), - 'equals', + $this->assertEquals( [ 'same' => true, 'unbounded' => false, @@ -133,27 +135,31 @@ class IntegerRangeTest extends TestCase 'adjacent bounded start' => false, 'disjoint open start' => false, 'disjoint bounded start' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'equals', + ) ); // fully unbounded - $this->checkScenarioResults( - new IntegerRange(null, null), - 'equals', + $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->checkScenarioResults( - new IntegerRange(10, 20), - 'intersects', + $this->assertEquals( [ 'same' => true, 'unbounded' => true, @@ -181,12 +187,14 @@ class IntegerRangeTest extends TestCase '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->checkScenarioResults( - new IntegerRange(null, 20), - 'intersects', + $this->assertEquals( [ 'same' => true, 'unbounded' => true, @@ -203,12 +211,14 @@ class IntegerRangeTest extends TestCase 'adjacent bounded end' => false, 'disjoint open end' => false, 'disjoint bounded end' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'intersects', + ) ); // open end - $this->checkScenarioResults( - new IntegerRange(10, null), - 'intersects', + $this->assertEquals( [ 'same' => true, 'unbounded' => true, @@ -225,27 +235,31 @@ class IntegerRangeTest extends TestCase 'adjacent bounded start' => false, 'disjoint open start' => false, 'disjoint bounded start' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'intersects', + ) ); // fully unbounded - $this->checkScenarioResults( - new IntegerRange(null, null), - 'intersects', + $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->checkScenarioResults( - new IntegerRange(10, 20), - 'contains', + $this->assertEquals( [ 'same' => true, 'unbounded' => false, @@ -273,12 +287,14 @@ class IntegerRangeTest extends TestCase '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->checkScenarioResults( - new IntegerRange(null, 20), - 'contains', + $this->assertEquals( [ 'same' => true, 'unbounded' => false, @@ -295,12 +311,14 @@ class IntegerRangeTest extends TestCase 'adjacent bounded end' => false, 'disjoint open end' => false, 'disjoint bounded end' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'contains', + ) ); // open end - $this->checkScenarioResults( - new IntegerRange(10, null), - 'contains', + $this->assertEquals( [ 'same' => true, 'unbounded' => false, @@ -317,18 +335,24 @@ class IntegerRangeTest extends TestCase 'adjacent bounded start' => false, 'disjoint open start' => false, 'disjoint bounded start' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'contains', + ) ); // fully unbounded - $this->checkScenarioResults( - new IntegerRange(null, null), - 'contains', + $this->assertEquals( [ 'same' => true, 'open start' => true, 'open end' => true, 'bounded' => true, - ] + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'contains', + ) ); } @@ -339,9 +363,7 @@ class IntegerRangeTest extends TestCase public function testAbutsStartOf() { // fully bounded - $this->checkScenarioResults( - new IntegerRange(10, 20), - 'abutsStartOf', + $this->assertEquals( [ 'same' => false, 'unbounded' => false, @@ -369,12 +391,14 @@ class IntegerRangeTest extends TestCase 'containing same end' => false, 'containing same start unbounded end' => false, 'containing same end unbounded start' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'abutsStartOf', + ) ); // open start - $this->checkScenarioResults( - new IntegerRange(null, 20), - 'abutsStartOf', + $this->assertEquals( [ 'same' => false, 'unbounded' => false, @@ -391,12 +415,14 @@ class IntegerRangeTest extends TestCase 'adjacent bounded end' => true, 'disjoint open end' => false, 'disjoint bounded end' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'abutsStartOf', + ) ); // open end - $this->checkScenarioResults( - new IntegerRange(10, null), - 'abutsStartOf', + $this->assertEquals( [ 'same' => false, 'unbounded' => false, @@ -413,18 +439,24 @@ class IntegerRangeTest extends TestCase 'adjacent bounded start' => false, 'disjoint open start' => false, 'disjoint bounded start' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'abutsStartOf', + ) ); // fully unbounded - $this->checkScenarioResults( - new IntegerRange(null, null), - 'abutsStartOf', + $this->assertEquals( [ 'same' => false, 'open start' => false, 'open end' => false, 'bounded' => false, - ] + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'abutsStartOf', + ) ); } @@ -509,30 +541,13 @@ class IntegerRangeTest extends TestCase } } - protected function checkScenarioResults(IntegerRange $range, string $method, array $expected_results) + protected function scenarioResults(IntegerRange $range, string $method): array { - $scenarios = $this->createScenarios($range); - foreach ($scenarios as $scenario => $other) { - $result = $range->$method($other); - if (!isset($expected_results[$scenario])) { - throw new Exception("No expected result for scenario '$scenario'"); - } - $expected = $expected_results[$scenario]; - unset($expected_results[$scenario]); - $this->assertEquals($expected, $result, sprintf( - "[%s,%s] %s [%s,%s] expected %s but returned %s (scenario %s)", - $range->start() ?? '-INF', - $range->end() ?? 'INF', - $method, - $other->start() ?? '-INF', - $other->end() ?? 'INF', - $expected ? 'true' : 'false', - $result ? 'true' : 'false', - $scenario - )); - } - if (count($expected_results) > 0) { - throw new Exception("Expected results not used: " . implode(', ', array_keys($expected_results))); - } + return array_map( + function ($s) use ($range, $method) { + return $range->$method($s); + }, + $this->createScenarios($range) + ); } } From 4dcbc43009ccd3096745d3275e6363938e67d6c8 Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Mon, 29 Jul 2024 11:06:00 -0600 Subject: [PATCH 3/9] added boolean AND, updated tests --- src/Ranges/AbstractRange.php | 22 +- tests/Ranges/IntegerRangeTest.php | 343 ++++++++++++++++++++++++++++-- 2 files changed, 342 insertions(+), 23 deletions(-) diff --git a/src/Ranges/AbstractRange.php b/src/Ranges/AbstractRange.php index d6290f2..c38a6a8 100644 --- a/src/Ranges/AbstractRange.php +++ b/src/Ranges/AbstractRange.php @@ -68,13 +68,31 @@ abstract class AbstractRange * @param T|null $start * @param T|null $end */ - public function __construct($start, $end) + final public function __construct($start, $end) { $this->setStart($start); $this->setEnd($end); } /** + * Perform a boolean AND operation on this range and another, returning the + * range that they both cover, returns null if they do not overlap. + * @param static $other + */ + public function booleanAnd(AbstractRange $other): static|null + { + if ($this->contains($other)) return new static($other->start(), $other->end()); + elseif ($other->contains($this)) return new static($this->start(), $this->end()); + elseif ($this->intersects($other)) { + return new static( + $this->extendsBefore($other) ? $other->start() : $this->start(), + $this->extendsAfter($other) ? $other->end() : $this->end() + ); + } else return null; + } + + /** + * Check if this range has the same start and end as another range. * @param static $other */ public function equals(AbstractRange $other): bool @@ -83,6 +101,7 @@ abstract class AbstractRange } /** + * Check if any part of this range overlaps with another range. * @param static $other */ public function intersects(AbstractRange $other): bool @@ -93,6 +112,7 @@ abstract class AbstractRange } /** + * Check if this range completely contains another range. * @param static $other */ public function contains(AbstractRange $other): bool diff --git a/tests/Ranges/IntegerRangeTest.php b/tests/Ranges/IntegerRangeTest.php index 2041c34..8746d3c 100644 --- a/tests/Ranges/IntegerRangeTest.php +++ b/tests/Ranges/IntegerRangeTest.php @@ -25,7 +25,6 @@ namespace Joby\Toolbox\Sorting; -use Exception; use Joby\Toolbox\Ranges\IntegerRange; use PHPUnit\Framework\TestCase; @@ -341,7 +340,7 @@ class IntegerRangeTest extends TestCase 'contains', ) ); - // fully unbounded + // fully unbounded contains anything $this->assertEquals( [ 'same' => true, @@ -356,13 +355,109 @@ class IntegerRangeTest extends TestCase ); } - // TODO: testAbutsEndOf - - // TODO: see about making failures go to the right line/test + public function testAbutsEndOf() + { + // 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), + 'abutsEndOf', + ) + ); + // 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), + 'abutsEndOf', + ) + ); + // 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), + 'abutsEndOf', + ) + ); + // fully unbounded can't abut anything + $this->assertEquals( + [ + 'same' => false, + 'open start' => false, + 'open end' => false, + 'bounded' => false, + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'abutsEndOf', + ) + ); + } public function testAbutsStartOf() { - // fully bounded + // fully bounded only abuts the start of things that are adjacent right $this->assertEquals( [ 'same' => false, @@ -397,7 +492,7 @@ class IntegerRangeTest extends TestCase 'abutsStartOf', ) ); - // open start + // open start only abuts the start of things that are adjacent right $this->assertEquals( [ 'same' => false, @@ -421,7 +516,7 @@ class IntegerRangeTest extends TestCase 'abutsStartOf', ) ); - // open end + // open end can't abut the start of anything $this->assertEquals( [ 'same' => false, @@ -445,7 +540,7 @@ class IntegerRangeTest extends TestCase 'abutsStartOf', ) ); - // fully unbounded + // fully unbounded can't abut anything $this->assertEquals( [ 'same' => false, @@ -460,13 +555,213 @@ class IntegerRangeTest extends TestCase ); } + public function testAbuts() + { + // 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), + 'abuts', + ) + ); + // 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), + 'abuts', + ) + ); + // 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), + 'abuts', + ) + ); + // fully unbounded can't abut anything + $this->assertEquals( + [ + 'same' => false, + 'open start' => false, + 'open end' => false, + 'bounded' => false, + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'abuts', + ) + ); + } + + public function testBooleanAnd() + { + // fully bounded + $this->assertEquals( + [ + 'same' => '10,20', + 'unbounded' => '10,20', + 'intersecting open end left' => '10,20', + 'intersecting open end right' => '12,20', + 'intersecting open end center' => '10,20', + 'intersecting open start left' => '10,18', + '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' => 'null,20', + 'unbounded' => 'null,20', + 'intersecting open start left' => 'null,18', + 'intersecting open start right' => 'null,20', + 'intersecting bounded start left' => '8,18', + 'intersecting bounded start right' => '12,20', + 'intersecting bounded start center' => '10,20', + 'intersecting open end same' => '20,20', + 'intersecting open end left' => '18,20', + 'intersecting bounded end same' => '20,20', + 'intersecting bounded end left' => '18,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,null', + 'unbounded' => '10,null', + 'intersecting open end left' => '10,null', + 'intersecting open end right' => '12,null', + 'intersecting bounded end left' => '10,18', + 'intersecting bounded end right' => '12,22', + 'intersecting bounded end center' => '10,20', + 'intersecting open start same' => '10,10', + 'intersecting open start right' => '10,12', + 'intersecting bounded start same' => '10,10', + 'intersecting bounded start right' => '10,12', + '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' => 'null,null', + 'open start' => 'null,20', + 'open end' => '10,null', + 'bounded' => '10,20', + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'booleanAnd', + ) + ); + } + 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, 10), + 'open start' => new IntegerRange(null, 20), 'open end' => new IntegerRange(10, null), 'bounded' => new IntegerRange(10, 20), ]; @@ -515,26 +810,26 @@ class IntegerRangeTest extends TestCase 'unbounded' => new IntegerRange(null, null), 'intersecting open end left' => new IntegerRange($range->start() - 2, null), 'intersecting open end right' => new IntegerRange($range->start() + 2, null), - 'intersecting open end center' => new IntegerRange(null, $range->end() - 2), + 'intersecting open end center' => new IntegerRange($range->start(), null), 'intersecting open start left' => new IntegerRange(null, $range->end() - 2), 'intersecting open start right' => new IntegerRange(null, $range->end() + 2), - 'intersecting open start center' => new IntegerRange($range->start() + 2, null), + '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() - 10, $range->start() - 2), - 'disjoint right' => new IntegerRange($range->end() + 2, $range->end() + 10), + '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() - 1, $range->start() - 1), - 'adjacent right' => new IntegerRange($range->end() + 1, $range->end() + 1), + '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() - 2, $range->end() + 2), - 'containing unbounded start' => new IntegerRange(null, $range->end() + 2), - 'containing unbounded end' => new IntegerRange($range->start() - 2, null), - 'containing same start' => new IntegerRange($range->start(), $range->end() + 2), - 'containing same end' => new IntegerRange($range->start() - 2, $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()), ]; @@ -545,7 +840,11 @@ class IntegerRangeTest extends TestCase { return array_map( function ($s) use ($range, $method) { - return $range->$method($s); + $result = $range->$method($s); + if ($result instanceof IntegerRange) { + $result = ($result->start() ?? 'null') . ',' . ($result->end() ?? 'null'); + } + return $result; }, $this->createScenarios($range) ); From 0980e5e68cd29381d5e6cf46a396414b5e72f46b Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Mon, 29 Jul 2024 12:29:02 -0600 Subject: [PATCH 4/9] boolean or and not operations --- src/Ranges/AbstractRange.php | 119 +++++++++++- src/Ranges/IntegerRange.php | 12 +- tests/Ranges/IntegerRangeTest.php | 294 +++++++++++++++++++++++++----- 3 files changed, 372 insertions(+), 53 deletions(-) diff --git a/src/Ranges/AbstractRange.php b/src/Ranges/AbstractRange.php index c38a6a8..2d139a1 100644 --- a/src/Ranges/AbstractRange.php +++ b/src/Ranges/AbstractRange.php @@ -25,6 +25,8 @@ namespace Joby\Toolbox\Ranges; +use RuntimeException; + /** * Class to represent a range of values, which consists of a start and an end * value, each of which may be null to indicate an open range in that direction. @@ -51,10 +53,18 @@ abstract class AbstractRange /** * This must be essentially a hash function, which converts a given value * into an integer, which represents its ordering somehow. - * @param T|null $value + * @param T $value * @return int */ - abstract protected static function convertToInt(mixed $value): int; + abstract protected static function valueToInteger(mixed $value): int; + + /** + * This must be the inverse of the valueToInteger method, which converts an + * integer back into the original value. + * @param int $integer + * @return T + */ + abstract protected static function integerToValue(int $integer): mixed; /** * This must prepare a value to be stored in this object, which may just be @@ -64,6 +74,30 @@ abstract class AbstractRange */ abstract protected static function prepareValue(mixed $value): mixed; + /** + * This must return the value that is immediately before a given integer. + * Returns null if number is infinite. + * @return T|null + */ + protected static function valueBefore(int|float $number): mixed + { + if ($number == INF) return null; + if ($number == -INF) return null; + return static::integerToValue((int)$number - 1); + } + + /** + * This must return the value that is immediately after a given integer. + * Returns null if number is infinite. + * @return T|null + */ + protected static function valueAfter(int|float $number): mixed + { + if ($number == INF) return null; + if ($number == -INF) return null; + return static::integerToValue((int)$number + 1); + } + /** * @param T|null $start * @param T|null $end @@ -91,6 +125,75 @@ abstract class AbstractRange } 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 static[] + */ + public function booleanOr(AbstractRange $other): array + { + if ($this->intersects($other) || $this->adjacent($other)) { + return [ + new static( + $this->extendsBefore($other) ? $this->start() : $other->start(), + $this->extendsAfter($other) ? $this->end() : $other->end() + ) + ]; + } else { + if ($this->extendsBefore($other)) { + return [new static($this->start(), $this->end()), new static($other->start(), $other->end())]; + } else { + return [new static($other->start(), $other->end()), new static($this->start(), $this->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 static[] + */ + public function booleanNot(AbstractRange $other): array + { + // if this range is completely contained by the other, return an empty array + if ($other->contains($this)) { + return []; + } + // if the ranges do not overlap, return this range + if (!$this->intersects($other)) { + return [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 [new static(static::valueAfter($other->end), $this->end())]; + } elseif ($this->end == $other->end) { + return [new static($this->start(), static::valueBefore($other->start))]; + } else { + return [ + 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 [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 [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 @@ -145,9 +248,9 @@ abstract class AbstractRange * is equivalent to checking both abutsStartOf and abutsEndOf. * @param static $other */ - public function abuts(AbstractRange $other): bool + public function adjacent(AbstractRange $other): bool { - return $this->abutsEndOf($other) || $this->abutsStartOf($other); + return $this->adjacentRightOf($other) || $this->adjacentLeftOf($other); } /** @@ -155,7 +258,7 @@ abstract class AbstractRange * This means that they do not overlap, but are directly adjacent. * @param static $other */ - public function abutsEndOf(AbstractRange $other): bool + public function adjacentRightOf(AbstractRange $other): bool { if ($this->start == -INF || $other->end == INF) return false; return $this->start == $other->end + 1; @@ -166,7 +269,7 @@ abstract class AbstractRange * This means that they do not overlap, but are directly adjacent. * @param static $other */ - public function abutsStartOf(AbstractRange $other): bool + public function adjacentLeftOf(AbstractRange $other): bool { if ($this->end == INF || $other->start == -INF) return false; return $this->end == $other->start - 1; @@ -179,7 +282,7 @@ abstract class AbstractRange public function setStart(mixed $start): static { $this->start = is_null($start) ? -INF - : static::convertToInt($start); + : static::valueToInteger($start); $this->start_value = is_null($start) ? null : static::prepareValue($start); return $this; @@ -192,7 +295,7 @@ abstract class AbstractRange public function setEnd(mixed $end): static { $this->end = is_null($end) ? INF - : static::convertToInt($end); + : static::valueToInteger($end); $this->end_value = is_null($end) ? null : static::prepareValue($end); return $this; diff --git a/src/Ranges/IntegerRange.php b/src/Ranges/IntegerRange.php index 0e81eb3..43cfae0 100644 --- a/src/Ranges/IntegerRange.php +++ b/src/Ranges/IntegerRange.php @@ -30,16 +30,24 @@ namespace Joby\Toolbox\Ranges; * its values as well as it's internal hashes. All it does to convert values is * cast them to integers. * + * This class is also effectively the test harness for AbstractRange. So it has + * extremely comprehensive tests while other implementations might only test + * for basic functionality and common off-by-one error locations. + * * @extends AbstractRange */ class IntegerRange extends AbstractRange { - - protected static function convertToInt(mixed $value): int + 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/tests/Ranges/IntegerRangeTest.php b/tests/Ranges/IntegerRangeTest.php index 8746d3c..d3882d8 100644 --- a/tests/Ranges/IntegerRangeTest.php +++ b/tests/Ranges/IntegerRangeTest.php @@ -355,7 +355,7 @@ class IntegerRangeTest extends TestCase ); } - public function testAbutsEndOf() + public function testAdjacentRightOf() { // fully bounded only abuts the end of things that are adjacent left $this->assertEquals( @@ -389,7 +389,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(10, 20), - 'abutsEndOf', + 'adjacentRightOf', ) ); // open start can't abut the end of anything @@ -413,7 +413,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(null, 20), - 'abutsEndOf', + 'adjacentRightOf', ) ); // open end only abuts the end of things that are adjacent left @@ -437,7 +437,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(10, null), - 'abutsEndOf', + 'adjacentRightOf', ) ); // fully unbounded can't abut anything @@ -450,12 +450,12 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(null, null), - 'abutsEndOf', + 'adjacentRightOf', ) ); } - public function testAbutsStartOf() + public function testAdjacentLeftOf() { // fully bounded only abuts the start of things that are adjacent right $this->assertEquals( @@ -489,7 +489,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(10, 20), - 'abutsStartOf', + 'adjacentLeftOf', ) ); // open start only abuts the start of things that are adjacent right @@ -513,7 +513,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(null, 20), - 'abutsStartOf', + 'adjacentLeftOf', ) ); // open end can't abut the start of anything @@ -537,7 +537,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(10, null), - 'abutsStartOf', + 'adjacentLeftOf', ) ); // fully unbounded can't abut anything @@ -550,12 +550,12 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(null, null), - 'abutsStartOf', + 'adjacentLeftOf', ) ); } - public function testAbuts() + public function testAdjacent() { // fully bounded only abuts things that are adjacent $this->assertEquals( @@ -589,7 +589,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(10, 20), - 'abuts', + 'adjacent', ) ); // open start abuts adjacent right @@ -613,7 +613,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(null, 20), - 'abuts', + 'adjacent', ) ); // open end abuts adjacent left @@ -637,7 +637,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(10, null), - 'abuts', + 'adjacent', ) ); // fully unbounded can't abut anything @@ -650,7 +650,7 @@ class IntegerRangeTest extends TestCase ], $this->scenarioResults( new IntegerRange(null, null), - 'abuts', + 'adjacent', ) ); } @@ -663,9 +663,9 @@ class IntegerRangeTest extends TestCase 'same' => '10,20', 'unbounded' => '10,20', 'intersecting open end left' => '10,20', - 'intersecting open end right' => '12,20', + 'intersecting open end right' => '11,20', 'intersecting open end center' => '10,20', - 'intersecting open start left' => '10,18', + 'intersecting open start left' => '10,19', 'intersecting open start right' => '10,20', 'intersecting open start center' => '10,20', 'disjoint open start' => null, @@ -697,15 +697,15 @@ class IntegerRangeTest extends TestCase [ 'same' => 'null,20', 'unbounded' => 'null,20', - 'intersecting open start left' => 'null,18', + 'intersecting open start left' => 'null,19', 'intersecting open start right' => 'null,20', - 'intersecting bounded start left' => '8,18', - 'intersecting bounded start right' => '12,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' => '18,20', + 'intersecting open end left' => '19,20', 'intersecting bounded end same' => '20,20', - 'intersecting bounded end left' => '18,20', + 'intersecting bounded end left' => '19,20', 'adjacent open end' => null, 'adjacent bounded end' => null, 'disjoint open end' => null, @@ -722,14 +722,14 @@ class IntegerRangeTest extends TestCase 'same' => '10,null', 'unbounded' => '10,null', 'intersecting open end left' => '10,null', - 'intersecting open end right' => '12,null', - 'intersecting bounded end left' => '10,18', - 'intersecting bounded end right' => '12,22', + 'intersecting open end right' => '11,null', + '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,12', + 'intersecting open start right' => '10,11', 'intersecting bounded start same' => '10,10', - 'intersecting bounded start right' => '10,12', + 'intersecting bounded start right' => '10,11', 'adjacent open start' => null, 'adjacent bounded start' => null, 'disjoint open start' => null, @@ -755,6 +755,206 @@ class IntegerRangeTest extends TestCase ); } + public function testBooleanOr() + { + // fully bounded + $this->assertEquals( + [ + 'same' => '10,20', + 'unbounded' => 'null,null', + 'intersecting open end left' => '9,null', + 'intersecting open end right' => '10,null', + 'intersecting open end center' => '10,null', + 'intersecting open start left' => 'null,20', + 'intersecting open start right' => 'null,21', + 'intersecting open start center' => 'null,20', + 'disjoint open start' => 'null,8;10,20', + 'disjoint open end' => '10,20;22,null', + 'disjoint left' => '-2,8;10,20', + 'disjoint right' => '10,20;22,32', + 'adjacent open start' => 'null,20', + 'adjacent open end' => '10,null', + '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' => 'null,21', + 'containing unbounded end' => '9,null', + 'containing same start' => '10,21', + 'containing same end' => '9,20', + 'containing same start unbounded end' => '10,null', + 'containing same end unbounded start' => 'null,20', + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'booleanOr', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => 'null,20', + 'unbounded' => 'null,null', + 'intersecting open start left' => 'null,20', + 'intersecting open start right' => 'null,21', + 'intersecting bounded start left' => 'null,20', + 'intersecting bounded start right' => 'null,21', + 'intersecting bounded start center' => 'null,20', + 'intersecting open end same' => 'null,null', + 'intersecting open end left' => 'null,null', + 'intersecting bounded end same' => 'null,30', + 'intersecting bounded end left' => 'null,29', + 'adjacent open end' => 'null,null', + 'adjacent bounded end' => 'null,30', + 'disjoint open end' => 'null,20;22,null', + 'disjoint bounded end' => 'null,20;22,32', + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'booleanOr', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => '10,null', + 'unbounded' => 'null,null', + 'intersecting open end left' => '9,null', + 'intersecting open end right' => '10,null', + 'intersecting bounded end left' => '9,null', + 'intersecting bounded end right' => '10,null', + 'intersecting bounded end center' => '10,null', + 'intersecting open start same' => 'null,null', + 'intersecting open start right' => 'null,null', + 'intersecting bounded start same' => '0,null', + 'intersecting bounded start right' => '1,null', + 'adjacent open start' => 'null,null', + 'adjacent bounded start' => '0,null', + 'disjoint open start' => 'null,8;10,null', + 'disjoint bounded start' => '-2,8;10,null', + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'booleanOr', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => 'null,null', + 'open start' => 'null,null', + 'open end' => 'null,null', + 'bounded' => 'null,null', + ], + $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' => 'null,8;20,20', + 'intersecting bounded start right' => 'null,10', + 'intersecting bounded start center' => 'null,9', + 'intersecting open end same' => 'null,19', + 'intersecting open end left' => 'null,18', + 'intersecting bounded end same' => 'null,19', + 'intersecting bounded end left' => 'null,18', + 'adjacent open end' => 'null,20', + 'adjacent bounded end' => 'null,20', + 'disjoint open end' => 'null,20', + 'disjoint bounded end' => 'null,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,null', + 'intersecting bounded end right' => '10,10;22,null', + 'intersecting bounded end center' => '21,null', + 'intersecting open start same' => '11,null', + 'intersecting open start right' => '12,null', + 'intersecting bounded start same' => '11,null', + 'intersecting bounded start right' => '12,null', + 'adjacent open start' => '10,null', + 'adjacent bounded start' => '10,null', + 'disjoint open start' => '10,null', + 'disjoint bounded start' => '10,null', + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'booleanNot', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => '', + 'open start' => '21,null', + 'open end' => 'null,9', + 'bounded' => 'null,9;21,null', + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'booleanNot', + ) + ); + } + protected function createScenarios(IntegerRange $range): array { if (is_null($range->start()) && is_null($range->end())) { @@ -770,15 +970,15 @@ class IntegerRangeTest extends TestCase return [ 'same' => new IntegerRange(null, $range->end()), 'unbounded' => new IntegerRange(null, null), - 'intersecting open start left' => new IntegerRange(null, $range->end() - 2), - 'intersecting open start right' => new IntegerRange(null, $range->end() + 2), - 'intersecting bounded start left' => new IntegerRange($range->end() - 12, $range->end() - 2), - 'intersecting bounded start right' => new IntegerRange($range->end() - 8, $range->end() + 2), + '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() - 2, 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() - 2, $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), @@ -789,15 +989,15 @@ class IntegerRangeTest extends TestCase return [ 'same' => new IntegerRange($range->start(), null), 'unbounded' => new IntegerRange(null, null), - 'intersecting open end left' => new IntegerRange($range->start() - 2, null), - 'intersecting open end right' => new IntegerRange($range->start() + 2, null), - 'intersecting bounded end left' => new IntegerRange($range->start() - 2, $range->start() + 8), - 'intersecting bounded end right' => new IntegerRange($range->start() + 2, $range->start() + 12), + '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() + 2), + '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() - 12, $range->start() + 2), + '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), @@ -808,11 +1008,11 @@ class IntegerRangeTest extends TestCase return [ 'same' => new IntegerRange($range->start(), $range->end()), 'unbounded' => new IntegerRange(null, null), - 'intersecting open end left' => new IntegerRange($range->start() - 2, null), - 'intersecting open end right' => new IntegerRange($range->start() + 2, 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() - 2), - 'intersecting open start right' => new IntegerRange(null, $range->end() + 2), + '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), @@ -844,6 +1044,14 @@ class IntegerRangeTest extends TestCase if ($result instanceof IntegerRange) { $result = ($result->start() ?? 'null') . ',' . ($result->end() ?? 'null'); } + if (is_array($result)) { + $result = implode(';', array_map( + function ($r) { + return ($r->start() ?? 'null') . ',' . ($r->end() ?? 'null'); + }, + $result + )); + } return $result; }, $this->createScenarios($range) From 1ab8c481a2569a875a3090af47704473f7553cdb Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Mon, 29 Jul 2024 14:07:27 -0600 Subject: [PATCH 5/9] added boolean xor and "slice" --- src/Ranges/AbstractRange.php | 82 ++++++++++++ tests/Ranges/IntegerRangeTest.php | 200 ++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+) diff --git a/src/Ranges/AbstractRange.php b/src/Ranges/AbstractRange.php index 2d139a1..87b9649 100644 --- a/src/Ranges/AbstractRange.php +++ b/src/Ranges/AbstractRange.php @@ -151,6 +151,88 @@ abstract class AbstractRange } } + /** + * 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 static[] + */ + public function booleanXor(AbstractRange $other): array + { + // if the ranges are equal, return an empty array + if ($this->equals($other)) return []; + // if the ranges are adjacent return a single range + if ($this->adjacentLeftOf($other)) return [new static($this->start(), $other->end())]; + if ($this->adjacentRightOf($other)) return [new static($other->start(), $this->end())]; + // if the ranges do not overlap, return both ranges + if (!$this->intersects($other)) { + if ($this->extendsBefore($other)) { + return [new static($this->start(), $this->end()), new static($other->start(), $other->end())]; + } else { + return [new static($other->start(), $other->end()), new static($this->start(), $this->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 [$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 static[] + */ + public function booleanSlice(AbstractRange $other): array + { + // if the ranges are equal, return a single range + if ($this->equals($other)) return [new static($this->start(), $this->end())]; + // if the ranges are adjacent, return two ranges + if ($this->adjacentLeftOf($other)) return [new static($this->start(), $this->end()), new static($other->start(), $other->end())]; + if ($this->adjacentRightOf($other)) return [new static($other->start(), $other->end()), new static($this->start(), $this->end())]; + // if the ranges do not overlap, return two ranges + if (!$this->intersects($other)) { + if ($this->extendsBefore($other)) { + return [new static($this->start(), $this->end()), new static($other->start(), $other->end())]; + } else { + return [new static($other->start(), $other->end()), new static($this->start(), $this->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); + $xor = $overall_range->booleanNot($intersection); + if (count($xor) == 2) { + return [$xor[0], $intersection, $xor[1]]; + } elseif (count($xor) == 1) { + if ($intersection->extendsBefore($xor[0])) { + return [$intersection, $xor[0]]; + } else { + return [$xor[0], $intersection]; + } + } + // 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 diff --git a/tests/Ranges/IntegerRangeTest.php b/tests/Ranges/IntegerRangeTest.php index d3882d8..66dbeb6 100644 --- a/tests/Ranges/IntegerRangeTest.php +++ b/tests/Ranges/IntegerRangeTest.php @@ -955,6 +955,206 @@ class IntegerRangeTest extends TestCase ); } + public function testBooleanXor() + { + // fully bounded + $this->assertEquals( + [ + 'same' => '', + 'unbounded' => 'null,9;21,null', + 'intersecting open end left' => '9,9;21,null', + 'intersecting open end right' => '10,10;21,null', + 'intersecting open end center' => '21,null', + 'intersecting open start left' => 'null,9;20,20', + 'intersecting open start right' => 'null,9;21,21', + 'intersecting open start center' => 'null,9', + 'disjoint open start' => 'null,8;10,20', + 'disjoint open end' => '10,20;22,null', + 'disjoint left' => '-2,8;10,20', + 'disjoint right' => '10,20;22,32', + 'adjacent open start' => 'null,20', + 'adjacent open end' => '10,null', + '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' => 'null,9;21,21', + 'containing unbounded end' => '9,9;21,null', + 'containing same start' => '21,21', + 'containing same end' => '9,9', + 'containing same start unbounded end' => '21,null', + 'containing same end unbounded start' => 'null,9', + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'booleanXor', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => '', + 'unbounded' => '21,null', + 'intersecting open start left' => '20,20', + 'intersecting open start right' => '21,21', + 'intersecting bounded start left' => 'null,8;20,20', + 'intersecting bounded start right' => 'null,10;21,21', + 'intersecting bounded start center' => 'null,9', + 'intersecting open end same' => 'null,19;21,null', + 'intersecting open end left' => 'null,18;21,null', + 'intersecting bounded end same' => 'null,19;21,30', + 'intersecting bounded end left' => 'null,18;21,29', + 'adjacent open end' => 'null,null', + 'adjacent bounded end' => 'null,30', + 'disjoint open end' => 'null,20;22,null', + 'disjoint bounded end' => 'null,20;22,32', + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'booleanXor', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => '', + 'unbounded' => 'null,9', + 'intersecting open end left' => '9,9', + 'intersecting open end right' => '10,10', + 'intersecting bounded end left' => '9,9;20,null', + 'intersecting bounded end right' => '10,10;22,null', + 'intersecting bounded end center' => '21,null', + 'intersecting open start same' => 'null,9;11,null', + 'intersecting open start right' => 'null,9;12,null', + 'intersecting bounded start same' => '0,9;11,null', + 'intersecting bounded start right' => '1,9;12,null', + 'adjacent open start' => 'null,null', + 'adjacent bounded start' => '0,null', + 'disjoint open start' => 'null,8;10,null', + 'disjoint bounded start' => '-2,8;10,null', + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'booleanXor', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => '', + 'open start' => '21,null', + 'open end' => 'null,9', + 'bounded' => 'null,9;21,null', + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'booleanXor', + ) + ); + } + + public function testBooleanSlice() + { + // fully bounded + $this->assertEquals( + [ + 'same' => '10,20', + 'unbounded' => 'null,9;10,20;21,null', + 'intersecting open end left' => '9,9;10,20;21,null', + 'intersecting open end right' => '10,10;11,20;21,null', + 'intersecting open end center' => '10,20;21,null', + 'intersecting open start left' => 'null,9;10,19;20,20', + 'intersecting open start right' => 'null,9;10,20;21,21', + 'intersecting open start center' => 'null,9;10,20', + 'disjoint open start' => 'null,8;10,20', + 'disjoint open end' => '10,20;22,null', + 'disjoint left' => '-2,8;10,20', + 'disjoint right' => '10,20;22,32', + 'adjacent open start' => 'null,9;10,20', + 'adjacent open end' => '10,20;21,null', + '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' => 'null,9;10,20;21,21', + 'containing unbounded end' => '9,9;10,20;21,null', + 'containing same start' => '10,20;21,21', + 'containing same end' => '9,9;10,20', + 'containing same start unbounded end' => '10,20;21,null', + 'containing same end unbounded start' => 'null,9;10,20', + ], + $this->scenarioResults( + new IntegerRange(10, 20), + 'booleanSlice', + ) + ); + // open start + $this->assertEquals( + [ + 'same' => 'null,20', + 'unbounded' => 'null,20;21,null', + 'intersecting open start left' => 'null,19;20,20', + 'intersecting open start right' => 'null,20;21,21', + 'intersecting bounded start left' => 'null,8;9,19;20,20', + 'intersecting bounded start right' => 'null,10;11,20;21,21', + 'intersecting bounded start center' => 'null,9;10,20', + 'intersecting open end same' => 'null,19;20,20;21,null', + 'intersecting open end left' => 'null,18;19,20;21,null', + 'intersecting bounded end same' => 'null,19;20,20;21,30', + 'intersecting bounded end left' => 'null,18;19,20;21,29', + 'adjacent open end' => 'null,20;21,null', + 'adjacent bounded end' => 'null,20;21,30', + 'disjoint open end' => 'null,20;22,null', + 'disjoint bounded end' => 'null,20;22,32', + ], + $this->scenarioResults( + new IntegerRange(null, 20), + 'booleanSlice', + ) + ); + // open end + $this->assertEquals( + [ + 'same' => '10,null', + 'unbounded' => 'null,9;10,null', + 'intersecting open end left' => '9,9;10,null', + 'intersecting open end right' => '10,10;11,null', + 'intersecting bounded end left' => '9,9;10,19;20,null', + 'intersecting bounded end right' => '10,10;11,21;22,null', + 'intersecting bounded end center' => '10,20;21,null', + 'intersecting open start same' => 'null,9;10,10;11,null', + 'intersecting open start right' => 'null,9;10,11;12,null', + 'intersecting bounded start same' => '0,9;10,10;11,null', + 'intersecting bounded start right' => '1,9;10,11;12,null', + 'adjacent open start' => 'null,9;10,null', + 'adjacent bounded start' => '0,9;10,null', + 'disjoint open start' => 'null,8;10,null', + 'disjoint bounded start' => '-2,8;10,null', + ], + $this->scenarioResults( + new IntegerRange(10, null), + 'booleanSlice', + ) + ); + // fully unbounded + $this->assertEquals( + [ + 'same' => 'null,null', + 'open start' => 'null,20;21,null', + 'open end' => 'null,9;10,null', + 'bounded' => 'null,9;10,20;21,null', + ], + $this->scenarioResults( + new IntegerRange(null, null), + 'booleanSlice', + ) + ); + } + protected function createScenarios(IntegerRange $range): array { if (is_null($range->start()) && is_null($range->end())) { From 933e847fb36cd6f021293a9c99f86cb395eac2a4 Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Mon, 29 Jul 2024 15:38:18 -0600 Subject: [PATCH 6/9] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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 From 476d4789e59cae4e97e9655d02fa42569c6b1049 Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Mon, 29 Jul 2024 16:52:12 -0600 Subject: [PATCH 7/9] Update ci.yml Maybe updating php-actions/phpunit will fix CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From e7d6aa07fa4cd6f89bce62198091266ca4fe289d Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Tue, 30 Jul 2024 09:59:25 -0600 Subject: [PATCH 8/9] refactored to use collections instead of arrays for results --- src/Ranges/AbstractRange.php | 94 +++--- src/Ranges/IntegerRange.php | 13 +- src/Ranges/RangeCollection.php | 166 ++++++++++ src/Sorting/Sorter.php | 6 +- tests/Ranges/IntegerRangeTest.php | 532 +++++++++++++++--------------- 5 files changed, 495 insertions(+), 316 deletions(-) create mode 100644 src/Ranges/RangeCollection.php diff --git a/src/Ranges/AbstractRange.php b/src/Ranges/AbstractRange.php index 87b9649..d393956 100644 --- a/src/Ranges/AbstractRange.php +++ b/src/Ranges/AbstractRange.php @@ -131,22 +131,28 @@ abstract class AbstractRange * overlap this array will contain both ranges separately). Separate objects * must be returned in ascending order. * @param static $other - * @return static[] + * @return RangeCollection */ - public function booleanOr(AbstractRange $other): array + public function booleanOr(AbstractRange $other): RangeCollection { if ($this->intersects($other) || $this->adjacent($other)) { - return [ + 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 [new static($this->start(), $this->end()), new static($other->start(), $other->end())]; + return RangeCollection::create( + new static($this->start(), $this->end()), + new static($other->start(), $other->end()) + ); } else { - return [new static($other->start(), $other->end()), new static($this->start(), $this->end())]; + return RangeCollection::create( + new static($other->start(), $other->end()), + new static($this->start(), $this->end()) + ); } } } @@ -157,22 +163,17 @@ abstract class AbstractRange * ranges do not overlap, this array will contain both ranges separately. * Separate objects must be returned in ascending order. * @param static $other - * @return static[] + * @return RangeCollection */ - public function booleanXor(AbstractRange $other): array + public function booleanXor(AbstractRange $other): RangeCollection { // if the ranges are equal, return an empty array - if ($this->equals($other)) return []; + if ($this->equals($other)) return RangeCollection::createEmpty($other); // if the ranges are adjacent return a single range - if ($this->adjacentLeftOf($other)) return [new static($this->start(), $other->end())]; - if ($this->adjacentRightOf($other)) return [new static($other->start(), $this->end())]; + if ($this->adjacent($other)) return $this->booleanOr($other); // if the ranges do not overlap, return both ranges if (!$this->intersects($other)) { - if ($this->extendsBefore($other)) { - return [new static($this->start(), $this->end()), new static($other->start(), $other->end())]; - } else { - return [new static($other->start(), $other->end()), new static($this->start(), $this->end())]; - } + 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( @@ -182,7 +183,7 @@ abstract class AbstractRange if ($intersect = $this->booleanAnd($other)) { return $range->booleanNot($intersect); } else { - return [$range]; + return RangeCollection::create($range); } } @@ -196,22 +197,18 @@ abstract class AbstractRange * - 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 static[] + * @return RangeCollection */ - public function booleanSlice(AbstractRange $other): array + public function booleanSlice(AbstractRange $other): RangeCollection { // if the ranges are equal, return a single range - if ($this->equals($other)) return [new static($this->start(), $this->end())]; - // if the ranges are adjacent, return two ranges - if ($this->adjacentLeftOf($other)) return [new static($this->start(), $this->end()), new static($other->start(), $other->end())]; - if ($this->adjacentRightOf($other)) return [new static($other->start(), $other->end()), new static($this->start(), $this->end())]; + 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)) { - if ($this->extendsBefore($other)) { - return [new static($this->start(), $this->end()), new static($other->start(), $other->end())]; - } else { - return [new static($other->start(), $other->end()), new static($this->start(), $this->end())]; - } + 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( @@ -219,15 +216,14 @@ abstract class AbstractRange $this->extendsAfter($other) ? $this->end() : $other->end() ); $intersection = $this->booleanAnd($other); + assert($intersection !== null); $xor = $overall_range->booleanNot($intersection); if (count($xor) == 2) { - return [$xor[0], $intersection, $xor[1]]; + assert(isset($xor[0], $xor[1])); + return RangeCollection::create($xor[0], $intersection, $xor[1]); } elseif (count($xor) == 1) { - if ($intersection->extendsBefore($xor[0])) { - return [$intersection, $xor[0]]; - } else { - return [$xor[0], $intersection]; - } + 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)); @@ -239,38 +235,38 @@ abstract class AbstractRange * 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 static[] + * @return RangeCollection */ - public function booleanNot(AbstractRange $other): array + public function booleanNot(AbstractRange $other): RangeCollection { // if this range is completely contained by the other, return an empty array if ($other->contains($this)) { - return []; + return RangeCollection::createEmpty($other); } // if the ranges do not overlap, return this range if (!$this->intersects($other)) { - return [new static($this->start(), $this->end())]; + 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 [new static(static::valueAfter($other->end), $this->end())]; + return RangeCollection::create(new static(static::valueAfter($other->end), $this->end())); } elseif ($this->end == $other->end) { - return [new static($this->start(), static::valueBefore($other->start))]; + return RangeCollection::create(new static($this->start(), static::valueBefore($other->start))); } else { - return [ + 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 [new static($this->start(), static::valueBefore($other->start))]; + 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 [new static(static::valueAfter($other->end), $this->end())]; + 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)); @@ -398,4 +394,14 @@ abstract class AbstractRange { return $this->end_value; } + + public function startAsNumber(): int|float + { + return $this->start; + } + + public function endAsNumber(): int|float + { + return $this->end; + } } diff --git a/src/Ranges/IntegerRange.php b/src/Ranges/IntegerRange.php index 43cfae0..02f6df2 100644 --- a/src/Ranges/IntegerRange.php +++ b/src/Ranges/IntegerRange.php @@ -25,6 +25,8 @@ namespace Joby\Toolbox\Ranges; +use Stringable; + /** * The simplest possible implementation of AbstractRange, which uses integers as * its values as well as it's internal hashes. All it does to convert values is @@ -36,7 +38,7 @@ namespace Joby\Toolbox\Ranges; * * @extends AbstractRange */ -class IntegerRange extends AbstractRange +class IntegerRange extends AbstractRange implements Stringable { protected static function valueToInteger(mixed $value): int { @@ -52,4 +54,13 @@ class IntegerRange extends AbstractRange { return (int)$value; } + + public function __toString(): string + { + return sprintf( + '[%s...%s]', + $this->start === -INF ? '' : $this->start, + $this->end === INF ? '' : $this->end + ); + } } diff --git a/src/Ranges/RangeCollection.php b/src/Ranges/RangeCollection.php new file mode 100644 index 0000000..22aac8a --- /dev/null +++ b/src/Ranges/RangeCollection.php @@ -0,0 +1,166 @@ + + * @implements IteratorAggregate + */ +class RangeCollection implements Countable, ArrayAccess, IteratorAggregate +{ + protected string $class; + /** @var T[] */ + protected $ranges = []; + + /** + * @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); + } + + /** + * @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); + } + + /** + * @return T[] + */ + public function toArray(): array + { + return $this->ranges; + } + + /** + * @param T ...$ranges + */ + public function add(AbstractRange ...$ranges): static + { + foreach ($ranges as $range) { + if (!($range instanceof $this->class)) { + throw new InvalidArgumentException("Ranges must be of type $this->class"); + } + } + $this->ranges = array_merge($this->ranges, $ranges); + $this->sort(); + return $this; + } + + /** + * @param class-string $class + * @param T ...$ranges + * @return void + */ + protected function __construct(string $class, AbstractRange ...$ranges) + { + $this->class = $class; + $this->add(...$ranges); + } + + 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); + } +} diff --git a/src/Sorting/Sorter.php b/src/Sorting/Sorter.php index 30efa39..2f871d6 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 index 66dbeb6..c3f0c41 100644 --- a/tests/Ranges/IntegerRangeTest.php +++ b/tests/Ranges/IntegerRangeTest.php @@ -26,6 +26,7 @@ namespace Joby\Toolbox\Sorting; use Joby\Toolbox\Ranges\IntegerRange; +use Joby\Toolbox\Ranges\RangeCollection; use PHPUnit\Framework\TestCase; class IntegerRangeTest extends TestCase @@ -660,14 +661,14 @@ class IntegerRangeTest extends TestCase // 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', + '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, @@ -676,16 +677,16 @@ class IntegerRangeTest extends TestCase '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', + '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), @@ -695,17 +696,17 @@ class IntegerRangeTest extends TestCase // open start $this->assertEquals( [ - 'same' => 'null,20', - 'unbounded' => 'null,20', - 'intersecting open start left' => 'null,19', - 'intersecting open start right' => 'null,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', + '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, @@ -719,17 +720,17 @@ class IntegerRangeTest extends TestCase // open end $this->assertEquals( [ - 'same' => '10,null', - 'unbounded' => '10,null', - 'intersecting open end left' => '10,null', - 'intersecting open end right' => '11,null', - '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', + '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, @@ -743,10 +744,10 @@ class IntegerRangeTest extends TestCase // fully unbounded $this->assertEquals( [ - 'same' => 'null,null', - 'open start' => 'null,20', - 'open end' => '10,null', - 'bounded' => '10,20', + 'same' => '[...]', + 'open start' => '[...20]', + 'open end' => '[10...]', + 'bounded' => '[10...20]', ], $this->scenarioResults( new IntegerRange(null, null), @@ -760,32 +761,32 @@ class IntegerRangeTest extends TestCase // fully bounded $this->assertEquals( [ - 'same' => '10,20', - 'unbounded' => 'null,null', - 'intersecting open end left' => '9,null', - 'intersecting open end right' => '10,null', - 'intersecting open end center' => '10,null', - 'intersecting open start left' => 'null,20', - 'intersecting open start right' => 'null,21', - 'intersecting open start center' => 'null,20', - 'disjoint open start' => 'null,8;10,20', - 'disjoint open end' => '10,20;22,null', - 'disjoint left' => '-2,8;10,20', - 'disjoint right' => '10,20;22,32', - 'adjacent open start' => 'null,20', - 'adjacent open end' => '10,null', - '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' => 'null,21', - 'containing unbounded end' => '9,null', - 'containing same start' => '10,21', - 'containing same end' => '9,20', - 'containing same start unbounded end' => '10,null', - 'containing same end unbounded start' => 'null,20', + '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), @@ -795,21 +796,21 @@ class IntegerRangeTest extends TestCase // open start $this->assertEquals( [ - 'same' => 'null,20', - 'unbounded' => 'null,null', - 'intersecting open start left' => 'null,20', - 'intersecting open start right' => 'null,21', - 'intersecting bounded start left' => 'null,20', - 'intersecting bounded start right' => 'null,21', - 'intersecting bounded start center' => 'null,20', - 'intersecting open end same' => 'null,null', - 'intersecting open end left' => 'null,null', - 'intersecting bounded end same' => 'null,30', - 'intersecting bounded end left' => 'null,29', - 'adjacent open end' => 'null,null', - 'adjacent bounded end' => 'null,30', - 'disjoint open end' => 'null,20;22,null', - 'disjoint bounded end' => 'null,20;22,32', + '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), @@ -819,21 +820,21 @@ class IntegerRangeTest extends TestCase // open end $this->assertEquals( [ - 'same' => '10,null', - 'unbounded' => 'null,null', - 'intersecting open end left' => '9,null', - 'intersecting open end right' => '10,null', - 'intersecting bounded end left' => '9,null', - 'intersecting bounded end right' => '10,null', - 'intersecting bounded end center' => '10,null', - 'intersecting open start same' => 'null,null', - 'intersecting open start right' => 'null,null', - 'intersecting bounded start same' => '0,null', - 'intersecting bounded start right' => '1,null', - 'adjacent open start' => 'null,null', - 'adjacent bounded start' => '0,null', - 'disjoint open start' => 'null,8;10,null', - 'disjoint bounded start' => '-2,8;10,null', + '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), @@ -843,10 +844,10 @@ class IntegerRangeTest extends TestCase // fully unbounded $this->assertEquals( [ - 'same' => 'null,null', - 'open start' => 'null,null', - 'open end' => 'null,null', - 'bounded' => 'null,null', + 'same' => '[...]', + 'open start' => '[...]', + 'open end' => '[...]', + 'bounded' => '[...]', ], $this->scenarioResults( new IntegerRange(null, null), @@ -863,22 +864,22 @@ class IntegerRangeTest extends TestCase 'same' => '', 'unbounded' => '', 'intersecting open end left' => '', - 'intersecting open end right' => '10,10', + 'intersecting open end right' => '[10...10]', 'intersecting open end center' => '', - 'intersecting open start left' => '20,20', + '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', + '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' => '', @@ -897,19 +898,19 @@ class IntegerRangeTest extends TestCase [ 'same' => '', 'unbounded' => '', - 'intersecting open start left' => '20,20', + 'intersecting open start left' => '[20...20]', 'intersecting open start right' => '', - 'intersecting bounded start left' => 'null,8;20,20', - 'intersecting bounded start right' => 'null,10', - 'intersecting bounded start center' => 'null,9', - 'intersecting open end same' => 'null,19', - 'intersecting open end left' => 'null,18', - 'intersecting bounded end same' => 'null,19', - 'intersecting bounded end left' => 'null,18', - 'adjacent open end' => 'null,20', - 'adjacent bounded end' => 'null,20', - 'disjoint open end' => 'null,20', - 'disjoint bounded end' => 'null,20', + '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), @@ -922,18 +923,18 @@ class IntegerRangeTest extends TestCase 'same' => '', 'unbounded' => '', 'intersecting open end left' => '', - 'intersecting open end right' => '10,10', - 'intersecting bounded end left' => '20,null', - 'intersecting bounded end right' => '10,10;22,null', - 'intersecting bounded end center' => '21,null', - 'intersecting open start same' => '11,null', - 'intersecting open start right' => '12,null', - 'intersecting bounded start same' => '11,null', - 'intersecting bounded start right' => '12,null', - 'adjacent open start' => '10,null', - 'adjacent bounded start' => '10,null', - 'disjoint open start' => '10,null', - 'disjoint bounded start' => '10,null', + '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), @@ -944,9 +945,9 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '', - 'open start' => '21,null', - 'open end' => 'null,9', - 'bounded' => 'null,9;21,null', + 'open start' => '[21...]', + 'open end' => '[...9]', + 'bounded' => '[...9] [21...]', ], $this->scenarioResults( new IntegerRange(null, null), @@ -961,31 +962,31 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '', - 'unbounded' => 'null,9;21,null', - 'intersecting open end left' => '9,9;21,null', - 'intersecting open end right' => '10,10;21,null', - 'intersecting open end center' => '21,null', - 'intersecting open start left' => 'null,9;20,20', - 'intersecting open start right' => 'null,9;21,21', - 'intersecting open start center' => 'null,9', - 'disjoint open start' => 'null,8;10,20', - 'disjoint open end' => '10,20;22,null', - 'disjoint left' => '-2,8;10,20', - 'disjoint right' => '10,20;22,32', - 'adjacent open start' => 'null,20', - 'adjacent open end' => '10,null', - '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' => 'null,9;21,21', - 'containing unbounded end' => '9,9;21,null', - 'containing same start' => '21,21', - 'containing same end' => '9,9', - 'containing same start unbounded end' => '21,null', - 'containing same end unbounded start' => 'null,9', + '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), @@ -996,20 +997,20 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '', - 'unbounded' => '21,null', - 'intersecting open start left' => '20,20', - 'intersecting open start right' => '21,21', - 'intersecting bounded start left' => 'null,8;20,20', - 'intersecting bounded start right' => 'null,10;21,21', - 'intersecting bounded start center' => 'null,9', - 'intersecting open end same' => 'null,19;21,null', - 'intersecting open end left' => 'null,18;21,null', - 'intersecting bounded end same' => 'null,19;21,30', - 'intersecting bounded end left' => 'null,18;21,29', - 'adjacent open end' => 'null,null', - 'adjacent bounded end' => 'null,30', - 'disjoint open end' => 'null,20;22,null', - 'disjoint bounded end' => 'null,20;22,32', + '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), @@ -1020,20 +1021,20 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '', - 'unbounded' => 'null,9', - 'intersecting open end left' => '9,9', - 'intersecting open end right' => '10,10', - 'intersecting bounded end left' => '9,9;20,null', - 'intersecting bounded end right' => '10,10;22,null', - 'intersecting bounded end center' => '21,null', - 'intersecting open start same' => 'null,9;11,null', - 'intersecting open start right' => 'null,9;12,null', - 'intersecting bounded start same' => '0,9;11,null', - 'intersecting bounded start right' => '1,9;12,null', - 'adjacent open start' => 'null,null', - 'adjacent bounded start' => '0,null', - 'disjoint open start' => 'null,8;10,null', - 'disjoint bounded start' => '-2,8;10,null', + '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), @@ -1044,9 +1045,9 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '', - 'open start' => '21,null', - 'open end' => 'null,9', - 'bounded' => 'null,9;21,null', + 'open start' => '[21...]', + 'open end' => '[...9]', + 'bounded' => '[...9] [21...]', ], $this->scenarioResults( new IntegerRange(null, null), @@ -1060,32 +1061,32 @@ class IntegerRangeTest extends TestCase // fully bounded $this->assertEquals( [ - 'same' => '10,20', - 'unbounded' => 'null,9;10,20;21,null', - 'intersecting open end left' => '9,9;10,20;21,null', - 'intersecting open end right' => '10,10;11,20;21,null', - 'intersecting open end center' => '10,20;21,null', - 'intersecting open start left' => 'null,9;10,19;20,20', - 'intersecting open start right' => 'null,9;10,20;21,21', - 'intersecting open start center' => 'null,9;10,20', - 'disjoint open start' => 'null,8;10,20', - 'disjoint open end' => '10,20;22,null', - 'disjoint left' => '-2,8;10,20', - 'disjoint right' => '10,20;22,32', - 'adjacent open start' => 'null,9;10,20', - 'adjacent open end' => '10,20;21,null', - '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' => 'null,9;10,20;21,21', - 'containing unbounded end' => '9,9;10,20;21,null', - 'containing same start' => '10,20;21,21', - 'containing same end' => '9,9;10,20', - 'containing same start unbounded end' => '10,20;21,null', - 'containing same end unbounded start' => 'null,9;10,20', + '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), @@ -1095,21 +1096,21 @@ class IntegerRangeTest extends TestCase // open start $this->assertEquals( [ - 'same' => 'null,20', - 'unbounded' => 'null,20;21,null', - 'intersecting open start left' => 'null,19;20,20', - 'intersecting open start right' => 'null,20;21,21', - 'intersecting bounded start left' => 'null,8;9,19;20,20', - 'intersecting bounded start right' => 'null,10;11,20;21,21', - 'intersecting bounded start center' => 'null,9;10,20', - 'intersecting open end same' => 'null,19;20,20;21,null', - 'intersecting open end left' => 'null,18;19,20;21,null', - 'intersecting bounded end same' => 'null,19;20,20;21,30', - 'intersecting bounded end left' => 'null,18;19,20;21,29', - 'adjacent open end' => 'null,20;21,null', - 'adjacent bounded end' => 'null,20;21,30', - 'disjoint open end' => 'null,20;22,null', - 'disjoint bounded end' => 'null,20;22,32', + '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), @@ -1119,21 +1120,21 @@ class IntegerRangeTest extends TestCase // open end $this->assertEquals( [ - 'same' => '10,null', - 'unbounded' => 'null,9;10,null', - 'intersecting open end left' => '9,9;10,null', - 'intersecting open end right' => '10,10;11,null', - 'intersecting bounded end left' => '9,9;10,19;20,null', - 'intersecting bounded end right' => '10,10;11,21;22,null', - 'intersecting bounded end center' => '10,20;21,null', - 'intersecting open start same' => 'null,9;10,10;11,null', - 'intersecting open start right' => 'null,9;10,11;12,null', - 'intersecting bounded start same' => '0,9;10,10;11,null', - 'intersecting bounded start right' => '1,9;10,11;12,null', - 'adjacent open start' => 'null,9;10,null', - 'adjacent bounded start' => '0,9;10,null', - 'disjoint open start' => 'null,8;10,null', - 'disjoint bounded start' => '-2,8;10,null', + '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), @@ -1143,10 +1144,10 @@ class IntegerRangeTest extends TestCase // fully unbounded $this->assertEquals( [ - 'same' => 'null,null', - 'open start' => 'null,20;21,null', - 'open end' => 'null,9;10,null', - 'bounded' => 'null,9;10,20;21,null', + 'same' => '[...]', + 'open start' => '[...20] [21...]', + 'open end' => '[...9] [10...]', + 'bounded' => '[...9] [10...20] [21...]', ], $this->scenarioResults( new IntegerRange(null, null), @@ -1242,15 +1243,10 @@ class IntegerRangeTest extends TestCase function ($s) use ($range, $method) { $result = $range->$method($s); if ($result instanceof IntegerRange) { - $result = ($result->start() ?? 'null') . ',' . ($result->end() ?? 'null'); + $result = (string)$result; } - if (is_array($result)) { - $result = implode(';', array_map( - function ($r) { - return ($r->start() ?? 'null') . ',' . ($r->end() ?? 'null'); - }, - $result - )); + if ($result instanceof RangeCollection) { + $result = implode(' ', $result->toArray()); } return $result; }, From f3c95c1f21ae49741daf0516db7b1f0f7999efa1 Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Tue, 30 Jul 2024 13:01:13 -0600 Subject: [PATCH 9/9] new features in RangeCollection --- src/Ranges/AbstractRange.php | 12 +- src/Ranges/IntegerRange.php | 11 +- src/Ranges/RangeCollection.php | 194 +++++++++++++-- tests/Ranges/IntegerRangeTest.php | 201 ++++++++-------- tests/Ranges/RangeCollectionTest.php | 346 +++++++++++++++++++++++++++ 5 files changed, 628 insertions(+), 136 deletions(-) create mode 100644 tests/Ranges/RangeCollectionTest.php diff --git a/src/Ranges/AbstractRange.php b/src/Ranges/AbstractRange.php index d393956..4b155a6 100644 --- a/src/Ranges/AbstractRange.php +++ b/src/Ranges/AbstractRange.php @@ -26,6 +26,7 @@ namespace Joby\Toolbox\Ranges; use RuntimeException; +use Stringable; /** * Class to represent a range of values, which consists of a start and an end @@ -43,7 +44,7 @@ use RuntimeException; * * @template T of mixed */ -abstract class AbstractRange +abstract class AbstractRange implements Stringable { protected int|float $start; protected int|float $end; @@ -404,4 +405,13 @@ abstract class AbstractRange { return $this->end; } + + public function __toString(): string + { + return sprintf( + '[%s...%s]', + $this->start === -INF ? '' : (is_string($this->start_value) ? $this->start_value : $this->start), + $this->end === INF ? '' : (is_string($this->end_value) ? $this->end_value : $this->end) + ); + } } diff --git a/src/Ranges/IntegerRange.php b/src/Ranges/IntegerRange.php index 02f6df2..aaf71d1 100644 --- a/src/Ranges/IntegerRange.php +++ b/src/Ranges/IntegerRange.php @@ -38,7 +38,7 @@ use Stringable; * * @extends AbstractRange */ -class IntegerRange extends AbstractRange implements Stringable +class IntegerRange extends AbstractRange { protected static function valueToInteger(mixed $value): int { @@ -54,13 +54,4 @@ class IntegerRange extends AbstractRange implements Stringable { return (int)$value; } - - public function __toString(): string - { - return sprintf( - '[%s...%s]', - $this->start === -INF ? '' : $this->start, - $this->end === INF ? '' : $this->end - ); - } } diff --git a/src/Ranges/RangeCollection.php b/src/Ranges/RangeCollection.php index 22aac8a..2905e8b 100644 --- a/src/Ranges/RangeCollection.php +++ b/src/Ranges/RangeCollection.php @@ -31,19 +31,32 @@ use Countable; use InvalidArgumentException; use IteratorAggregate; use Joby\Toolbox\Sorting\Sorter; +use Stringable; /** + * Stores a collection of ranges, which must be all of the same type, and + * ensures that they are always sorted in ascencing chronological order. Also + * provides a number of methods for manipulating the colleciton, such as merging + * it with another collection, mapping, filtering, boolean operations with other + * collections or ranges, etc. + * + * It also allows counting, array access, and iteration in foreach loops. + * * @template T of AbstractRange * @implements ArrayAccess * @implements IteratorAggregate */ -class RangeCollection implements Countable, ArrayAccess, IteratorAggregate +class RangeCollection implements Countable, ArrayAccess, IteratorAggregate, Stringable { + /** @var class-string */ protected string $class; - /** @var T[] */ + /** @var array */ protected $ranges = []; /** + * Create a new collection from any number of ranges. Must have at least one + * argument, which is used to determine the type of range to store. + * * @template RangeType of AbstractRange * @param RangeType $range * @param RangeType ...$ranges @@ -55,6 +68,8 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate } /** + * Create an empty collection of a specific range type. + * * @template RangeType of AbstractRange * @param RangeType|class-string $class * @return RangeCollection @@ -66,7 +81,151 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate } /** - * @return T[] + * @param class-string $class + * @param T ...$ranges + * @return void + */ + protected function __construct(string $class, AbstractRange ...$ranges) + { + foreach ($ranges as $range) { + if (!($range instanceof $class)) { + throw new InvalidArgumentException("Ranges must be of type $class"); + } + } + $this->class = $class; + $this->ranges = array_values($ranges); + $this->sort(); + } + + /** + * Subtract the given range from all ranges in this collection. + * @param T $other + * @return RangeCollection + */ + public function booleanNot(AbstractRange $other): RangeCollection + { + return $this->map(function (AbstractRange $range) use ($other) { + return $range->booleanNot($other); + }); + } + + /** + * Return only the ranges that intersect with the given range from any + * range in this colelction. + * @param T $other + * @return RangeCollection + */ + public function booleanAnd(AbstractRange $other): RangeCollection + { + return $this->map(function (AbstractRange $range) use ($other) { + return $range->booleanAnd($other); + }); + } + + /** + * Transform this collection into the set of ranges that fully contain all + * ranges in this collection. This is done by merging overlapping or adjacent + * ranges until no more merges are possible. + * + * @return RangeCollection + */ + public function mergeRanges(): RangeCollection + { + return $this + ->mergeIntersectingRanges() + ->mergeAdjacentRanges(); + } + + /** + * Merge all ranges that intersect with each other into single continuous + * ranges instead of a buch of separate chunks. + * + * @return RangeCollection + */ + public function mergeIntersectingRanges(): RangeCollection + { + /** @var array */ + $merged = []; + foreach ($this->ranges as $range) { + $found = false; + foreach ($merged as $k => $m) { + if ($range->intersects($m)) { + $found = true; + $merged[$k] = $m->booleanOr($range)[0]; + break; + } + } + if (!$found) $merged[] = $range; + } + return new RangeCollection($this->class, ...$merged); + } + + /** + * Merge all ranges that are adjacent to each other into single continuous + * ranges instead of a buch of separate chunks. Note that this does not + * merge ranges that overlap, and strictly merges only ranges that are + * adjacent. If ranges are adjacent to multiple other ranges only one will + * be merged, and the others will remain separate. This method is protected + * because its behavior is complex in the case of multiple adjacent ranges + * and most users are probably looking for the behavior of mergeRanges() + * or mergeIntersectingRanges() anyway. + * @return RangeCollection + */ + protected function mergeAdjacentRanges(): RangeCollection + { + /** @var array */ + $merged = []; + foreach ($this->ranges as $range) { + $found = false; + foreach ($merged as $k => $m) { + if ($range->adjacent($m)) { + $found = true; + $merged[$k] = $m->booleanOr($range)[0]; + break; + } + } + if (!$found) $merged[] = $range; + } + return new RangeCollection($this->class, ...$merged); + } + + /** + * Filter this collection to only include ranges that return true when + * passed to the provided callback. + * @param callable(T):bool $callback + * @return RangeCollection + */ + public function filter(callable $callback): RangeCollection + { + return new RangeCollection($this->class, ...array_filter($this->ranges, $callback)); + } + + /** + * Inspect and modify each range in the collection using the provided callback. If the callback returns a collection it will be merged. If the callback returns null, the range will be removed. + * @param callable(T):(T|RangeCollection|null) $callback + * @return RangeCollection + */ + public function map(callable $callback): RangeCollection + { + $new_ranges = []; + foreach ($this->ranges as $range) { + $new_range = $callback($range); + if ($new_range instanceof RangeCollection) { + $new_ranges = array_merge($new_ranges, $new_range->toArray()); + } elseif ($new_range !== null) { + $new_ranges[] = $new_range; + } + } + return new RangeCollection($this->class, ...$new_ranges); + } + + public function isEmpty(): bool + { + return empty($this->ranges); + } + + /** + * @return array */ public function toArray(): array { @@ -75,28 +234,12 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate /** * @param T ...$ranges + * @return RangeCollection */ - public function add(AbstractRange ...$ranges): static + public function add(AbstractRange ...$ranges): RangeCollection { - foreach ($ranges as $range) { - if (!($range instanceof $this->class)) { - throw new InvalidArgumentException("Ranges must be of type $this->class"); - } - } - $this->ranges = array_merge($this->ranges, $ranges); - $this->sort(); - return $this; - } - - /** - * @param class-string $class - * @param T ...$ranges - * @return void - */ - protected function __construct(string $class, AbstractRange ...$ranges) - { - $this->class = $class; - $this->add(...$ranges); + $ranges = array_merge($this->ranges, $ranges); + return new RangeCollection($this->class, ...$ranges); } protected function sort(): void @@ -163,4 +306,9 @@ class RangeCollection implements Countable, ArrayAccess, IteratorAggregate { return new ArrayIterator($this->ranges); } + + public function __toString(): string + { + return implode(', ', $this->ranges); + } } diff --git a/tests/Ranges/IntegerRangeTest.php b/tests/Ranges/IntegerRangeTest.php index c3f0c41..f3296e8 100644 --- a/tests/Ranges/IntegerRangeTest.php +++ b/tests/Ranges/IntegerRangeTest.php @@ -769,10 +769,10 @@ class IntegerRangeTest extends TestCase 'intersecting open start left' => '[...20]', 'intersecting open start right' => '[...21]', 'intersecting open start center' => '[...20]', - 'disjoint open start' => '[...8] [10...20]', - 'disjoint open end' => '[10...20] [22...]', - 'disjoint left' => '[-2...8] [10...20]', - 'disjoint right' => '[10...20] [22...32]', + 'disjoint open start' => '[...8], [10...20]', + 'disjoint open end' => '[10...20], [22...]', + 'disjoint left' => '[-2...8], [10...20]', + 'disjoint right' => '[10...20], [22...32]', 'adjacent open start' => '[...20]', 'adjacent open end' => '[10...]', 'adjacent left' => '[-1...20]', @@ -809,8 +809,8 @@ class IntegerRangeTest extends TestCase 'intersecting bounded end left' => '[...29]', 'adjacent open end' => '[...]', 'adjacent bounded end' => '[...30]', - 'disjoint open end' => '[...20] [22...]', - 'disjoint bounded end' => '[...20] [22...32]', + 'disjoint open end' => '[...20], [22...]', + 'disjoint bounded end' => '[...20], [22...32]', ], $this->scenarioResults( new IntegerRange(null, 20), @@ -833,8 +833,8 @@ class IntegerRangeTest extends TestCase 'intersecting bounded start right' => '[1...]', 'adjacent open start' => '[...]', 'adjacent bounded start' => '[0...]', - 'disjoint open start' => '[...8] [10...]', - 'disjoint bounded start' => '[-2...8] [10...]', + 'disjoint open start' => '[...8], [10...]', + 'disjoint bounded start' => '[-2...8], [10...]', ], $this->scenarioResults( new IntegerRange(10, null), @@ -877,7 +877,7 @@ class IntegerRangeTest extends TestCase 'adjacent open end' => '[10...20]', 'adjacent left' => '[10...20]', 'adjacent right' => '[10...20]', - 'contained' => '[10...10] [20...20]', + 'contained' => '[10...10], [20...20]', 'contained same start' => '[20...20]', 'contained same end' => '[10...10]', 'containing' => '', @@ -900,7 +900,7 @@ class IntegerRangeTest extends TestCase 'unbounded' => '', 'intersecting open start left' => '[20...20]', 'intersecting open start right' => '', - 'intersecting bounded start left' => '[...8] [20...20]', + 'intersecting bounded start left' => '[...8], [20...20]', 'intersecting bounded start right' => '[...10]', 'intersecting bounded start center' => '[...9]', 'intersecting open end same' => '[...19]', @@ -925,7 +925,7 @@ class IntegerRangeTest extends TestCase 'intersecting open end left' => '', 'intersecting open end right' => '[10...10]', 'intersecting bounded end left' => '[20...]', - 'intersecting bounded end right' => '[10...10] [22...]', + 'intersecting bounded end right' => '[10...10], [22...]', 'intersecting bounded end center' => '[21...]', 'intersecting open start same' => '[11...]', 'intersecting open start right' => '[12...]', @@ -947,7 +947,7 @@ class IntegerRangeTest extends TestCase 'same' => '', 'open start' => '[21...]', 'open end' => '[...9]', - 'bounded' => '[...9] [21...]', + 'bounded' => '[...9], [21...]', ], $this->scenarioResults( new IntegerRange(null, null), @@ -962,27 +962,27 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '', - 'unbounded' => '[...9] [21...]', - 'intersecting open end left' => '[9...9] [21...]', - 'intersecting open end right' => '[10...10] [21...]', + 'unbounded' => '[...9], [21...]', + 'intersecting open end left' => '[9...9], [21...]', + 'intersecting open end right' => '[10...10], [21...]', 'intersecting open end center' => '[21...]', - 'intersecting open start left' => '[...9] [20...20]', - 'intersecting open start right' => '[...9] [21...21]', + 'intersecting open start left' => '[...9], [20...20]', + 'intersecting open start right' => '[...9], [21...21]', 'intersecting open start center' => '[...9]', - 'disjoint open start' => '[...8] [10...20]', - 'disjoint open end' => '[10...20] [22...]', - 'disjoint left' => '[-2...8] [10...20]', - 'disjoint right' => '[10...20] [22...32]', + 'disjoint open start' => '[...8], [10...20]', + 'disjoint open end' => '[10...20], [22...]', + 'disjoint left' => '[-2...8], [10...20]', + 'disjoint right' => '[10...20], [22...32]', 'adjacent open start' => '[...20]', 'adjacent open end' => '[10...]', 'adjacent left' => '[-1...20]', 'adjacent right' => '[10...31]', - 'contained' => '[10...10] [20...20]', + 'contained' => '[10...10], [20...20]', 'contained same start' => '[20...20]', 'contained same end' => '[10...10]', - 'containing' => '[9...9] [21...21]', - 'containing unbounded start' => '[...9] [21...21]', - 'containing unbounded end' => '[9...9] [21...]', + 'containing' => '[9...9], [21...21]', + 'containing unbounded start' => '[...9], [21...21]', + 'containing unbounded end' => '[9...9], [21...]', 'containing same start' => '[21...21]', 'containing same end' => '[9...9]', 'containing same start unbounded end' => '[21...]', @@ -1000,17 +1000,17 @@ class IntegerRangeTest extends TestCase 'unbounded' => '[21...]', 'intersecting open start left' => '[20...20]', 'intersecting open start right' => '[21...21]', - 'intersecting bounded start left' => '[...8] [20...20]', - 'intersecting bounded start right' => '[...10] [21...21]', + 'intersecting bounded start left' => '[...8], [20...20]', + 'intersecting bounded start right' => '[...10], [21...21]', 'intersecting bounded start center' => '[...9]', - 'intersecting open end same' => '[...19] [21...]', - 'intersecting open end left' => '[...18] [21...]', - 'intersecting bounded end same' => '[...19] [21...30]', - 'intersecting bounded end left' => '[...18] [21...29]', + 'intersecting open end same' => '[...19], [21...]', + 'intersecting open end left' => '[...18], [21...]', + 'intersecting bounded end same' => '[...19], [21...30]', + 'intersecting bounded end left' => '[...18], [21...29]', 'adjacent open end' => '[...]', 'adjacent bounded end' => '[...30]', - 'disjoint open end' => '[...20] [22...]', - 'disjoint bounded end' => '[...20] [22...32]', + 'disjoint open end' => '[...20], [22...]', + 'disjoint bounded end' => '[...20], [22...32]', ], $this->scenarioResults( new IntegerRange(null, 20), @@ -1024,17 +1024,17 @@ class IntegerRangeTest extends TestCase 'unbounded' => '[...9]', 'intersecting open end left' => '[9...9]', 'intersecting open end right' => '[10...10]', - 'intersecting bounded end left' => '[9...9] [20...]', - 'intersecting bounded end right' => '[10...10] [22...]', + 'intersecting bounded end left' => '[9...9], [20...]', + 'intersecting bounded end right' => '[10...10], [22...]', 'intersecting bounded end center' => '[21...]', - 'intersecting open start same' => '[...9] [11...]', - 'intersecting open start right' => '[...9] [12...]', - 'intersecting bounded start same' => '[0...9] [11...]', - 'intersecting bounded start right' => '[1...9] [12...]', + 'intersecting open start same' => '[...9], [11...]', + 'intersecting open start right' => '[...9], [12...]', + 'intersecting bounded start same' => '[0...9], [11...]', + 'intersecting bounded start right' => '[1...9], [12...]', 'adjacent open start' => '[...]', 'adjacent bounded start' => '[0...]', - 'disjoint open start' => '[...8] [10...]', - 'disjoint bounded start' => '[-2...8] [10...]', + 'disjoint open start' => '[...8], [10...]', + 'disjoint bounded start' => '[-2...8], [10...]', ], $this->scenarioResults( new IntegerRange(10, null), @@ -1047,7 +1047,7 @@ class IntegerRangeTest extends TestCase 'same' => '', 'open start' => '[21...]', 'open end' => '[...9]', - 'bounded' => '[...9] [21...]', + 'bounded' => '[...9], [21...]', ], $this->scenarioResults( new IntegerRange(null, null), @@ -1062,31 +1062,31 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '[10...20]', - 'unbounded' => '[...9] [10...20] [21...]', - 'intersecting open end left' => '[9...9] [10...20] [21...]', - 'intersecting open end right' => '[10...10] [11...20] [21...]', - 'intersecting open end center' => '[10...20] [21...]', - 'intersecting open start left' => '[...9] [10...19] [20...20]', - 'intersecting open start right' => '[...9] [10...20] [21...21]', - 'intersecting open start center' => '[...9] [10...20]', - 'disjoint open start' => '[...8] [10...20]', - 'disjoint open end' => '[10...20] [22...]', - 'disjoint left' => '[-2...8] [10...20]', - 'disjoint right' => '[10...20] [22...32]', - 'adjacent open start' => '[...9] [10...20]', - 'adjacent open end' => '[10...20] [21...]', - 'adjacent left' => '[-1...9] [10...20]', - 'adjacent right' => '[10...20] [21...31]', - 'contained' => '[10...10] [11...19] [20...20]', - 'contained same start' => '[10...19] [20...20]', - 'contained same end' => '[10...10] [11...20]', - 'containing' => '[9...9] [10...20] [21...21]', - 'containing unbounded start' => '[...9] [10...20] [21...21]', - 'containing unbounded end' => '[9...9] [10...20] [21...]', - 'containing same start' => '[10...20] [21...21]', - 'containing same end' => '[9...9] [10...20]', - 'containing same start unbounded end' => '[10...20] [21...]', - 'containing same end unbounded start' => '[...9] [10...20]', + 'unbounded' => '[...9], [10...20], [21...]', + 'intersecting open end left' => '[9...9], [10...20], [21...]', + 'intersecting open end right' => '[10...10], [11...20], [21...]', + 'intersecting open end center' => '[10...20], [21...]', + 'intersecting open start left' => '[...9], [10...19], [20...20]', + 'intersecting open start right' => '[...9], [10...20], [21...21]', + 'intersecting open start center' => '[...9], [10...20]', + 'disjoint open start' => '[...8], [10...20]', + 'disjoint open end' => '[10...20], [22...]', + 'disjoint left' => '[-2...8], [10...20]', + 'disjoint right' => '[10...20], [22...32]', + 'adjacent open start' => '[...9], [10...20]', + 'adjacent open end' => '[10...20], [21...]', + 'adjacent left' => '[-1...9], [10...20]', + 'adjacent right' => '[10...20], [21...31]', + 'contained' => '[10...10], [11...19], [20...20]', + 'contained same start' => '[10...19], [20...20]', + 'contained same end' => '[10...10], [11...20]', + 'containing' => '[9...9], [10...20], [21...21]', + 'containing unbounded start' => '[...9], [10...20], [21...21]', + 'containing unbounded end' => '[9...9], [10...20], [21...]', + 'containing same start' => '[10...20], [21...21]', + 'containing same end' => '[9...9], [10...20]', + 'containing same start unbounded end' => '[10...20], [21...]', + 'containing same end unbounded start' => '[...9], [10...20]', ], $this->scenarioResults( new IntegerRange(10, 20), @@ -1097,20 +1097,20 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '[...20]', - 'unbounded' => '[...20] [21...]', - 'intersecting open start left' => '[...19] [20...20]', - 'intersecting open start right' => '[...20] [21...21]', - 'intersecting bounded start left' => '[...8] [9...19] [20...20]', - 'intersecting bounded start right' => '[...10] [11...20] [21...21]', - 'intersecting bounded start center' => '[...9] [10...20]', - 'intersecting open end same' => '[...19] [20...20] [21...]', - 'intersecting open end left' => '[...18] [19...20] [21...]', - 'intersecting bounded end same' => '[...19] [20...20] [21...30]', - 'intersecting bounded end left' => '[...18] [19...20] [21...29]', - 'adjacent open end' => '[...20] [21...]', - 'adjacent bounded end' => '[...20] [21...30]', - 'disjoint open end' => '[...20] [22...]', - 'disjoint bounded end' => '[...20] [22...32]', + 'unbounded' => '[...20], [21...]', + 'intersecting open start left' => '[...19], [20...20]', + 'intersecting open start right' => '[...20], [21...21]', + 'intersecting bounded start left' => '[...8], [9...19], [20...20]', + 'intersecting bounded start right' => '[...10], [11...20], [21...21]', + 'intersecting bounded start center' => '[...9], [10...20]', + 'intersecting open end same' => '[...19], [20...20], [21...]', + 'intersecting open end left' => '[...18], [19...20], [21...]', + 'intersecting bounded end same' => '[...19], [20...20], [21...30]', + 'intersecting bounded end left' => '[...18], [19...20], [21...29]', + 'adjacent open end' => '[...20], [21...]', + 'adjacent bounded end' => '[...20], [21...30]', + 'disjoint open end' => '[...20], [22...]', + 'disjoint bounded end' => '[...20], [22...32]', ], $this->scenarioResults( new IntegerRange(null, 20), @@ -1121,20 +1121,20 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '[10...]', - 'unbounded' => '[...9] [10...]', - 'intersecting open end left' => '[9...9] [10...]', - 'intersecting open end right' => '[10...10] [11...]', - 'intersecting bounded end left' => '[9...9] [10...19] [20...]', - 'intersecting bounded end right' => '[10...10] [11...21] [22...]', - 'intersecting bounded end center' => '[10...20] [21...]', - 'intersecting open start same' => '[...9] [10...10] [11...]', - 'intersecting open start right' => '[...9] [10...11] [12...]', - 'intersecting bounded start same' => '[0...9] [10...10] [11...]', - 'intersecting bounded start right' => '[1...9] [10...11] [12...]', - 'adjacent open start' => '[...9] [10...]', - 'adjacent bounded start' => '[0...9] [10...]', - 'disjoint open start' => '[...8] [10...]', - 'disjoint bounded start' => '[-2...8] [10...]', + 'unbounded' => '[...9], [10...]', + 'intersecting open end left' => '[9...9], [10...]', + 'intersecting open end right' => '[10...10], [11...]', + 'intersecting bounded end left' => '[9...9], [10...19], [20...]', + 'intersecting bounded end right' => '[10...10], [11...21], [22...]', + 'intersecting bounded end center' => '[10...20], [21...]', + 'intersecting open start same' => '[...9], [10...10], [11...]', + 'intersecting open start right' => '[...9], [10...11], [12...]', + 'intersecting bounded start same' => '[0...9], [10...10], [11...]', + 'intersecting bounded start right' => '[1...9], [10...11], [12...]', + 'adjacent open start' => '[...9], [10...]', + 'adjacent bounded start' => '[0...9], [10...]', + 'disjoint open start' => '[...8], [10...]', + 'disjoint bounded start' => '[-2...8], [10...]', ], $this->scenarioResults( new IntegerRange(10, null), @@ -1145,9 +1145,9 @@ class IntegerRangeTest extends TestCase $this->assertEquals( [ 'same' => '[...]', - 'open start' => '[...20] [21...]', - 'open end' => '[...9] [10...]', - 'bounded' => '[...9] [10...20] [21...]', + 'open start' => '[...20], [21...]', + 'open end' => '[...9], [10...]', + 'bounded' => '[...9], [10...20], [21...]', ], $this->scenarioResults( new IntegerRange(null, null), @@ -1242,12 +1242,9 @@ class IntegerRangeTest extends TestCase return array_map( function ($s) use ($range, $method) { $result = $range->$method($s); - if ($result instanceof IntegerRange) { + if ($result instanceof IntegerRange || $result instanceof RangeCollection) { $result = (string)$result; } - if ($result instanceof RangeCollection) { - $result = implode(' ', $result->toArray()); - } return $result; }, $this->createScenarios($range) diff --git a/tests/Ranges/RangeCollectionTest.php b/tests/Ranges/RangeCollectionTest.php new file mode 100644 index 0000000..1c398f3 --- /dev/null +++ b/tests/Ranges/RangeCollectionTest.php @@ -0,0 +1,346 @@ +assertEquals( + '', + (string)$collection + ); + $this->assertEquals( + 0, + count($collection) + ); + $this->assertEquals( + [], + $collection->toArray() + ); + } + + public function testSorting() + { + // basic in-order sorting, sorted by start date + $collection = RangeCollection::create( + new IntegerRange(1, 2), + new IntegerRange(3, 4), + new IntegerRange(2, 4) + ); + $this->assertEquals( + '[1...2], [2...4], [3...4]', + (string)$collection + ); + // ties in the start date are broken by which has an earlier end date + $collection = RangeCollection::create( + new IntegerRange(1, 2), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + new IntegerRange(2, 3) + ); + $this->assertEquals( + '[1...2], [2...3], [2...4], [3...4]', + (string)$collection + ); + // infinite start dates go first + $collection = RangeCollection::create( + new IntegerRange(3, 4), + new IntegerRange(2, 4), + new IntegerRange(2, 3), + new IntegerRange(null, 2) + ); + $this->assertEquals( + '[...2], [2...3], [2...4], [3...4]', + (string)$collection + ); + // infinite end dates go last + $collection = RangeCollection::create( + new IntegerRange(2, 4), + new IntegerRange(2, null), + new IntegerRange(2, 3), + ); + $this->assertEquals( + '[2...3], [2...4], [2...]', + (string)$collection + ); + } + + public function testFilter() + { + // filter a collection to only include ranges that start with 2 + $collection = RangeCollection::create( + new IntegerRange(1, 2), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + new IntegerRange(2, 3) + ); + $filtered = $collection->filter(function ($range) { + return $range->start() == 2; + }); + $this->assertEquals( + '[2...3], [2...4]', + (string)$filtered + ); + } + + public function testMap() + { + // map a collection to include only the end date of each range + $collection = RangeCollection::create( + new IntegerRange(1, 2), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + new IntegerRange(2, 3) + ); + $mapped = $collection->map(function ($range) { + return new IntegerRange(null, $range->end()); + }); + $this->assertEquals( + '[...2], [...3], [...4], [...4]', + (string)$mapped + ); + // map a collection to make a one-unit collection of the start and end of each range + $collection = RangeCollection::create( + new IntegerRange(1, 2), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + new IntegerRange(2, 3) + ); + $mapped = $collection->map(function ($range) { + return RangeCollection::create( + new IntegerRange($range->start(), $range->start()), + new IntegerRange($range->end(), $range->end()) + ); + }); + $this->assertEquals( + '[1...1], [2...2], [2...2], [2...2], [3...3], [3...3], [4...4], [4...4]', + (string)$mapped + ); + // map a collection to remove any range that starts with 2 + $collection = RangeCollection::create( + new IntegerRange(1, 2), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + new IntegerRange(2, 3) + ); + $mapped = $collection->map(function ($range) { + if ($range->start() == 2) return null; + return $range; + }); + $this->assertEquals( + '[1...2], [3...4]', + (string)$mapped + ); + } + + public function testMergeIntersectingRanges() + { + // simple intersecting ranges + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $merged = $collection->mergeIntersectingRanges(); + $this->assertEquals( + '[1...4]', + (string)$merged + ); + // two groups of intersecting ranges that are adjacent but not overlapping + $collection = RangeCollection::create( + new IntegerRange(1, 2), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + new IntegerRange(2, 3), + new IntegerRange(5, 6), + new IntegerRange(7, 8), + new IntegerRange(6, 8), + new IntegerRange(6, 7) + ); + $merged = $collection->mergeIntersectingRanges(); + $this->assertEquals( + '[1...4], [5...8]', + (string)$merged + ); + } + + public function testMergeRanges() + { + // this method does everything mergeIntersectingRanges does, plus it + // merges adjacent ranges effectively this method turns a collection of + // ranges into the smallest possible set of ranges that fully contain + // all the original ranges + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $merged = $collection->mergeRanges(); + $this->assertEquals( + '[1...4]', + (string)$merged + ); + // two groups of intersecting ranges that are adjacent but not overlapping + $collection = RangeCollection::create( + new IntegerRange(1, 2), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + new IntegerRange(2, 3), + new IntegerRange(5, 6), + new IntegerRange(7, 8), + new IntegerRange(6, 8), + new IntegerRange(6, 7) + ); + $merged = $collection->mergeRanges(); + $this->assertEquals( + '[1...8]', + (string)$merged + ); + // two groups that are not adjacent + $collection = RangeCollection::create( + new IntegerRange(1, 2), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + new IntegerRange(2, 3), + new IntegerRange(5, 6), + new IntegerRange(7, 8), + new IntegerRange(6, 8), + new IntegerRange(6, 7), + new IntegerRange(10, 11), + new IntegerRange(12, 13), + new IntegerRange(11, 13), + new IntegerRange(11, 12) + ); + $merged = $collection->mergeRanges(); + $this->assertEquals( + '[1...8], [10...13]', + (string)$merged + ); + } + + public function testBooleanNot() + { + // subtract a range from a collection + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $subtracted = $collection->booleanNot(new IntegerRange(2, 3)); + $this->assertEquals( + '[1...1], [4...4], [4...4]', + (string)$subtracted + ); + // subtract a range from a collection that is fully contained + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $subtracted = $collection->booleanNot(new IntegerRange(2, 4)); + $this->assertEquals( + '[1...1]', + (string)$subtracted + ); + // subtract a range from a collection that fully contains the range + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $subtracted = $collection->booleanNot(new IntegerRange(1, 4)); + $this->assertEquals( + '', + (string)$subtracted + ); + // subtract a range from a collection that is fully contained + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $subtracted = $collection->booleanNot(new IntegerRange(1, 3)); + $this->assertEquals( + '[4...4], [4...4]', + (string)$subtracted + ); + } + + public function testBooleanAnd() + { + // intersect a range with a collection + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $intersected = $collection->booleanAnd(new IntegerRange(2, 3)); + $this->assertEquals( + '[2...3], [2...3], [3...3]', + (string)$intersected + ); + // intersect a range with a collection that is fully contained + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $intersected = $collection->booleanAnd(new IntegerRange(2, 4)); + $this->assertEquals( + '[2...3], [2...4], [3...4]', + (string)$intersected + ); + // intersect a range with a collection that fully contains the range + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $intersected = $collection->booleanAnd(new IntegerRange(1, 4)); + $this->assertEquals( + '[1...3], [2...4], [3...4]', + (string)$intersected + ); + // intersect a range with a collection that is fully contained + $collection = RangeCollection::create( + new IntegerRange(1, 3), + new IntegerRange(3, 4), + new IntegerRange(2, 4), + ); + $intersected = $collection->booleanAnd(new IntegerRange(1, 3)); + $this->assertEquals( + '[1...3], [2...3], [3...3]', + (string)$intersected + ); + } +}