diff --git a/README.md b/README.md index e2b1e4f..5c3d7d6 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,30 @@ The spatial model implements the curve contract in clause 7.3.1, Description rules 4–12. This SQL/MM geometry is distinct from the OGC Simple Features geometry subset; EWKT remains a vendor extension. +## CompoundCurve decoding + +WKT and EWKT decode `COMPOUNDCURVE` into `CompoundCurveInterface` objects, +preserving each ordered linear or circular component without flattening or +linearizing it. XY, XYZ, XYM, XYZM and EMPTY are supported, including inside +geometry collections. Components share the containing curve's dimension. + +```php +$curve = (new Decoder(new EwktDecoderStrategy())) + ->decode('SRID=4326;COMPOUNDCURVE ((0 0,1 1),CIRCULARSTRING (1 1,2 0,3 1))'); +// $curve->getCurve(0) is a LineStringInterface +// $curve->getCurve(1) is a CircularStringInterface +// $curve->getSrid() === 4326 (also applied to components and their points) +``` + +Linear components use parenthesized coordinates without a `LINESTRING` keyword; +circular components use `CIRCULARSTRING`. This follows ISO/IEC CD +13249-3:201x(E), the available 2009-01-16 Committee Draft, clause 5.1.57, +``. Typed `LINESTRING` components are rejected in both dialects. +The existing WKT/EWKT dimension and SRID rules apply. Component continuity and +other structural invariants are validated by `spatial-types`; disconnected or +malformed curves raise the decoder's `InvalidArgumentException`, retaining the +original exception when domain construction fails. + ## Recognized types awaiting implementation `LongitudeOne\SpatialDecoder\Exception\NotYetImplementedException` is available @@ -114,13 +138,11 @@ The shared WKT/EWKT lexer uses `SpatialTypeImplementationStatus` from keywords. `CURVEPOLYGON`, `MULTICURVE`, `MULTISURFACE`, and `TIN` raise `NotYetImplementedException`. Non-instantiable enum types raise `NonInstantiableGeometryTypeException`. Implemented types -continue to the representation-specific parsers. `COMPOUNDCURVE` still raises -`NotYetImplementedException` because decoder support is not implemented, even -though the spatial model now provides it. EWKT supports `POINT`, -`LINESTRING`, `CIRCULARSTRING`, `POLYGON`, `TRIANGLE`, `POLYHEDRALSURFACE`, `MULTIPOINT`, +continue to the representation-specific parsers. EWKT supports `POINT`, +`LINESTRING`, `CIRCULARSTRING`, `COMPOUNDCURVE`, `POLYGON`, `TRIANGLE`, `POLYHEDRALSURFACE`, `MULTIPOINT`, `MULTILINESTRING`, `MULTIPOLYGON`, and `GEOMETRYCOLLECTION`, including nested collections and supported EMPTY values. Standard WKT retains its existing -geometry set, adds `CIRCULARSTRING`, and does not decode `TRIANGLE` or `POLYHEDRALSURFACE`. +geometry set, adds `CIRCULARSTRING` and `COMPOUNDCURVE`, and does not decode `TRIANGLE` or `POLYHEDRALSURFACE`. EWKT triangles support XY, XYZ, XYM, and XYZM. Polyhedral surfaces require XYZ or XYZM, including for EMPTY values; XY and XYM raise `InvalidArgumentException`, diff --git a/lib/Strategy/Common/Lexer.php b/lib/Strategy/Common/Lexer.php index 9d07846..d4e063d 100644 --- a/lib/Strategy/Common/Lexer.php +++ b/lib/Strategy/Common/Lexer.php @@ -189,8 +189,7 @@ private function getWordType(string $value): int throw new NonInstantiableGeometryTypeException($geometryType); } - // Compound curves are available in the model but still await decoder support. - if (GeometryTypeEnum::COMPOUNDCURVE === $geometryType || !SpatialTypeImplementationStatus::isFullyImplemented($geometryType)) { + if (!SpatialTypeImplementationStatus::isFullyImplemented($geometryType)) { throw new NotYetImplementedException($geometryType); } } diff --git a/lib/Strategy/Wkt/Factory/WktCompoundCurveFactory.php b/lib/Strategy/Wkt/Factory/WktCompoundCurveFactory.php new file mode 100644 index 0000000..961435d --- /dev/null +++ b/lib/Strategy/Wkt/Factory/WktCompoundCurveFactory.php @@ -0,0 +1,55 @@ + 2026 + * Copyright Longitude One 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\SpatialDecoder\Strategy\Wkt\Factory; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException; +use LongitudeOne\SpatialTypes\Exception\SpatialTypeExceptionInterface; +use LongitudeOne\SpatialTypes\Interfaces\CompoundCurveInterface; +use LongitudeOne\SpatialTypes\Interfaces\CurveInterface; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\CompoundCurve as CompoundCurve2D; +use LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\CompoundCurve as CompoundCurve3DM; +use LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\CompoundCurve as CompoundCurve3DZ; +use LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\CompoundCurve as CompoundCurve4D; + +/** + * Creates dimension-specific WKT compound curves. + * + * @internal + */ +final class WktCompoundCurveFactory +{ + /** + * Create a compound curve from its dimension and ordered components. + * + * @param CoordinateDimensionEnum $dimension coordinate dimension of the compound curve + * @param CurveInterface[] $curves ordered line-string and circular-string components + */ + public function createCompoundCurve(CoordinateDimensionEnum $dimension, array $curves): CompoundCurveInterface + { + try { + return match ($dimension) { + CoordinateDimensionEnum::XY => new CompoundCurve2D($curves), + CoordinateDimensionEnum::XYZ => new CompoundCurve3DZ($curves), + CoordinateDimensionEnum::XYM => new CompoundCurve3DM($curves), + CoordinateDimensionEnum::XYZM => new CompoundCurve4D($curves), + }; + } catch (SpatialTypeExceptionInterface $exception) { + throw new InvalidArgumentException($exception->getMessage(), 0, $exception); + } + } +} diff --git a/lib/Strategy/Wkt/Parser/CompoundCurveWktParser.php b/lib/Strategy/Wkt/Parser/CompoundCurveWktParser.php new file mode 100644 index 0000000..b206555 --- /dev/null +++ b/lib/Strategy/Wkt/Parser/CompoundCurveWktParser.php @@ -0,0 +1,130 @@ + 2026 + * Copyright Longitude One 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\SpatialDecoder\Strategy\Wkt\Parser; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialDecoder\Strategy\Common\Lexer; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktCompoundCurveFactory; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktLineStringFactory; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktCoordinateReader; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor; +use LongitudeOne\SpatialTypes\Interfaces\CircularStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\CurveInterface; +use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface; + +/** + * Parses WKT compound-curve representations. + * + * @internal + */ +final class CompoundCurveWktParser implements WktGeometryParserInterface +{ + /** + * Construct a compound-curve parser. + * + * @param WktTokenCursor $cursor lexer cursor for the WKT input + * @param WktCoordinateReader $coordinateReader reader for coordinate values and dimensions + * @param WktLineStringFactory $lineStringFactory factory for linear components + * @param WktCompoundCurveFactory $factory factory for the decoded compound curve + * @param WktGeometryParserDispatcherInterface $geometryParser dispatcher for typed curve components + */ + public function __construct( + private WktTokenCursor $cursor, + private WktCoordinateReader $coordinateReader, + private WktLineStringFactory $lineStringFactory, + private WktCompoundCurveFactory $factory, + private WktGeometryParserDispatcherInterface $geometryParser + ) { + } + + /** + * Parse a compound-curve representation. + * + * @param CoordinateDimensionEnum|null $inheritedDimension dimension inherited from a parent collection + * + * @return SpatialInterface the decoded compound curve + */ + public function parse(?CoordinateDimensionEnum $inheritedDimension = null): SpatialInterface + { + $dimension = $this->coordinateReader->consumeDimension($inheritedDimension); + if ($this->cursor->isNextToken(Lexer::T_EMPTY)) { + $this->cursor->moveNext(); + + return $this->factory->createCompoundCurve($dimension ?? CoordinateDimensionEnum::XY, []); + } + + $this->cursor->expectSymbol('('); + $curves = []; + do { + $curve = $this->parseComponent($dimension); + $dimension ??= $curve->getDimension(); + $curves[] = $curve; + + $hasNextCurve = $this->cursor->isNextToken(Lexer::T_COMMA); + if ($hasNextCurve) { + $this->cursor->moveNext(); + } + } while ($hasNextCurve); + $this->cursor->expectSymbol(')'); + + return $this->factory->createCompoundCurve($dimension, $curves); + } + + /** + * Parse a parenthesized linear component or a typed curve component. + * + * @param CoordinateDimensionEnum|null $dimension compound-curve dimension established by its parent or first component + * + * @return CurveInterface the decoded component + */ + private function parseComponent(?CoordinateDimensionEnum $dimension): CurveInterface + { + if ($this->cursor->isNextToken(Lexer::T_OPEN_PARENTHESIS)) { + [$dimension, $coordinates] = $this->coordinateReader->consumeCoordinateSequence($dimension); + + return $this->lineStringFactory->createLineString($dimension, $coordinates); + } + + return $this->parseTypedComponent($dimension); + } + + /** + * Parse and validate a typed circular-string component. + * + * @param CoordinateDimensionEnum|null $dimension compound-curve dimension established by its parent or first component + * + * @return CurveInterface the decoded component + */ + private function parseTypedComponent(?CoordinateDimensionEnum $dimension): CurveInterface + { + $token = $this->cursor->currentToken(); + if (null === $token || Lexer::T_CIRCULARSTRING !== $token->type) { + throw $this->cursor->createInvalidInputException('A compound curve component must be a line string or circular string.'); + } + + $curve = $this->geometryParser->parseNext($dimension); + if (null !== $dimension && $curve->getDimension() !== $dimension) { + throw $this->cursor->createInvalidInputException('Compound-curve components must use the compound-curve coordinate dimension.'); + } + if (GeometryTypeEnum::CIRCULARSTRING === $curve->getType() && $curve instanceof CircularStringInterface) { + return $curve; + } + + throw $this->cursor->createInvalidInputException('A compound curve component must be a line string or circular string.'); + } +} diff --git a/lib/Strategy/Wkt/Parser/WktParserFactory.php b/lib/Strategy/Wkt/Parser/WktParserFactory.php index 9661c13..b5f642a 100644 --- a/lib/Strategy/Wkt/Parser/WktParserFactory.php +++ b/lib/Strategy/Wkt/Parser/WktParserFactory.php @@ -19,6 +19,7 @@ use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException; use LongitudeOne\SpatialDecoder\Strategy\Common\Lexer; use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktCircularStringFactory; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktCompoundCurveFactory; use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktGeometryCollectionFactory; use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktLineStringFactory; use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktMultiLineStringFactory; @@ -74,6 +75,10 @@ public function createFromCursor(WktTokenCursor $cursor, WktDialect $dialect = W Lexer::T_CIRCULARSTRING, new CircularStringWktParser($cursor, $coordinateReader, new WktCircularStringFactory($pointFactory)) ); + $registry->register( + Lexer::T_COMPOUNDCURVE, + new CompoundCurveWktParser($cursor, $coordinateReader, $lineStringFactory, new WktCompoundCurveFactory(), $registry) + ); $registry->register(Lexer::T_MULTIPOINT, new MultiPointWktParser($cursor, $coordinateReader, new WktMultiPointFactory($pointFactory))); $registry->register(Lexer::T_MULTILINESTRING, new MultiLineStringWktParser($cursor, $coordinateReader, new WktMultiLineStringFactory($lineStringFactory))); $registry->register(Lexer::T_POLYGON, new PolygonWktParser($cursor, $coordinateReader, $polygonFactory)); diff --git a/tests/Unit/CompoundCurveDecoderTest.php b/tests/Unit/CompoundCurveDecoderTest.php new file mode 100644 index 0000000..f2ffd44 --- /dev/null +++ b/tests/Unit/CompoundCurveDecoderTest.php @@ -0,0 +1,223 @@ + 2026 + * Copyright Longitude One 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\SpatialDecoder\Tests\Unit; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException; +use LongitudeOne\SpatialDecoder\Strategy\EwktDecoderStrategy; +use LongitudeOne\SpatialDecoder\Strategy\WktDecoderStrategy; +use LongitudeOne\SpatialTypes\Exception\SpatialTypeExceptionInterface; +use LongitudeOne\SpatialTypes\Interfaces\CircularStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\CollectionInterface; +use LongitudeOne\SpatialTypes\Interfaces\CompoundCurveInterface; +use LongitudeOne\SpatialTypes\Interfaces\LineStringInterface; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +/** @internal */ +class CompoundCurveDecoderTest extends TestCase +{ + /** + * @return iterable, list, list, list}> + */ + public static function dimensions(): iterable + { + yield 'XY' => ['', CoordinateDimensionEnum::XY, [0, 0], [1, 1], [2, 0], [3, 1]]; + yield 'XYZ' => [' Z', CoordinateDimensionEnum::XYZ, [0, 0, 1], [1, 1, 2], [2, 0, 3], [3, 1, 4]]; + yield 'XYM' => [' M', CoordinateDimensionEnum::XYM, [0, 0, 1], [1, 1, 2], [2, 0, 3], [3, 1, 4]]; + yield 'XYZM' => [' ZM', CoordinateDimensionEnum::XYZM, [0, 0, 1, 2], [1, 1, 2, 3], [2, 0, 3, 4], [3, 1, 4, 5]]; + } + + /** @return iterable */ + public static function invalidCurves(): iterable + { + yield 'typed linear component' => ['COMPOUNDCURVE (LINESTRING (0 0,1 1))']; + yield 'unsupported component' => ['COMPOUNDCURVE (POINT (0 0))']; + yield 'disconnected components' => ['COMPOUNDCURVE ((0 0,1 1),CIRCULARSTRING (2 2,3 3,4 4))']; + yield 'empty line component' => ['COMPOUNDCURVE (EMPTY)']; + yield 'empty circular component' => ['COMPOUNDCURVE (CIRCULARSTRING EMPTY)']; + yield 'parent XYZ with short component' => ['COMPOUNDCURVE Z ((0 0 1,1 1 2),CIRCULARSTRING (1 1,2 0,3 1))']; + yield 'conflicting component marker' => ['COMPOUNDCURVE Z (CIRCULARSTRING M (0 0 1,1 1 2,2 0 3))']; + yield 'EWKT independently inferred component dimension' => ['COMPOUNDCURVE ((0 0 1,1 1 2),CIRCULARSTRING (1 1,2 0,3 1))']; + yield 'malformed component' => ['COMPOUNDCURVE ((0 0,1 1),)']; + } + + /** + * Format a coordinate tuple for a WKT fixture. + * + * @param list $tuple coordinate ordinates + */ + private static function tuple(array $tuple): string + { + return implode(' ', $tuple); + } + + /** EMPTY, compact/inferred EWKT dimensions and curve closure are retained. */ + public function testEmptyAndClosedCurves(): void + { + foreach ([ + 'COMPOUNDCURVE EMPTY' => CoordinateDimensionEnum::XY, + 'COMPOUNDCURVE Z EMPTY' => CoordinateDimensionEnum::XYZ, + 'COMPOUNDCURVE M EMPTY' => CoordinateDimensionEnum::XYM, + 'COMPOUNDCURVE ZM EMPTY' => CoordinateDimensionEnum::XYZM, + ] as $input => $dimension) { + foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) { + $empty = $strategy->decode($input); + self::assertInstanceOf(CompoundCurveInterface::class, $empty); + self::assertTrue($empty->isEmpty()); + self::assertSame($dimension, $empty->getDimension()); + } + } + foreach ([ + 'COMPOUNDCURVE EMPTY' => CoordinateDimensionEnum::XY, + 'COMPOUNDCURVEZ EMPTY' => CoordinateDimensionEnum::XYZ, + 'COMPOUNDCURVEM EMPTY' => CoordinateDimensionEnum::XYM, + 'COMPOUNDCURVEZM EMPTY' => CoordinateDimensionEnum::XYZM, + ] as $input => $dimension) { + $empty = (new EwktDecoderStrategy())->decode($input); + self::assertInstanceOf(CompoundCurveInterface::class, $empty); + self::assertSame($dimension, $empty->getDimension()); + } + + $closed = (new WktDecoderStrategy())->decode('COMPOUNDCURVE ((0 0,1 1,0 0))'); + self::assertInstanceOf(CompoundCurveInterface::class, $closed); + self::assertTrue($closed->isClosed()); + + $inferred = (new EwktDecoderStrategy())->decode('COMPOUNDCURVE ((0 0 1,1 1 2),CIRCULARSTRING (1 1 2,2 0 3,3 1 4))'); + self::assertInstanceOf(CompoundCurveInterface::class, $inferred); + self::assertSame(CoordinateDimensionEnum::XYZ, $inferred->getDimension()); + self::assertSame(CoordinateDimensionEnum::XYZ, $inferred->getCurve(1)->getDimension()); + + $nested = (new EwktDecoderStrategy())->decode('SRID=4326;GEOMETRYCOLLECTION (COMPOUNDCURVE ((0 0,1 1),CIRCULARSTRING (1 1,2 0,3 1)))'); + self::assertInstanceOf(CollectionInterface::class, $nested); + $nestedCurve = $nested->getElements()[0]; + self::assertInstanceOf(CompoundCurveInterface::class, $nestedCurve); + self::assertSame(4326, $nestedCurve->getSrid()); + self::assertSame(4326, $nestedCurve->getCurve(0)->getSrid()); + } + + /** Root EWKT SRID is retained by the compound curve, parts, and their points. */ + public function testEwktPropagatesSridToComponents(): void + { + $curve = (new EwktDecoderStrategy())->decode('SRID=4326;COMPOUNDCURVE Z ((0 0 1,1 1 2),CIRCULARSTRING (1 1 2,2 0 3,3 1 4))'); + self::assertInstanceOf(CompoundCurveInterface::class, $curve); + self::assertSame(4326, $curve->getSrid()); + + foreach ($curve->getCurves() as $part) { + self::assertSame(4326, $part->getSrid()); + foreach ([$part->getStartPoint(), $part->getEndPoint()] as $point) { + self::assertNotNull($point); + self::assertSame(4326, $point->getSrid()); + } + } + } + + /** + * Decode all dimensional layouts while preserving ordered concrete curve types. + * + * @param string $marker parent dimension marker + * @param CoordinateDimensionEnum $dimension expected layout + * @param list $start line start point + * @param list $join shared component endpoint + * @param list $middle circular-string control point + * @param list $end circular-string endpoint + */ + #[DataProvider('dimensions')] + public function testPreservesMixedCurvesAndDimensions( + string $marker, + CoordinateDimensionEnum $dimension, + array $start, + array $join, + array $middle, + array $end + ): void { + $input = 'COMPOUNDCURVE'.$marker.' (('.self::tuple($start).','.self::tuple($join).'),CIRCULARSTRING ('.self::tuple($join).','.self::tuple($middle).','.self::tuple($end).'))'; + foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) { + $curve = $strategy->decode($input); + self::assertInstanceOf(CompoundCurveInterface::class, $curve); + self::assertSame(GeometryTypeEnum::COMPOUNDCURVE, $curve->getType()); + self::assertSame($dimension, $curve->getDimension()); + self::assertSame([[$start, $join], [$join, $middle, $end]], $curve->toArray()); + self::assertFalse($curve->isClosed()); + self::assertCount(2, $curve->getCurves()); + self::assertInstanceOf(LineStringInterface::class, $curve->getCurve(0)); + self::assertInstanceOf(CircularStringInterface::class, $curve->getCurve(1)); + self::assertSame($dimension, $curve->getCurve(0)->getDimension()); + self::assertSame($dimension, $curve->getCurve(1)->getDimension()); + } + } + + /** + * Invalid structures follow the decoder exception contract in both dialects. + * + * @param string $input invalid WKT or EWKT representation + */ + #[DataProvider('invalidCurves')] + public function testRejectsInvalidCompoundCurves(string $input): void + { + foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) { + try { + $strategy->decode($strategy instanceof EwktDecoderStrategy ? 'SRID=4326;'.$input : $input); + self::fail('Invalid compound curves must be rejected.'); + } catch (InvalidArgumentException $exception) { + self::assertNotSame('', $exception->getMessage()); + } + } + } + + /** Parenthesized linear components and single curve families are supported. */ + public function testSupportsLinearAndSingleFamilyComponents(): void + { + foreach ([new WktDecoderStrategy(), new EwktDecoderStrategy()] as $strategy) { + $linear = $strategy->decode('COMPOUNDCURVE ((0 0,1 1,2 0))'); + self::assertInstanceOf(CompoundCurveInterface::class, $linear); + self::assertInstanceOf(LineStringInterface::class, $linear->getCurve(0)); + self::assertSame([[[0, 0], [1, 1], [2, 0]]], $linear->toArray()); + + $circular = $strategy->decode('COMPOUNDCURVE (CIRCULARSTRING (0 0,1 1,2 0))'); + self::assertInstanceOf(CompoundCurveInterface::class, $circular); + self::assertInstanceOf(CircularStringInterface::class, $circular->getCurve(0)); + self::assertSame([[[0, 0], [1, 1], [2, 0]]], $circular->toArray()); + } + } + + /** Standard WKT continues to reject an EWKT SRID prefix. */ + public function testWktRejectsSrid(): void + { + $this->expectException(InvalidArgumentException::class); + (new WktDecoderStrategy())->decode('SRID=4326;COMPOUNDCURVE EMPTY'); + } + + /** Standard WKT requires an explicit marker for non-XY coordinates. */ + public function testWktRequiresDimensionMarkerForNonXyCoordinates(): void + { + $this->expectException(InvalidArgumentException::class); + (new WktDecoderStrategy())->decode('COMPOUNDCURVE ((0 0 1,1 1 2),CIRCULARSTRING (1 1 2,2 0 3,3 1 4))'); + } + + /** Domain validation failures are wrapped and retain their cause. */ + public function testWrapsSpatialContractFailure(): void + { + try { + (new WktDecoderStrategy())->decode('COMPOUNDCURVE ((0 0,1 1),CIRCULARSTRING (2 2,3 3,4 4))'); + self::fail('Disconnected components must be rejected.'); + } catch (InvalidArgumentException $exception) { + self::assertInstanceOf(SpatialTypeExceptionInterface::class, $exception->getPrevious()); + } + } +} diff --git a/tests/Unit/CompoundCurveWktParserTest.php b/tests/Unit/CompoundCurveWktParserTest.php new file mode 100644 index 0000000..b81ae0e --- /dev/null +++ b/tests/Unit/CompoundCurveWktParserTest.php @@ -0,0 +1,76 @@ + 2026 + * Copyright Longitude One 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\SpatialDecoder\Tests\Unit; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialDecoder\Exception\InvalidArgumentException; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktCompoundCurveFactory; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktLineStringFactory; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\Factory\WktPointFactory; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\Parser\CompoundCurveWktParser; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\Parser\WktGeometryParserDispatcherInterface; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktCoordinateReader; +use LongitudeOne\SpatialDecoder\Strategy\Wkt\WktTokenCursor; +use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\CircularString; +use LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\Point; +use PHPUnit\Framework\TestCase; + +/** @internal */ +class CompoundCurveWktParserTest extends TestCase +{ + /** A dispatcher must not return a component with a conflicting dimension. */ + public function testRejectsDispatcherDimensionMismatch(): void + { + $parser = $this->parserReturning(new CircularString([])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains('Compound-curve components must use the compound-curve coordinate dimension.'); + $parser->parse(CoordinateDimensionEnum::XYZ); + } + + /** A circular-string token must resolve to a circular-string domain object. */ + public function testRejectsUnexpectedDispatcherGeometry(): void + { + $parser = $this->parserReturning(new Point()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIsOrContains('A compound curve component must be a line string or circular string.'); + $parser->parse(CoordinateDimensionEnum::XYZ); + } + + /** + * Build a parser whose dispatcher violates one component postcondition. + * + * @param SpatialInterface $component geometry returned for the circular-string token + */ + private function parserReturning(SpatialInterface $component): CompoundCurveWktParser + { + $cursor = new WktTokenCursor('(CIRCULARSTRING Z EMPTY)'); + $dispatcher = $this->createMock(WktGeometryParserDispatcherInterface::class); + $dispatcher->expects(self::once())->method('parseNext') + ->with(CoordinateDimensionEnum::XYZ)->willReturn($component); + + return new CompoundCurveWktParser( + $cursor, + new WktCoordinateReader($cursor), + new WktLineStringFactory(new WktPointFactory()), + new WktCompoundCurveFactory(), + $dispatcher + ); + } +} diff --git a/tests/Unit/EwktGeometryCoverageTest.php b/tests/Unit/EwktGeometryCoverageTest.php index 2e4417d..ed3d087 100644 --- a/tests/Unit/EwktGeometryCoverageTest.php +++ b/tests/Unit/EwktGeometryCoverageTest.php @@ -80,8 +80,7 @@ public static function invalidGeometries(): iterable /** @return iterable */ public static function unimplementedTypes(): iterable { - foreach ([GeometryTypeEnum::COMPOUNDCURVE, - GeometryTypeEnum::CURVEPOLYGON, GeometryTypeEnum::MULTICURVE, + foreach ([GeometryTypeEnum::CURVEPOLYGON, GeometryTypeEnum::MULTICURVE, GeometryTypeEnum::MULTISURFACE, GeometryTypeEnum::TIN] as $type) { yield $type->name => [$type]; } diff --git a/tests/Unit/SpatialTypeImplementationTest.php b/tests/Unit/SpatialTypeImplementationTest.php index 49642b6..8257673 100644 --- a/tests/Unit/SpatialTypeImplementationTest.php +++ b/tests/Unit/SpatialTypeImplementationTest.php @@ -44,8 +44,7 @@ public static function invalidInputs(): iterable /** @return iterable */ public static function missingTypes(): iterable { - foreach ([GeometryTypeEnum::COMPOUNDCURVE, - GeometryTypeEnum::CURVEPOLYGON, GeometryTypeEnum::MULTICURVE, + foreach ([GeometryTypeEnum::CURVEPOLYGON, GeometryTypeEnum::MULTICURVE, GeometryTypeEnum::MULTISURFACE, GeometryTypeEnum::TIN] as $type) { yield $type->name => [$type]; } @@ -54,7 +53,7 @@ public static function missingTypes(): iterable /** Test all implemented geometry keywords remain available to parsers. */ public function testImplementedTypesRemainLexerTokens(): void { - foreach ([GeometryTypeEnum::POINT, GeometryTypeEnum::LINESTRING, GeometryTypeEnum::CIRCULARSTRING, GeometryTypeEnum::POLYGON, + foreach ([GeometryTypeEnum::POINT, GeometryTypeEnum::LINESTRING, GeometryTypeEnum::CIRCULARSTRING, GeometryTypeEnum::COMPOUNDCURVE, GeometryTypeEnum::POLYGON, GeometryTypeEnum::TRIANGLE, GeometryTypeEnum::POLYHEDRALSURFACE, GeometryTypeEnum::MULTIPOINT, GeometryTypeEnum::MULTILINESTRING, GeometryTypeEnum::MULTIPOLYGON, GeometryTypeEnum::GEOMETRYCOLLECTION] as $type) {