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())) {