From f66c2e223e963538ffc400d96de3ab1a160ac205 Mon Sep 17 00:00:00 2001 From: Alexandre Tranchant Date: Thu, 29 Oct 2026 13:39:40 +0100 Subject: [PATCH] feat(types): add mutable points and declared context editing --- docs/immutability.md | 15 +- docs/instantiable-spatial-types.md | 52 +++- docs/spatial-reference-systems.md | 4 + docs/type-class-hierarchy.md | 7 +- lib/Interfaces/MutablePointInterface.php | 13 +- lib/Types/AbstractPoint.php | 147 +++++++++++ lib/Types/AbstractPointImmutable.php | 250 +----------------- lib/Types/Dimension2/Point.php | 46 ++++ lib/Types/Dimension3m/Point.php | 48 ++++ lib/Types/Dimension3z/Point.php | 48 ++++ lib/Types/Dimension4zm/Point.php | 50 ++++ lib/Types/PointBehaviorTrait.php | 266 ++++++++++++++++++++ tests/Unit/Types/Dimension2/PointTest.php | 213 ++++++++++++++++ tests/Unit/Types/Dimension3m/PointTest.php | 201 +++++++++++++++ tests/Unit/Types/Dimension3z/PointTest.php | 201 +++++++++++++++ tests/Unit/Types/Dimension4zm/PointTest.php | 188 ++++++++++++++ 16 files changed, 1491 insertions(+), 258 deletions(-) create mode 100644 lib/Types/AbstractPoint.php create mode 100644 lib/Types/Dimension2/Point.php create mode 100644 lib/Types/Dimension3m/Point.php create mode 100644 lib/Types/Dimension3z/Point.php create mode 100644 lib/Types/Dimension4zm/Point.php create mode 100644 lib/Types/PointBehaviorTrait.php create mode 100644 tests/Unit/Types/Dimension2/PointTest.php create mode 100644 tests/Unit/Types/Dimension3m/PointTest.php create mode 100644 tests/Unit/Types/Dimension3z/PointTest.php create mode 100644 tests/Unit/Types/Dimension4zm/PointTest.php diff --git a/docs/immutability.md b/docs/immutability.md index 017b675..e7fd810 100644 --- a/docs/immutability.md +++ b/docs/immutability.md @@ -5,10 +5,10 @@ as immutable makes that reuse safe and makes a value's location explicit. ## Current contract -Every currently available concrete spatial class has an `Immutable` suffix and -is final and immutable through the public API. Former concrete names are removed, +Concrete immutable spatial classes have an `Immutable` suffix and +are final and immutable through the public API. Former concrete names are removed, without deprecated aliases. `Value\Coordinates` and `Reference\SpatialReference` -remain immutable auxiliary values with their existing names. Its coordinates, spatial reference, +remain immutable auxiliary values with their existing names. An immutable object's coordinates, spatial reference, and aggregate membership are set by its constructor, and it has no public setter or membership mutator. `getCoordinates()` returns an immutable `Value\Coordinates` value. `withCoordinates()` creates a distinct point with @@ -98,9 +98,10 @@ returning `static`. These metadata operations must preserve ordinates. Neither variant has a dimension setter. Immutable concrete namespaces fix the family and dimension; `withSpatialReference()` and `withSrid()` preserve them. -Concrete mutable classes and detailed type-specific editing methods arrive in -the following Stories of Epic #38. They are not available in this change. Existing -factories currently create the explicitly named immutable classes and expose +Mutable `Point` classes are available directly under each dimension namespace. +See [mutable points](instantiable-spatial-types.md#mutable-points) for constructors +and in-place operations. Mutable aggregates arrive in later Stories of Epic #38. +Existing factories create the explicitly named immutable classes and expose immutable return contracts. Immutable aggregate constructors and replacement paths accept only immutable @@ -139,7 +140,7 @@ multi-line string would both change as an indirect side effect. A caller that only holds `$multiLine` could therefore observe a geometry change without any operation being made on it. -Because points are immutable, that operation does not exist. Code that needs +Because these points are immutable, that operation does not exist. Code that needs another location creates another point and explicitly builds the spatial value that should contain it. The represented value remains separate from functional validity checks. diff --git a/docs/instantiable-spatial-types.md b/docs/instantiable-spatial-types.md index 93471a6..093bc85 100644 --- a/docs/instantiable-spatial-types.md +++ b/docs/instantiable-spatial-types.md @@ -216,6 +216,56 @@ ISO/IEC 13249-3 or OGC requirements. Request coordinate-completeness validation explicitly through `spatial-validator`; this library does not invoke it or depend on it. See [Validation](validators.md). +### Mutable points + +Mutable points live directly in the dimension namespace: + +| Layout | Class | Constructor ordinates | +| --- | --- | --- | +| XY | `Types\Dimension2\Point` | X, Y | +| XYZ | `Types\Dimension3z\Point` | X, Y, Z | +| XYM | `Types\Dimension3m\Point` | X, Y, M | +| XYZM | `Types\Dimension4zm\Point` | X, Y, Z, M | + +All ordinates default to `null`. Each constructor ends with +`int|SpatialReference $srid = 0` and +`SpatialModelEnum $family = SpatialModelEnum::GEOMETRY`. +The family is instance state: the same class supports Geometry and Geography. + +```php +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension3z\Point; + +$point = new Point(0, null, null, 4326); +$samePoint = $point->setX("2°30'E") + ->setY(48.5) + ->setZ(35) + ->setFamily(SpatialModelEnum::GEOGRAPHY) + ->setSpatialReference(SpatialReference::epsg(2154)); +assert($samePoint === $point); +assert([2.5, 48.5, 35] === $point->toArray()); +``` + +`MutablePointInterface` exposes `setX()`, `setY()`, `setZ()` and `setM()`; +`MutableSpatialInterface` provides `setFamily()`, `setSpatialReference()` and +`setSrid()`. Every successful setter returns the same instance as `static`. +X/Y accept `float|int|string|null` and use the constructor's geo-parser path +for strings. Z/M accept `float|int|null`, without string parsing. +Rejected technical or parsing input leaves the receiver unchanged. + +Calling a Z/M setter for an axis outside the declared layout throws +`LongitudeOne\SpatialTypes\Exception\BadMethodCallException`, even for `null`. +Type and dimension remain fixed; there is no dimension setter or `with…` method. +Supported axes accept null independently: `[0, null]` is not EMPTY. All-null +ordinates produce EMPTY, with `toArray() === []` and `getCoordinates() === null`. +A previously obtained `Coordinates` value remains an immutable snapshot. + +Family and declared-reference setters preserve every ordinate without coordinate +transformation. Geometric validation remains explicit in the consumer. Mutable +points cannot be inserted into immutable aggregates and are not implicitly +converted. Factories continue to create immutable types. + ### Immutable point coordinates `LongitudeOne\SpatialTypes\Value\Coordinates` is a public immutable value @@ -403,7 +453,7 @@ Functional validity checks belong to explicit consumer validation; see ## Immutability contract -Every spatial type is immutable through the public API: construction sets its +Every spatial type with the `Immutable` suffix is immutable through the public API: construction sets its ordinates, SRID, and aggregate membership, and no public mutator exists. `withCoordinates(Coordinates $coordinates): static` returns a point with replacement coordinates of the same dimension; `withSpatialReference(SpatialReference $reference): static` diff --git a/docs/spatial-reference-systems.md b/docs/spatial-reference-systems.md index 2ec37b7..caa4774 100644 --- a/docs/spatial-reference-systems.md +++ b/docs/spatial-reference-systems.md @@ -27,6 +27,10 @@ checked explicitly by consumers. See [Spatial validation](validators.md). reference. It does not change coordinates and therefore does not perform a coordinate transformation. `withSrid()` is the legacy integer equivalent. +Mutable points provide `setSpatialReference()`, `setSrid()` and `setFamily()` +for changing the declared context on the same instance. These setters return +that instance and preserve every ordinate, including null values. + To obtain a valid value in a different SRS, calculate new coordinates with an external transformer, then create or update a value with those coordinates and the target `SpatialReference`. diff --git a/docs/type-class-hierarchy.md b/docs/type-class-hierarchy.md index 705e1e9..a25c06f 100644 --- a/docs/type-class-hierarchy.md +++ b/docs/type-class-hierarchy.md @@ -6,8 +6,11 @@ one branch for each listed coordinate dimension and `{Geometry, Geography}` one concrete class for each spatial model. `AbstractSpatialTypeImmutable` extends the read-only `AbstractSpatialType` base. -The following hierarchy contains only immutable implementation bases; future mutable -classes do not inherit their copy operations. +The following hierarchy contains immutable implementation bases. Mutable points +extend `AbstractSpatialType` through `AbstractPoint`, with concrete +`Dimension{2,3m,3z,4zm}\Point` classes. `PointBehaviorTrait` shares point reads, +coordinate initialization and parsing between `AbstractPoint` and +`AbstractPointImmutable`; mutable points do not inherit copy operations. ```text AbstractSpatialTypeImmutable diff --git a/lib/Interfaces/MutablePointInterface.php b/lib/Interfaces/MutablePointInterface.php index c03e3b1..8b16835 100644 --- a/lib/Interfaces/MutablePointInterface.php +++ b/lib/Interfaces/MutablePointInterface.php @@ -16,7 +16,18 @@ namespace LongitudeOne\SpatialTypes\Interfaces; -/** Mutable point contract; type-specific editing is introduced separately. */ +/** In-place point editing; unavailable Z/M axes reject even null values. */ interface MutablePointInterface extends MutableSpatialInterface, PointInterface { + /** @param null|float|int $m Measure ordinate; the layout must contain M */ + public function setM(float|int|null $m): static; + + /** @param null|float|int|string $x X ordinate; strings use constructor parsing */ + public function setX(float|int|string|null $x): static; + + /** @param null|float|int|string $y Y ordinate; strings use constructor parsing */ + public function setY(float|int|string|null $y): static; + + /** @param null|float|int $z Elevation ordinate; the layout must contain Z */ + public function setZ(float|int|null $z): static; } diff --git a/lib/Types/AbstractPoint.php b/lib/Types/AbstractPoint.php new file mode 100644 index 0000000..39686a4 --- /dev/null +++ b/lib/Types/AbstractPoint.php @@ -0,0 +1,147 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\BadMethodCallException; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; + +/** Common in-place point editing, without geometric validation. + * @internal + */ +abstract class AbstractPoint extends AbstractSpatialType implements MutablePointInterface +{ + use PointBehaviorTrait; + + protected SpatialModelEnum $family = SpatialModelEnum::GEOMETRY; + + protected float|int|null $m = null; + + protected float|int|null $z = null; + + /** Return the family. */ + public function getFamily(): SpatialModelEnum + { + return $this->family; + } + + /** Return the m. */ + public function getM(): float|int|null + { + if (!$this->hasM()) { + throw BadMethodCallException::create(__METHOD__, $this->getDimension()); + } + + return $this->m; + } + + /** Return the type. */ + public function getType(): GeometryTypeEnum + { + return GeometryTypeEnum::POINT; + } + + /** Return the z. */ + public function getZ(): float|int|null + { + if (!$this->hasZ()) { + throw BadMethodCallException::create(__METHOD__, $this->getDimension()); + } + + return $this->z; + } + + /** @param SpatialModelEnum $family Declared family */ + public function setFamily(SpatialModelEnum $family): static + { + $this->family = $family; + + return $this; + } + + /** @param null|float|int $m Measure ordinate */ + public function setM(float|int|null $m): static + { + if (!$this->hasM()) { + throw BadMethodCallException::create(__METHOD__, $this->getDimension()); + } + + $this->m = $m; + $this->refreshEmptyState(); + + return $this; + } + + /** @param SpatialReference $reference Declared reference */ + public function setSpatialReference(SpatialReference $reference): static + { + $this->spatialReference = $reference; + + return $this; + } + + /** @param int $srid Declared reference identifier */ + public function setSrid(int $srid): static + { + return $this->setSpatialReference(SpatialReference::fromSrid($srid)); + } + + /** @param null|float|int|string $x X ordinate, using constructor parsing */ + public function setX(float|int|string|null $x): static + { + $this->initializeX($x); + $this->refreshEmptyState(); + + return $this; + } + + /** @param null|float|int|string $y Y ordinate, using constructor parsing */ + public function setY(float|int|string|null $y): static + { + $this->initializeY($y); + $this->refreshEmptyState(); + + return $this; + } + + /** @param null|float|int $z Elevation ordinate */ + public function setZ(float|int|null $z): static + { + if (!$this->hasZ()) { + throw BadMethodCallException::create(__METHOD__, $this->getDimension()); + } + + $this->z = $z; + $this->refreshEmptyState(); + + return $this; + } + + /** @return (null|float|int)[] */ + public function toArray(): array + { + return $this->getCoordinates()?->toArray() ?? []; + } + + /** Recompute EMPTY after a successful ordinate edit. */ + private function refreshEmptyState(): void + { + $this->hasOnlyNullCoordinates($this->x, $this->y, $this->z, $this->m); + } +} diff --git a/lib/Types/AbstractPointImmutable.php b/lib/Types/AbstractPointImmutable.php index 2a35c9b..e817c11 100644 --- a/lib/Types/AbstractPointImmutable.php +++ b/lib/Types/AbstractPointImmutable.php @@ -16,128 +16,16 @@ namespace LongitudeOne\SpatialTypes\Types; -use LongitudeOne\Core\Diagnostic\DiagnosticValueFormatter; -use LongitudeOne\GeoParser\Exception\RangeException as GeoParserRangeException; -use LongitudeOne\GeoParser\Exception\UnexpectedValueException; -use LongitudeOne\GeoParser\Parser; -use LongitudeOne\SpatialTypes\Exception\BadMethodCallException; use LongitudeOne\SpatialTypes\Exception\InvalidDimensionException; -use LongitudeOne\SpatialTypes\Exception\InvalidValueException; -use LongitudeOne\SpatialTypes\Exception\RangeException; use LongitudeOne\SpatialTypes\Interfaces\ImmutablePointInterface; -use LongitudeOne\SpatialTypes\Interfaces\PointInterface; use LongitudeOne\SpatialTypes\Value\Coordinates; -/** - * Abstract point object for POINT spatial types. - * - * @see https://stackoverflow.com/questions/7309121/preferred-order-of-writing-latitude-longitude-tuples - * @see https://docs.geotools.org/latest/userguide/library/referencing/order.html - * - * @internal this class provides common behaviour for geometry and geography points +/** Immutable point coordinate replacement. + * @internal */ abstract class AbstractPointImmutable extends AbstractSpatialTypeImmutable implements ImmutablePointInterface { - /** - * The X coordinate or the longitude. - */ - protected float|int|null $x = null; - - /** - * The Y coordinate or the latitude. - */ - protected float|int|null $y = null; - - /** Whether this point corresponds to the empty set. */ - private bool $empty = false; - - /** - * Is the current point equal to another point? - * - * @param PointInterface $point the point to compare - */ - public function equalsTo(PointInterface $point): bool - { - if (!($point instanceof static && $point->getSrid() === $this->getSrid() && $point->getFamily() === $this->getFamily() && $point->getDimension() === $this->getDimension())) { - return false; - } - - if ($this->isEmpty() || $point->isEmpty()) { - return $this->isEmpty() && $point->isEmpty(); - } - - if ($point->getX() !== $this->getX() || $point->getY() !== $this->getY()) { - return false; - } - - if ($point->hasM() && $this->hasM() && $this->getM() !== $point->getM()) { - return false; - } - - if ($point->hasZ() && $this->hasZ() && $this->getZ() !== $point->getZ()) { - return false; - } - - return true; - } - - /** - * Return the normalized coordinates of this point. - */ - public function getCoordinates(): ?Coordinates - { - if ($this->isEmpty()) { - return null; - } - - return new Coordinates( - $this->getDimension(), - $this->x, - $this->y, - $this->hasZ() ? $this->getZ() : null, - $this->hasM() ? $this->getM() : null - ); - } - - /** - * Latitude getter. - */ - public function getLatitude(): float|int|null - { - return $this->getY(); - } - - /** - * Longitude getter. - */ - public function getLongitude(): float|int|null - { - return $this->getX(); - } - - /** - * X getter. (Longitude getter). - */ - public function getX(): float|int|null - { - return $this->isEmpty() ? null : $this->x; - } - - /** - * Y getter. Latitude getter. - */ - public function getY(): float|int|null - { - return $this->isEmpty() ? null : $this->y; - } - - /** - * Does this point correspond to the empty set? - */ - public function isEmpty(): bool - { - return $this->empty; - } + use PointBehaviorTrait; /** * Return a copy of this point with the supplied normalized coordinates. @@ -171,136 +59,4 @@ public function withCoordinates(Coordinates $coordinates): static return $point; } - - /** - * Use the longitude-one/geo-parser to parse a coordinate. - * - * @param string $coordinate the coordinate to parse - * - * @throws InvalidValueException when coordinate is invalid - */ - protected function geoParse(string $coordinate): float|int - { - try { - $parser = new Parser($coordinate); - - $parsedCoordinate = $parser->parse(); - } catch (GeoParserRangeException $e) { - $messages = [ - GeoParserRangeException::LATITUDE_OUT_OF_RANGE => sprintf(InvalidValueException::OUT_OF_RANGE_LATITUDE, DiagnosticValueFormatter::format((string) $coordinate)), - GeoParserRangeException::LONGITUDE_OUT_OF_RANGE => sprintf(InvalidValueException::OUT_OF_RANGE_LONGITUDE, DiagnosticValueFormatter::format((string) $coordinate)), - GeoParserRangeException::MINUTES_OUT_OF_RANGE => sprintf(InvalidValueException::OUT_OF_RANGE_MINUTE, DiagnosticValueFormatter::format((string) $coordinate)), - GeoParserRangeException::SECONDS_OUT_OF_RANGE => sprintf(InvalidValueException::OUT_OF_RANGE_SECOND, DiagnosticValueFormatter::format((string) $coordinate)), - ]; - $message = $messages[$e->getCode()] ?? DiagnosticValueFormatter::format($e->getMessage()); - - throw new InvalidValueException($message, $e->getCode(), $e); - } catch (UnexpectedValueException $e) { - throw new InvalidValueException(sprintf('Invalid coordinate value, got "%s".', DiagnosticValueFormatter::format((string) $coordinate)), $e->getCode(), $e); - } - - if (is_array($parsedCoordinate)) { - throw new InvalidValueException('Invalid coordinate value, coordinate cannot be an array.'); - } - - return $parsedCoordinate; - } - - /** - * Determine whether all coordinates describe an empty point. - * - * A point is empty only when every ordinate in its coordinate layout is - * null. Partially supplied coordinates are preserved without validation. - * - * @param (null|float|int|string) ...$coordinates Point ordinates - */ - final protected function hasOnlyNullCoordinates(float|int|string|null ...$coordinates): bool - { - $this->empty = count($coordinates) === count(array_filter($coordinates, static fn (float|int|string|null $coordinate): bool => null === $coordinate)); - - return $this->empty; - } - - /** - * Set the M coordinate on a point that supports a measure ordinate. - * - * @param null|float|int $m M coordinate or measure - * - * @throws BadMethodCallException when the point has no M ordinate - */ - protected function initializeM(float|int|null $m): static - { - // @codeCoverageIgnoreStart - throw new BadMethodCallException(sprintf('The M ordinate "%s" cannot be assigned to a point with the %s dimension.', $m, $this->getDimension()->value)); - // @codeCoverageIgnoreEnd - } - - /** - * X setter. (Latitude setter). - * - * @param null|float|int|string $x the new X - * - * @throws InvalidValueException when x is not valid - */ - protected function initializeX(float|int|string|null $x): static - { - $this->x = $this->setCartesianCoordinate($x); - - return $this; - } - - /** - * Y setter. Longitude Setter. - * - * @param null|float|int|string $y the new Y value - * - * @throws InvalidValueException when Y is invalid, not in valid range - */ - protected function initializeY(float|int|string|null $y): static - { - $this->y = $this->setCartesianCoordinate($y); - - return $this; - } - - /** - * Set the Z coordinate on a point that supports an elevation ordinate. - * - * @param null|float|int $z Z coordinate or elevation - * - * @throws BadMethodCallException when the point has no Z ordinate - */ - protected function initializeZ(float|int|null $z): static - { - // @codeCoverageIgnoreStart - throw new BadMethodCallException(sprintf('The Z ordinate "%s" cannot be assigned to a point with the %s dimension.', $z, $this->getDimension()->value)); - // @codeCoverageIgnoreEnd - } - - /** - * Set a cartesian coordinate. - * Abscissa or ordinate. - * - * @param null|float|int|string $coordinate the coordinate to set - * - * @throws InvalidValueException when coordinate is invalid, RangeException is never thrown - */ - protected function setCartesianCoordinate(float|int|string|null $coordinate): float|int|null - { - if (null === $coordinate || is_integer($coordinate) || is_float($coordinate)) { - // We don't check the range of the value. - return $coordinate; - } - - // $y is a string, let's use the geo-parser. - return $this->geoParse($coordinate); - } - - /** - * Convert point into an array of coordinates. - * SRID is NOT exported. - * - * The array contains coordinate values only; it does not contain spatial object instances. - */ - abstract public function toArray(): array; } diff --git a/lib/Types/Dimension2/Point.php b/lib/Types/Dimension2/Point.php new file mode 100644 index 0000000..5473246 --- /dev/null +++ b/lib/Types/Dimension2/Point.php @@ -0,0 +1,46 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension2; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\AbstractPoint; + +/** Mutable XY point with independently editable declared context. */ +final class Point extends AbstractPoint +{ + /** + * @param null|float|int|string $x X ordinate; strings use geo-parser + * @param null|float|int|string $y Y ordinate; strings use geo-parser + * @param int|SpatialReference $srid Declared reference + * @param SpatialModelEnum $family Declared family + */ + public function __construct(float|int|string|null $x = null, float|int|string|null $y = null, int|SpatialReference $srid = 0, SpatialModelEnum $family = SpatialModelEnum::GEOMETRY) + { + $this->setFamily($family); + $this->initializeSpatialReference($srid); + $this->setX($x); + $this->setY($y); + } + + /** Return the dimension. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XY; + } +} diff --git a/lib/Types/Dimension3m/Point.php b/lib/Types/Dimension3m/Point.php new file mode 100644 index 0000000..05425bc --- /dev/null +++ b/lib/Types/Dimension3m/Point.php @@ -0,0 +1,48 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3m; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\AbstractPoint; + +/** Mutable XYM point with independently editable declared context. */ +final class Point extends AbstractPoint +{ + /** + * @param null|float|int|string $x X ordinate; strings use geo-parser + * @param null|float|int|string $y Y ordinate; strings use geo-parser + * @param null|float|int $m M ordinate + * @param int|SpatialReference $srid Declared reference + * @param SpatialModelEnum $family Declared family + */ + public function __construct(float|int|string|null $x = null, float|int|string|null $y = null, float|int|null $m = null, int|SpatialReference $srid = 0, SpatialModelEnum $family = SpatialModelEnum::GEOMETRY) + { + $this->setFamily($family); + $this->initializeSpatialReference($srid); + $this->setX($x); + $this->setY($y); + $this->setM($m); + } + + /** Return the dimension. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYM; + } +} diff --git a/lib/Types/Dimension3z/Point.php b/lib/Types/Dimension3z/Point.php new file mode 100644 index 0000000..86126e6 --- /dev/null +++ b/lib/Types/Dimension3z/Point.php @@ -0,0 +1,48 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3z; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\AbstractPoint; + +/** Mutable XYZ point with independently editable declared context. */ +final class Point extends AbstractPoint +{ + /** + * @param null|float|int|string $x X ordinate; strings use geo-parser + * @param null|float|int|string $y Y ordinate; strings use geo-parser + * @param null|float|int $z Z ordinate + * @param int|SpatialReference $srid Declared reference + * @param SpatialModelEnum $family Declared family + */ + public function __construct(float|int|string|null $x = null, float|int|string|null $y = null, float|int|null $z = null, int|SpatialReference $srid = 0, SpatialModelEnum $family = SpatialModelEnum::GEOMETRY) + { + $this->setFamily($family); + $this->initializeSpatialReference($srid); + $this->setX($x); + $this->setY($y); + $this->setZ($z); + } + + /** Return the dimension. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZ; + } +} diff --git a/lib/Types/Dimension4zm/Point.php b/lib/Types/Dimension4zm/Point.php new file mode 100644 index 0000000..3c053f4 --- /dev/null +++ b/lib/Types/Dimension4zm/Point.php @@ -0,0 +1,50 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension4zm; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\AbstractPoint; + +/** Mutable XYZM point with independently editable declared context. */ +final class Point extends AbstractPoint +{ + /** + * @param null|float|int|string $x X ordinate; strings use geo-parser + * @param null|float|int|string $y Y ordinate; strings use geo-parser + * @param null|float|int $z Z ordinate + * @param null|float|int $m M ordinate + * @param int|SpatialReference $srid Declared reference + * @param SpatialModelEnum $family Declared family + */ + public function __construct(float|int|string|null $x = null, float|int|string|null $y = null, float|int|null $z = null, float|int|null $m = null, int|SpatialReference $srid = 0, SpatialModelEnum $family = SpatialModelEnum::GEOMETRY) + { + $this->setFamily($family); + $this->initializeSpatialReference($srid); + $this->setX($x); + $this->setY($y); + $this->setZ($z); + $this->setM($m); + } + + /** Return the dimension. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZM; + } +} diff --git a/lib/Types/PointBehaviorTrait.php b/lib/Types/PointBehaviorTrait.php new file mode 100644 index 0000000..7eb5928 --- /dev/null +++ b/lib/Types/PointBehaviorTrait.php @@ -0,0 +1,266 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types; + +use LongitudeOne\Core\Diagnostic\DiagnosticValueFormatter; +use LongitudeOne\GeoParser\Exception\RangeException as GeoParserRangeException; +use LongitudeOne\GeoParser\Exception\UnexpectedValueException; +use LongitudeOne\GeoParser\Parser; +use LongitudeOne\SpatialTypes\Exception\BadMethodCallException; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Exception\RangeException; +use LongitudeOne\SpatialTypes\Interfaces\PointInterface; +use LongitudeOne\SpatialTypes\Value\Coordinates; + +/** Shared point reads, initialization and coordinate parsing. + * @internal + */ +trait PointBehaviorTrait +{ + /** + * The X coordinate or the longitude. + */ + protected float|int|null $x = null; + + /** + * The Y coordinate or the latitude. + */ + protected float|int|null $y = null; + + /** Whether this point corresponds to the empty set. */ + private bool $empty = false; + + /** + * Is the current point equal to another point? + * + * @param PointInterface $point the point to compare + */ + public function equalsTo(PointInterface $point): bool + { + if (!($point instanceof static && $point->getSrid() === $this->getSrid() && $point->getFamily() === $this->getFamily() && $point->getDimension() === $this->getDimension())) { + return false; + } + + if ($this->isEmpty() || $point->isEmpty()) { + return $this->isEmpty() && $point->isEmpty(); + } + + if ($point->getX() !== $this->getX() || $point->getY() !== $this->getY()) { + return false; + } + + if ($point->hasM() && $this->hasM() && $this->getM() !== $point->getM()) { + return false; + } + + if ($point->hasZ() && $this->hasZ() && $this->getZ() !== $point->getZ()) { + return false; + } + + return true; + } + + /** + * Return the normalized coordinates of this point. + */ + public function getCoordinates(): ?Coordinates + { + if ($this->isEmpty()) { + return null; + } + + return new Coordinates( + $this->getDimension(), + $this->x, + $this->y, + $this->hasZ() ? $this->getZ() : null, + $this->hasM() ? $this->getM() : null + ); + } + + /** + * Latitude getter. + */ + public function getLatitude(): float|int|null + { + return $this->getY(); + } + + /** + * Longitude getter. + */ + public function getLongitude(): float|int|null + { + return $this->getX(); + } + + /** + * X getter. (Longitude getter). + */ + public function getX(): float|int|null + { + return $this->isEmpty() ? null : $this->x; + } + + /** + * Y getter. Latitude getter. + */ + public function getY(): float|int|null + { + return $this->isEmpty() ? null : $this->y; + } + + /** + * Does this point correspond to the empty set? + */ + public function isEmpty(): bool + { + return $this->empty; + } + + /** + * Use the longitude-one/geo-parser to parse a coordinate. + * + * @param string $coordinate the coordinate to parse + * + * @throws InvalidValueException when coordinate is invalid + */ + protected function geoParse(string $coordinate): float|int + { + try { + $parser = new Parser($coordinate); + + $parsedCoordinate = $parser->parse(); + } catch (GeoParserRangeException $e) { + $messages = [ + GeoParserRangeException::LATITUDE_OUT_OF_RANGE => sprintf(InvalidValueException::OUT_OF_RANGE_LATITUDE, DiagnosticValueFormatter::format((string) $coordinate)), + GeoParserRangeException::LONGITUDE_OUT_OF_RANGE => sprintf(InvalidValueException::OUT_OF_RANGE_LONGITUDE, DiagnosticValueFormatter::format((string) $coordinate)), + GeoParserRangeException::MINUTES_OUT_OF_RANGE => sprintf(InvalidValueException::OUT_OF_RANGE_MINUTE, DiagnosticValueFormatter::format((string) $coordinate)), + GeoParserRangeException::SECONDS_OUT_OF_RANGE => sprintf(InvalidValueException::OUT_OF_RANGE_SECOND, DiagnosticValueFormatter::format((string) $coordinate)), + ]; + $message = $messages[$e->getCode()] ?? DiagnosticValueFormatter::format($e->getMessage()); + + throw new InvalidValueException($message, $e->getCode(), $e); + } catch (UnexpectedValueException $e) { + throw new InvalidValueException(sprintf('Invalid coordinate value, got "%s".', DiagnosticValueFormatter::format((string) $coordinate)), $e->getCode(), $e); + } + + if (is_array($parsedCoordinate)) { + throw new InvalidValueException('Invalid coordinate value, coordinate cannot be an array.'); + } + + return $parsedCoordinate; + } + + /** + * Determine whether all coordinates describe an empty point. + * + * A point is empty only when every ordinate in its coordinate layout is + * null. Partially supplied coordinates are preserved without validation. + * + * @param (null|float|int|string) ...$coordinates Point ordinates + */ + final protected function hasOnlyNullCoordinates(float|int|string|null ...$coordinates): bool + { + $this->empty = count($coordinates) === count(array_filter($coordinates, static fn (float|int|string|null $coordinate): bool => null === $coordinate)); + + return $this->empty; + } + + /** + * Set the M coordinate on a point that supports a measure ordinate. + * + * @param null|float|int $m M coordinate or measure + * + * @throws BadMethodCallException when the point has no M ordinate + */ + protected function initializeM(float|int|null $m): static + { + // @codeCoverageIgnoreStart + throw new BadMethodCallException(sprintf('The M ordinate "%s" cannot be assigned to a point with the %s dimension.', $m, $this->getDimension()->value)); + // @codeCoverageIgnoreEnd + } + + /** + * X setter. (Latitude setter). + * + * @param null|float|int|string $x the new X + * + * @throws InvalidValueException when x is not valid + */ + protected function initializeX(float|int|string|null $x): static + { + $this->x = $this->setCartesianCoordinate($x); + + return $this; + } + + /** + * Y setter. Longitude Setter. + * + * @param null|float|int|string $y the new Y value + * + * @throws InvalidValueException when Y is invalid, not in valid range + */ + protected function initializeY(float|int|string|null $y): static + { + $this->y = $this->setCartesianCoordinate($y); + + return $this; + } + + /** + * Set the Z coordinate on a point that supports an elevation ordinate. + * + * @param null|float|int $z Z coordinate or elevation + * + * @throws BadMethodCallException when the point has no Z ordinate + */ + protected function initializeZ(float|int|null $z): static + { + // @codeCoverageIgnoreStart + throw new BadMethodCallException(sprintf('The Z ordinate "%s" cannot be assigned to a point with the %s dimension.', $z, $this->getDimension()->value)); + // @codeCoverageIgnoreEnd + } + + /** + * Set a cartesian coordinate. + * Abscissa or ordinate. + * + * @param null|float|int|string $coordinate the coordinate to set + * + * @throws InvalidValueException when coordinate is invalid, RangeException is never thrown + */ + protected function setCartesianCoordinate(float|int|string|null $coordinate): float|int|null + { + if (null === $coordinate || is_integer($coordinate) || is_float($coordinate)) { + // We don't check the range of the value. + return $coordinate; + } + + // $y is a string, let's use the geo-parser. + return $this->geoParse($coordinate); + } + + /** + * Convert point into an array of coordinates. + * SRID is NOT exported. + * + * The array contains coordinate values only; it does not contain spatial object instances. + */ + abstract public function toArray(): array; +} diff --git a/tests/Unit/Types/Dimension2/PointTest.php b/tests/Unit/Types/Dimension2/PointTest.php new file mode 100644 index 0000000..60900dc --- /dev/null +++ b/tests/Unit/Types/Dimension2/PointTest.php @@ -0,0 +1,213 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types\Dimension2; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\BadMethodCallException; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Interfaces\ImmutableSpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableSpatialInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use PHPUnit\Framework\TestCase; + +/** + * Mutable XY point contracts. + * + * @internal + * + * @coversNothing + */ +class PointTest extends TestCase +{ + /** Edits and declared context retain identity. */ + public function testEditsAndDeclaredContextRetainIdentity(): void + { + $point = new Point(1, 2, 4326); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + $snapshot = $point->getCoordinates(); + $reference = SpatialReference::epsg(2154); + static::assertSame($point, $point->setX(3.5)->setY(4)->setFamily(SpatialModelEnum::GEOGRAPHY)->setSpatialReference($reference)); + static::assertSame([3.5, 4], $point->toArray()); + static::assertSame($reference, $point->getSpatialReference()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(CoordinateDimensionEnum::XY, $point->getDimension()); + static::assertSame(GeometryTypeEnum::POINT, $point->getType()); + static::assertSame([1, 2], $snapshot?->toArray()); + static::assertSame($point, $point->setSrid(3857)->setFamily(SpatialModelEnum::GEOMETRY)); + static::assertSame(3857, $point->getSrid()); + static::assertSame([3.5, 4], $point->toArray()); + } + + /** Empty construction and recovery. */ + public function testEmptyConstructionAndRecovery(): void + { + $point = new Point(); + static::assertTrue($point->isEmpty()); + static::assertSame([], $point->toArray()); + static::assertSame($point, $point->setY(0)); + static::assertSame([null, 0], $point->toArray()); + static::assertSame(0, $point->getLatitude()); + static::assertNull($point->getLongitude()); + } + + /** Geography construction and independent null transitions. */ + public function testGeographyConstructionAndIndependentNullTransitions(): void + { + $point = new Point(1, 2, SpatialReference::epsg(4326), SpatialModelEnum::GEOGRAPHY); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame($point, $point->setX(null)); + static::assertNull($point->getX()); + static::assertFalse($point->isEmpty()); + static::assertSame($point, $point->setY(null)); + static::assertTrue($point->isEmpty()); + static::assertSame([], $point->toArray()); + static::assertNull($point->getCoordinates()); + static::assertSame($point, $point->setX(0)); + static::assertFalse($point->isEmpty()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $point->getDimension()); + static::assertSame(4326, $point->getSrid()); + } + + /** Mutable contract excludes immutable operations. */ + public function testMutableContractExcludesImmutableOperations(): void + { + $point = new Point(); + static::assertContains(MutablePointInterface::class, class_implements($point)); + static::assertContains(MutableSpatialInterface::class, class_implements($point)); + static::assertNotContains(ImmutableSpatialInterface::class, class_implements($point)); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withCoordinates')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withSpatialReference')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withSrid')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('setDimension')); + } + + /** Rejected abscissa parsing preserves receiver. */ + public function testRejectedAbscissaParsingPreservesReceiver(): void + { + $point = new Point(1, 2, 4326, SpatialModelEnum::GEOGRAPHY); + + try { + $point->setX('not a coordinate'); + static::fail('Invalid coordinate must be rejected.'); + } catch (InvalidValueException) { + static::assertSame([1, 2], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + } + } + + /** Rejected ordinate parsing preserves receiver. */ + public function testRejectedOrdinateParsingPreservesReceiver(): void + { + $point = new Point(1, 2, 4326, SpatialModelEnum::GEOGRAPHY); + + try { + $point->setY('not a coordinate'); + static::fail('Invalid coordinate must be rejected.'); + } catch (InvalidValueException) { + static::assertSame([1, 2], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + } + } + + /** Setters use constructor coordinate parsing. */ + public function testSettersUseConstructorCoordinateParsing(): void + { + $point = new Point(1, 2); + $constructed = new Point("2°30'E", "48°30'N"); + static::assertSame($point, $point->setX("2°30'E")->setY("48°30'N")); + static::assertSame($constructed->getX(), $point->getX()); + static::assertSame($constructed->getY(), $point->getY()); + static::assertSame(2.5, $point->getLongitude()); + static::assertSame(48.5, $point->getLatitude()); + } + + /** Technical input rejection preserves receiver. */ + public function testTechnicalInputRejectionPreservesReceiver(): void + { + $point = new Point(1, 2); + + try { + (new \ReflectionMethod($point, 'setX'))->invoke($point, []); + static::fail('Arrays are not coordinate input.'); + } catch (\TypeError) { + static::assertSame([1, 2], $point->toArray()); + } + } + + /** Unavailable elevation rejects null without mutation. */ + public function testUnavailableElevationRejectsNullWithoutMutation(): void + { + $point = new Point(1, 2); + + try { + $point->setZ(null); + static::fail('An unavailable axis must be rejected.'); + } catch (BadMethodCallException) { + static::assertSame([1, 2], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $point->getDimension()); + } + } + + /** Unavailable elevation rejects number without mutation. */ + public function testUnavailableElevationRejectsNumberWithoutMutation(): void + { + $point = new Point(1, 2); + + try { + $point->setZ(9); + static::fail('An unavailable axis must be rejected.'); + } catch (BadMethodCallException) { + static::assertSame([1, 2], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $point->getDimension()); + } + } + + /** Unavailable measure rejects null without mutation. */ + public function testUnavailableMeasureRejectsNullWithoutMutation(): void + { + $point = new Point(1, 2); + + try { + $point->setM(null); + static::fail('An unavailable axis must be rejected.'); + } catch (BadMethodCallException) { + static::assertSame([1, 2], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $point->getDimension()); + } + } + + /** Unavailable measure rejects number without mutation. */ + public function testUnavailableMeasureRejectsNumberWithoutMutation(): void + { + $point = new Point(1, 2); + + try { + $point->setM(9); + static::fail('An unavailable axis must be rejected.'); + } catch (BadMethodCallException) { + static::assertSame([1, 2], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $point->getDimension()); + } + } +} diff --git a/tests/Unit/Types/Dimension3m/PointTest.php b/tests/Unit/Types/Dimension3m/PointTest.php new file mode 100644 index 0000000..1e03e16 --- /dev/null +++ b/tests/Unit/Types/Dimension3m/PointTest.php @@ -0,0 +1,201 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types\Dimension3m; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\BadMethodCallException; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Interfaces\ImmutableSpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableSpatialInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension3m\Point; +use PHPUnit\Framework\TestCase; + +/** + * Mutable XYM point contracts. + * + * @internal + * + * @coversNothing + */ +class PointTest extends TestCase +{ + /** Edits and declared context retain identity. */ + public function testEditsAndDeclaredContextRetainIdentity(): void + { + $point = new Point(1, 2, 5, 4326); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + $snapshot = $point->getCoordinates(); + $reference = SpatialReference::epsg(2154); + static::assertSame($point, $point->setX(3.5)->setY(4)->setM(6)->setFamily(SpatialModelEnum::GEOGRAPHY)->setSpatialReference($reference)); + static::assertSame([3.5, 4, 6], $point->toArray()); + static::assertSame($reference, $point->getSpatialReference()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(CoordinateDimensionEnum::XYM, $point->getDimension()); + static::assertSame(GeometryTypeEnum::POINT, $point->getType()); + static::assertSame([1, 2, 5], $snapshot?->toArray()); + static::assertSame($point, $point->setSrid(3857)->setFamily(SpatialModelEnum::GEOMETRY)); + static::assertSame(3857, $point->getSrid()); + static::assertSame([3.5, 4, 6], $point->toArray()); + } + + /** Empty construction and recovery. */ + public function testEmptyConstructionAndRecovery(): void + { + $point = new Point(); + static::assertTrue($point->isEmpty()); + static::assertSame([], $point->toArray()); + static::assertSame($point, $point->setY(0)); + static::assertSame([null, 0, null], $point->toArray()); + static::assertSame(0, $point->getLatitude()); + static::assertNull($point->getLongitude()); + } + + /** Geography construction and independent null transitions. */ + public function testGeographyConstructionAndIndependentNullTransitions(): void + { + $point = new Point(1, 2, 5, SpatialReference::epsg(4326), SpatialModelEnum::GEOGRAPHY); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame($point, $point->setX(null)); + static::assertNull($point->getX()); + static::assertFalse($point->isEmpty()); + static::assertSame($point, $point->setY(null)); + static::assertSame($point, $point->setM(null)); + static::assertNull($point->getM()); + static::assertTrue($point->isEmpty()); + static::assertSame([], $point->toArray()); + static::assertNull($point->getCoordinates()); + static::assertSame($point, $point->setX(0)); + static::assertFalse($point->isEmpty()); + static::assertSame([0, null, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XYM, $point->getDimension()); + static::assertSame(4326, $point->getSrid()); + } + + /** Measure rejects string without mutation. */ + public function testMeasureRejectsStringWithoutMutation(): void + { + $point = new Point(1, 2, 5); + + try { + $setter = (new \ReflectionMethod($point, 'setM'))->getClosure($point); + $setter('12'); + static::fail('Numeric strings are not accepted for M.'); + } catch (\TypeError) { + static::assertSame([1, 2, 5], $point->toArray()); + } + } + + /** Mutable contract excludes immutable operations. */ + public function testMutableContractExcludesImmutableOperations(): void + { + $point = new Point(); + static::assertContains(MutablePointInterface::class, class_implements($point)); + static::assertContains(MutableSpatialInterface::class, class_implements($point)); + static::assertNotContains(ImmutableSpatialInterface::class, class_implements($point)); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withCoordinates')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withSpatialReference')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withSrid')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('setDimension')); + } + + /** Rejected abscissa parsing preserves receiver. */ + public function testRejectedAbscissaParsingPreservesReceiver(): void + { + $point = new Point(1, 2, 5, 4326, SpatialModelEnum::GEOGRAPHY); + + try { + $point->setX('not a coordinate'); + static::fail('Invalid coordinate must be rejected.'); + } catch (InvalidValueException) { + static::assertSame([1, 2, 5], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + } + } + + /** Rejected ordinate parsing preserves receiver. */ + public function testRejectedOrdinateParsingPreservesReceiver(): void + { + $point = new Point(1, 2, 5, 4326, SpatialModelEnum::GEOGRAPHY); + + try { + $point->setY('not a coordinate'); + static::fail('Invalid coordinate must be rejected.'); + } catch (InvalidValueException) { + static::assertSame([1, 2, 5], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + } + } + + /** Setters use constructor coordinate parsing. */ + public function testSettersUseConstructorCoordinateParsing(): void + { + $point = new Point(1, 2, 5); + $constructed = new Point("2°30'E", "48°30'N", 5); + static::assertSame($point, $point->setX("2°30'E")->setY("48°30'N")); + static::assertSame($constructed->getX(), $point->getX()); + static::assertSame($constructed->getY(), $point->getY()); + static::assertSame(2.5, $point->getLongitude()); + static::assertSame(48.5, $point->getLatitude()); + } + + /** Technical input rejection preserves receiver. */ + public function testTechnicalInputRejectionPreservesReceiver(): void + { + $point = new Point(1, 2, 5); + + try { + (new \ReflectionMethod($point, 'setX'))->invoke($point, []); + static::fail('Arrays are not coordinate input.'); + } catch (\TypeError) { + static::assertSame([1, 2, 5], $point->toArray()); + } + } + + /** Unavailable elevation rejects null without mutation. */ + public function testUnavailableElevationRejectsNullWithoutMutation(): void + { + $point = new Point(1, 2, 5); + + try { + $point->setZ(null); + static::fail('An unavailable axis must be rejected.'); + } catch (BadMethodCallException) { + static::assertSame([1, 2, 5], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XYM, $point->getDimension()); + } + } + + /** Unavailable elevation rejects number without mutation. */ + public function testUnavailableElevationRejectsNumberWithoutMutation(): void + { + $point = new Point(1, 2, 5); + + try { + $point->setZ(9); + static::fail('An unavailable axis must be rejected.'); + } catch (BadMethodCallException) { + static::assertSame([1, 2, 5], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XYM, $point->getDimension()); + } + } +} diff --git a/tests/Unit/Types/Dimension3z/PointTest.php b/tests/Unit/Types/Dimension3z/PointTest.php new file mode 100644 index 0000000..b670f5c --- /dev/null +++ b/tests/Unit/Types/Dimension3z/PointTest.php @@ -0,0 +1,201 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types\Dimension3z; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\BadMethodCallException; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Interfaces\ImmutableSpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableSpatialInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension3z\Point; +use PHPUnit\Framework\TestCase; + +/** + * Mutable XYZ point contracts. + * + * @internal + * + * @coversNothing + */ +class PointTest extends TestCase +{ + /** Edits and declared context retain identity. */ + public function testEditsAndDeclaredContextRetainIdentity(): void + { + $point = new Point(1, 2, 5, 4326); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + $snapshot = $point->getCoordinates(); + $reference = SpatialReference::epsg(2154); + static::assertSame($point, $point->setX(3.5)->setY(4)->setZ(6)->setFamily(SpatialModelEnum::GEOGRAPHY)->setSpatialReference($reference)); + static::assertSame([3.5, 4, 6], $point->toArray()); + static::assertSame($reference, $point->getSpatialReference()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(CoordinateDimensionEnum::XYZ, $point->getDimension()); + static::assertSame(GeometryTypeEnum::POINT, $point->getType()); + static::assertSame([1, 2, 5], $snapshot?->toArray()); + static::assertSame($point, $point->setSrid(3857)->setFamily(SpatialModelEnum::GEOMETRY)); + static::assertSame(3857, $point->getSrid()); + static::assertSame([3.5, 4, 6], $point->toArray()); + } + + /** Elevation rejects string without mutation. */ + public function testElevationRejectsStringWithoutMutation(): void + { + $point = new Point(1, 2, 5); + + try { + $setter = (new \ReflectionMethod($point, 'setZ'))->getClosure($point); + $setter('12'); + static::fail('Numeric strings are not accepted for Z.'); + } catch (\TypeError) { + static::assertSame([1, 2, 5], $point->toArray()); + } + } + + /** Empty construction and recovery. */ + public function testEmptyConstructionAndRecovery(): void + { + $point = new Point(); + static::assertTrue($point->isEmpty()); + static::assertSame([], $point->toArray()); + static::assertSame($point, $point->setY(0)); + static::assertSame([null, 0, null], $point->toArray()); + static::assertSame(0, $point->getLatitude()); + static::assertNull($point->getLongitude()); + } + + /** Geography construction and independent null transitions. */ + public function testGeographyConstructionAndIndependentNullTransitions(): void + { + $point = new Point(1, 2, 5, SpatialReference::epsg(4326), SpatialModelEnum::GEOGRAPHY); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame($point, $point->setX(null)); + static::assertNull($point->getX()); + static::assertFalse($point->isEmpty()); + static::assertSame($point, $point->setY(null)); + static::assertSame($point, $point->setZ(null)); + static::assertNull($point->getZ()); + static::assertTrue($point->isEmpty()); + static::assertSame([], $point->toArray()); + static::assertNull($point->getCoordinates()); + static::assertSame($point, $point->setX(0)); + static::assertFalse($point->isEmpty()); + static::assertSame([0, null, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XYZ, $point->getDimension()); + static::assertSame(4326, $point->getSrid()); + } + + /** Mutable contract excludes immutable operations. */ + public function testMutableContractExcludesImmutableOperations(): void + { + $point = new Point(); + static::assertContains(MutablePointInterface::class, class_implements($point)); + static::assertContains(MutableSpatialInterface::class, class_implements($point)); + static::assertNotContains(ImmutableSpatialInterface::class, class_implements($point)); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withCoordinates')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withSpatialReference')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withSrid')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('setDimension')); + } + + /** Rejected abscissa parsing preserves receiver. */ + public function testRejectedAbscissaParsingPreservesReceiver(): void + { + $point = new Point(1, 2, 5, 4326, SpatialModelEnum::GEOGRAPHY); + + try { + $point->setX('not a coordinate'); + static::fail('Invalid coordinate must be rejected.'); + } catch (InvalidValueException) { + static::assertSame([1, 2, 5], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + } + } + + /** Rejected ordinate parsing preserves receiver. */ + public function testRejectedOrdinateParsingPreservesReceiver(): void + { + $point = new Point(1, 2, 5, 4326, SpatialModelEnum::GEOGRAPHY); + + try { + $point->setY('not a coordinate'); + static::fail('Invalid coordinate must be rejected.'); + } catch (InvalidValueException) { + static::assertSame([1, 2, 5], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + } + } + + /** Setters use constructor coordinate parsing. */ + public function testSettersUseConstructorCoordinateParsing(): void + { + $point = new Point(1, 2, 5); + $constructed = new Point("2°30'E", "48°30'N", 5); + static::assertSame($point, $point->setX("2°30'E")->setY("48°30'N")); + static::assertSame($constructed->getX(), $point->getX()); + static::assertSame($constructed->getY(), $point->getY()); + static::assertSame(2.5, $point->getLongitude()); + static::assertSame(48.5, $point->getLatitude()); + } + + /** Technical input rejection preserves receiver. */ + public function testTechnicalInputRejectionPreservesReceiver(): void + { + $point = new Point(1, 2, 5); + + try { + (new \ReflectionMethod($point, 'setX'))->invoke($point, []); + static::fail('Arrays are not coordinate input.'); + } catch (\TypeError) { + static::assertSame([1, 2, 5], $point->toArray()); + } + } + + /** Unavailable measure rejects null without mutation. */ + public function testUnavailableMeasureRejectsNullWithoutMutation(): void + { + $point = new Point(1, 2, 5); + + try { + $point->setM(null); + static::fail('An unavailable axis must be rejected.'); + } catch (BadMethodCallException) { + static::assertSame([1, 2, 5], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XYZ, $point->getDimension()); + } + } + + /** Unavailable measure rejects number without mutation. */ + public function testUnavailableMeasureRejectsNumberWithoutMutation(): void + { + $point = new Point(1, 2, 5); + + try { + $point->setM(9); + static::fail('An unavailable axis must be rejected.'); + } catch (BadMethodCallException) { + static::assertSame([1, 2, 5], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XYZ, $point->getDimension()); + } + } +} diff --git a/tests/Unit/Types/Dimension4zm/PointTest.php b/tests/Unit/Types/Dimension4zm/PointTest.php new file mode 100644 index 0000000..be825bc --- /dev/null +++ b/tests/Unit/Types/Dimension4zm/PointTest.php @@ -0,0 +1,188 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types\Dimension4zm; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Interfaces\ImmutableSpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableSpatialInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension4zm\Point; +use PHPUnit\Framework\TestCase; + +/** + * Mutable XYZM point contracts. + * + * @internal + * + * @coversNothing + */ +class PointTest extends TestCase +{ + /** Edits and declared context retain identity. */ + public function testEditsAndDeclaredContextRetainIdentity(): void + { + $point = new Point(1, 2, 5, 7, 4326); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + $snapshot = $point->getCoordinates(); + $reference = SpatialReference::epsg(2154); + static::assertSame($point, $point->setX(3.5)->setY(4)->setZ(6)->setM(8)->setFamily(SpatialModelEnum::GEOGRAPHY)->setSpatialReference($reference)); + static::assertSame([3.5, 4, 6, 8], $point->toArray()); + static::assertSame($reference, $point->getSpatialReference()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(CoordinateDimensionEnum::XYZM, $point->getDimension()); + static::assertSame(GeometryTypeEnum::POINT, $point->getType()); + static::assertSame([1, 2, 5, 7], $snapshot?->toArray()); + static::assertSame($point, $point->setSrid(3857)->setFamily(SpatialModelEnum::GEOMETRY)); + static::assertSame(3857, $point->getSrid()); + static::assertSame([3.5, 4, 6, 8], $point->toArray()); + } + + /** Elevation rejects string without mutation. */ + public function testElevationRejectsStringWithoutMutation(): void + { + $point = new Point(1, 2, 5, 7); + + try { + $setter = (new \ReflectionMethod($point, 'setZ'))->getClosure($point); + $setter('12'); + static::fail('Numeric strings are not accepted for Z.'); + } catch (\TypeError) { + static::assertSame([1, 2, 5, 7], $point->toArray()); + } + } + + /** Empty construction and recovery. */ + public function testEmptyConstructionAndRecovery(): void + { + $point = new Point(); + static::assertTrue($point->isEmpty()); + static::assertSame([], $point->toArray()); + static::assertSame($point, $point->setY(0)); + static::assertSame([null, 0, null, null], $point->toArray()); + static::assertSame(0, $point->getLatitude()); + static::assertNull($point->getLongitude()); + } + + /** Geography construction and independent null transitions. */ + public function testGeographyConstructionAndIndependentNullTransitions(): void + { + $point = new Point(1, 2, 5, 7, SpatialReference::epsg(4326), SpatialModelEnum::GEOGRAPHY); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame($point, $point->setX(null)); + static::assertNull($point->getX()); + static::assertFalse($point->isEmpty()); + static::assertSame($point, $point->setY(null)); + static::assertSame($point, $point->setZ(null)); + static::assertNull($point->getZ()); + static::assertSame($point, $point->setM(null)); + static::assertNull($point->getM()); + static::assertTrue($point->isEmpty()); + static::assertSame([], $point->toArray()); + static::assertNull($point->getCoordinates()); + static::assertSame($point, $point->setX(0)); + static::assertFalse($point->isEmpty()); + static::assertSame([0, null, null, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XYZM, $point->getDimension()); + static::assertSame(4326, $point->getSrid()); + } + + /** Measure rejects string without mutation. */ + public function testMeasureRejectsStringWithoutMutation(): void + { + $point = new Point(1, 2, 5, 7); + + try { + $setter = (new \ReflectionMethod($point, 'setM'))->getClosure($point); + $setter('12'); + static::fail('Numeric strings are not accepted for M.'); + } catch (\TypeError) { + static::assertSame([1, 2, 5, 7], $point->toArray()); + } + } + + /** Mutable contract excludes immutable operations. */ + public function testMutableContractExcludesImmutableOperations(): void + { + $point = new Point(); + static::assertContains(MutablePointInterface::class, class_implements($point)); + static::assertContains(MutableSpatialInterface::class, class_implements($point)); + static::assertNotContains(ImmutableSpatialInterface::class, class_implements($point)); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withCoordinates')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withSpatialReference')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('withSrid')); + static::assertFalse((new \ReflectionClass($point))->hasMethod('setDimension')); + } + + /** Rejected abscissa parsing preserves receiver. */ + public function testRejectedAbscissaParsingPreservesReceiver(): void + { + $point = new Point(1, 2, 5, 7, 4326, SpatialModelEnum::GEOGRAPHY); + + try { + $point->setX('not a coordinate'); + static::fail('Invalid coordinate must be rejected.'); + } catch (InvalidValueException) { + static::assertSame([1, 2, 5, 7], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + } + } + + /** Rejected ordinate parsing preserves receiver. */ + public function testRejectedOrdinateParsingPreservesReceiver(): void + { + $point = new Point(1, 2, 5, 7, 4326, SpatialModelEnum::GEOGRAPHY); + + try { + $point->setY('not a coordinate'); + static::fail('Invalid coordinate must be rejected.'); + } catch (InvalidValueException) { + static::assertSame([1, 2, 5, 7], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + } + } + + /** Setters use constructor coordinate parsing. */ + public function testSettersUseConstructorCoordinateParsing(): void + { + $point = new Point(1, 2, 5, 7); + $constructed = new Point("2°30'E", "48°30'N", 5, 5); + static::assertSame($point, $point->setX("2°30'E")->setY("48°30'N")); + static::assertSame($constructed->getX(), $point->getX()); + static::assertSame($constructed->getY(), $point->getY()); + static::assertSame(2.5, $point->getLongitude()); + static::assertSame(48.5, $point->getLatitude()); + } + + /** Technical input rejection preserves receiver. */ + public function testTechnicalInputRejectionPreservesReceiver(): void + { + $point = new Point(1, 2, 5, 7); + + try { + (new \ReflectionMethod($point, 'setX'))->invoke($point, []); + static::fail('Arrays are not coordinate input.'); + } catch (\TypeError) { + static::assertSame([1, 2, 5, 7], $point->toArray()); + } + } +}