From 51bcf5f75ccfd75c47af267aa32a293bf925252d Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Fri, 26 Jul 2024 14:02:22 -0600 Subject: [PATCH] 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))); + } + } +}