boolean or and not operations

This commit is contained in:
Joby Elliott 2024-07-29 12:29:02 -06:00
parent 4dcbc43009
commit 0980e5e68c
3 changed files with 372 additions and 53 deletions

View file

@ -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;

View file

@ -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<int>
*/
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;

View file

@ -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)