From 2f6c9e91203020b02465d6fd157317e2847d3c26 Mon Sep 17 00:00:00 2001 From: Alexandre Tranchant Date: Mon, 21 Sep 2026 21:27:57 +0200 Subject: [PATCH] feat(geojson): preserve geometry collection structure --- docs/strategies.md | 42 +++- .../Strategy/GeoJson/GeometryEncoder.php | 25 ++- .../GeoJson/ExceptionContractTest.php | 26 +++ .../GeoJson/GeographyCollectionTest.php | 190 ++++++++++++++++++ .../GeoJson/GeometryCollectionTest.php | 190 ++++++++++++++++++ tests/README.md | 8 + 6 files changed, 476 insertions(+), 5 deletions(-) create mode 100644 tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyCollectionTest.php create mode 100644 tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryCollectionTest.php diff --git a/docs/strategies.md b/docs/strategies.md index 0d395b5..d1031c1 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -13,7 +13,7 @@ This table describes the capabilities of the repository's current implementation | Class. | Output | SRID in output | Dimensions written | Usage | | --------------------- | ------------------------------- | ----------------- | ------------------ | ---------------------------------------------------------------- | -| `GeoJsonStrategy` | GeoJSON geometry text | No | XY, XYZ | Point, LineString, MultiPoint, MultiLineString, Polygon and MultiPolygon encoding according to RFC 7946 | +| `GeoJsonStrategy` | GeoJSON geometry text | No | XY, XYZ | Point, LineString, MultiPoint, MultiLineString, Polygon, MultiPolygon and GeometryCollection encoding according to RFC 7946 | | `WktTextStrategy` | WKT Well Known Text | No | XY, XYZ, XYM, XYZM | Display, text exchanges, functions accepting WKT | | `EwktTextStrategy` | EWKT Extended Well Known Text. | Yes, when nonzero | XY, XYZ, XYM, XYZM | Text exchanges that include the spatial reference | | `WkbBinaryStrategy` | ISO WKB Well Known Binary. | No | XY, XYZ, XYM, XYZM | Exchanges with a standard WKB consumer | @@ -235,9 +235,9 @@ and the [testing guide](../tests/README.md). To add a format, implement ## GeoJSON — `GeoJsonStrategy` -This increment supports Geometry and Geography Points, LineStrings, MultiPoints, -MultiLineStrings, Polygons and MultiPolygons in XY and XYZ, including EMPTY. Other geometry -types are not yet supported. Feature and FeatureCollection composition belongs +This strategy supports Geometry and Geography Points, LineStrings, MultiPoints, +MultiLineStrings, Polygons, MultiPolygons and collections in XY and XYZ, including +EMPTY. Types outside the seven RFC 7946 geometry types are rejected. Feature and FeatureCollection composition belongs to the consuming application. ```php @@ -420,3 +420,37 @@ References: [RFC 7946 geometry objects and positions](https://www.rfc-editor.org [MultiPolygon members](https://www.rfc-editor.org/rfc/rfc7946.html#section-3.1.7), [coordinate reference system](https://www.rfc-editor.org/rfc/rfc7946.html#section-4), [non-extensible types](https://www.rfc-editor.org/rfc/rfc7946.html#section-7). + +GeometryCollections recursively preserve each member's type, coordinates, order +and nesting. GeographyCollection uses the same GeoJSON `GeometryCollection` type: + +```php +$collection = new \LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\GeometryCollection(4326, [ + new \LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Point(1, 2, 4326), + new \LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineString([[3, 4], [5, 6]], 4326), +]); +echo $writer->convert($collection); +// {"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]}]} +``` + +A collection with no members produces +`{"type":"GeometryCollection","geometries":[]}`. EMPTY geometry members retain +their own empty `coordinates` arrays; nested collections retain their +`geometries` arrays. No `coordinates` member is added to a collection. XY and +XYZ are supported, preserving Z; XYM and XYZM are rejected even when EMPTY. +Reference metadata is omitted throughout, under the caller responsibilities above. + +The approved policy deliberately preserves nested, singleton and homogeneous +collections instead of flattening them or converting them to another type. +This does not apply the SHOULD guidance in +[RFC 7946 section 3.1.8](https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.8). +Consumers that expect simplified forms may not handle or display these collections +consistently; callers must prepare any simplification needed by their consumers. +Antimeridian crossings also remain unchanged under section 3.1.9 as described above. + +All six other supported types use their normal encoders when nested in a +collection. A non-RFC member throws `UnsupportedSpatialTypeException`; an +incompatible member structure throws `UnsupportedGeometryStructureException`. +Failures propagate through every collection level: no member is omitted and no +partial result is returned. JSON failures retain the shared `JsonEncodingException` +contract and original exception. diff --git a/lib/LongitudeOne/SpatialWriter/Strategy/GeoJson/GeometryEncoder.php b/lib/LongitudeOne/SpatialWriter/Strategy/GeoJson/GeometryEncoder.php index 5f2995d..1ed47ce 100644 --- a/lib/LongitudeOne/SpatialWriter/Strategy/GeoJson/GeometryEncoder.php +++ b/lib/LongitudeOne/SpatialWriter/Strategy/GeoJson/GeometryEncoder.php @@ -17,6 +17,7 @@ namespace LongitudeOne\SpatialWriter\Strategy\GeoJson; use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialTypes\Interfaces\CollectionInterface; use LongitudeOne\SpatialTypes\Interfaces\LineStringInterface; use LongitudeOne\SpatialTypes\Interfaces\MultiLineStringInterface; use LongitudeOne\SpatialTypes\Interfaces\MultiPointInterface; @@ -40,11 +41,12 @@ final class GeometryEncoder * * @param SpatialInterface $spatial the geometry to encode * - * @return array{type: string, coordinates: array} + * @return array{type: string, coordinates?: array, geometries?: list>} */ public function encode(SpatialInterface $spatial): array { return match ($spatial->getType()) { + GeometryTypeEnum::GEOMETRYCOLLECTION => $this->encodeCollection($spatial), GeometryTypeEnum::POINT => $this->encodePoint($spatial), GeometryTypeEnum::LINESTRING => $this->encodeLineString($spatial), GeometryTypeEnum::MULTIPOINT => $this->encodeMultiPoint($spatial), @@ -55,6 +57,27 @@ public function encode(SpatialInterface $spatial): array }; } + /** + * Recursively retain every member, including nested and EMPTY geometries. + * + * @param SpatialInterface $spatial the collection to encode + * + * @return array{type: string, geometries: list>} + */ + private function encodeCollection(SpatialInterface $spatial): array + { + if (!$spatial instanceof CollectionInterface) { + throw new UnsupportedSpatialInterfaceException($spatial::class); + } + + $geometries = []; + foreach ($spatial->getElements() as $element) { + $geometries[] = $this->encode($element); + } + + return ['type' => 'GeometryCollection', 'geometries' => $geometries]; + } + /** * Preserve a line's positions, allowing EMPTY but rejecting singleton lines. * diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/ExceptionContractTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/ExceptionContractTest.php index fc30f4e..186bf85 100644 --- a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/ExceptionContractTest.php +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/ExceptionContractTest.php @@ -18,6 +18,7 @@ use LongitudeOne\Core\Enum\GeometryTypeEnum; use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\GeometryCollection; use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineString; use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiLineString; use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiPoint; @@ -51,6 +52,20 @@ #[CoversClass(Writer::class)] class ExceptionContractTest extends TestCase { + /** A nested real member retains the original JSON exception. */ + public function testCollectionEncodingFailure(): void + { + $collection = new GeometryCollection(0, [new Point(1, 2), new GeometryCollection(0, [new Point(INF, 3)])]); + + try { + (new Writer(new GeoJsonStrategy()))->convert($collection); + static::fail('JSON encoding must fail.'); + } catch (JsonEncodingException $exception) { + static::assertInstanceOf(\JsonException::class, $exception->getPrevious()); + static::assertSame(JSON_ERROR_INF_OR_NAN, $exception->getPrevious()->getCode()); + } + } + /** The installed model accepts this float through its ordinary constructor. */ public function testInfinityEncodingFailure(): void { @@ -181,6 +196,17 @@ public function testPolygonEncodingFailure(): void } } + /** Preserve the internal exception for a missing collection interface. */ + public function testUnsupportedCollectionInterfaceThroughWriter(): void + { + $spatial = static::createStub(SpatialInterface::class); + $spatial->method('getType')->willReturn(GeometryTypeEnum::GEOMETRYCOLLECTION); + $spatial->method('hasM')->willReturn(false); + + $this->expectException(UnsupportedSpatialInterfaceException::class); + (new Writer(new GeoJsonStrategy()))->convert($spatial); + } + /** Preserve the approved public exception inheritance and constructor contract. */ public function testUnsupportedDimensionExceptionContract(): void { diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyCollectionTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyCollectionTest.php new file mode 100644 index 0000000..3d59506 --- /dev/null +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeographyCollectionTest.php @@ -0,0 +1,190 @@ + 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\SpatialWriter\Tests\Unit\Strategy\GeoJson; + +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\GeographyCollection; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\MultiLineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\MultiPoint; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\MultiPolygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\Point; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\Polygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geography\Triangle; +use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; +use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialTypeException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; +use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; +use LongitudeOne\SpatialWriter\Writer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +/** + * Explicit real Geography collection composition examples. + * + * @internal + */ +#[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] +#[CoversClass(Writer::class)] +class GeographyCollectionTest extends TestCase +{ + /** Preserve the complete collection structure and values. */ + public function testAllMemberTypes(): void + { + $collection = new GeographyCollection(0, [new Point(1, 2), new LineString([[3, 4], [5, 6]]), new MultiPoint([[7, 8], [9, 10]]), new MultiLineString([[[11, 12], [13, 14]]]), new Polygon([[[0, 0], [2, 0], [0, 2], [0, 0]]]), new MultiPolygon([[[[3, 0], [5, 0], [3, 2], [3, 0]]]])]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]},{"type":"MultiPoint","coordinates":[[7,8],[9,10]]},{"type":"MultiLineString","coordinates":[[[11,12],[13,14]]]},{"type":"Polygon","coordinates":[[[0,0],[2,0],[0,2],[0,0]]]},{"type":"MultiPolygon","coordinates":[[[[3,0],[5,0],[3,2],[3,0]]]]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]},{"type":"MultiPoint","coordinates":[[7,8],[9,10]]},{"type":"MultiLineString","coordinates":[[[11,12],[13,14]]]},{"type":"Polygon","coordinates":[[[0,0],[2,0],[0,2],[0,0]]]},{"type":"MultiPolygon","coordinates":[[[[3,0],[5,0],[3,2],[3,0]]]]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testAntimeridian(): void + { + $collection = new GeographyCollection(0, [new LineString([[170, 45], [-170, 45]])]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"LineString","coordinates":[[170,45],[-170,45]]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"LineString","coordinates":[[170,45],[-170,45]]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testEmptyXy(): void + { + $collection = new GeographyCollection(0, []); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testEmptyXyz(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\GeographyCollection(0, []); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testHomogeneous(): void + { + $collection = new GeographyCollection(0, [new Point(3, 4), new Point(1, 2), new Point(3, 4)]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[3,4]},{"type":"Point","coordinates":[1,2]},{"type":"Point","coordinates":[3,4]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[3,4]},{"type":"Point","coordinates":[1,2]},{"type":"Point","coordinates":[3,4]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testNestedEmpty(): void + { + $collection = new GeographyCollection(0, [new GeographyCollection(0, [new Point()]), new GeographyCollection(0, [])]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[]}]},{"type":"GeometryCollection","geometries":[]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[]}]},{"type":"GeometryCollection","geometries":[]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectClockwisePolygon(): void + { + $collection = new GeographyCollection(0, [new Point(1, 2), new Polygon([[[0, 0], [0, 2], [2, 0], [0, 0]]])]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectDimension3m(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\GeographyCollection(0, [new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\Point(1, 2, 3)]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectDimension4zm(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\GeographyCollection(0, [new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\Point(1, 2, 3, 4)]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectEmptyDimension3m(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geography\GeographyCollection(0, []); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectEmptyDimension4zm(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geography\GeographyCollection(0, []); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectEmptyMultiPointMember(): void + { + $collection = new GeographyCollection(0, [new Point(1, 2), new MultiPoint([new Point()])]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectNestedSingletonLine(): void + { + $collection = new GeographyCollection(0, [new Point(1, 2), new GeographyCollection(0, [new LineString([[3, 4]])])]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectNestedTriangle(): void + { + $collection = new GeographyCollection(0, [new Point(1, 2), new GeographyCollection(0, [new Triangle([[[0, 0], [2, 0], [0, 2], [0, 0]]])])]); + + $this->expectException(UnsupportedSpatialTypeException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Preserve the complete collection structure and values. */ + public function testXy(): void + { + $collection = new GeographyCollection(4326, [new Point(1, 2, 4326), new LineString([[3, 4], [5, 6]], 4326)]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testXyzSingleton(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\GeographyCollection(new SpatialReference(999999, 'CUSTOM'), [new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geography\Point(1, 2, 3, new SpatialReference(999999, 'CUSTOM'))]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2,3]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2,3]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } +} diff --git a/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryCollectionTest.php b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryCollectionTest.php new file mode 100644 index 0000000..3238d14 --- /dev/null +++ b/tests/LongitudeOne/SpatialWriter/Tests/Unit/Strategy/GeoJson/GeometryCollectionTest.php @@ -0,0 +1,190 @@ + 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\SpatialWriter\Tests\Unit\Strategy\GeoJson; + +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\GeometryCollection; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiLineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiPoint; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiPolygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Point; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Polygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\Triangle; +use LongitudeOne\SpatialWriter\Exception\UnsupportedDimensionException; +use LongitudeOne\SpatialWriter\Exception\UnsupportedGeometryStructureException; +use LongitudeOne\SpatialWriter\Exception\UnsupportedSpatialTypeException; +use LongitudeOne\SpatialWriter\Strategy\GeoJson\GeometryEncoder; +use LongitudeOne\SpatialWriter\Strategy\GeoJsonStrategy; +use LongitudeOne\SpatialWriter\Writer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +/** + * Explicit real Geometry collection composition examples. + * + * @internal + */ +#[CoversClass(GeoJsonStrategy::class)] +#[CoversClass(GeometryEncoder::class)] +#[CoversClass(Writer::class)] +class GeometryCollectionTest extends TestCase +{ + /** Preserve the complete collection structure and values. */ + public function testAllMemberTypes(): void + { + $collection = new GeometryCollection(0, [new Point(1, 2), new LineString([[3, 4], [5, 6]]), new MultiPoint([[7, 8], [9, 10]]), new MultiLineString([[[11, 12], [13, 14]]]), new Polygon([[[0, 0], [2, 0], [0, 2], [0, 0]]]), new MultiPolygon([[[[3, 0], [5, 0], [3, 2], [3, 0]]]])]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]},{"type":"MultiPoint","coordinates":[[7,8],[9,10]]},{"type":"MultiLineString","coordinates":[[[11,12],[13,14]]]},{"type":"Polygon","coordinates":[[[0,0],[2,0],[0,2],[0,0]]]},{"type":"MultiPolygon","coordinates":[[[[3,0],[5,0],[3,2],[3,0]]]]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]},{"type":"MultiPoint","coordinates":[[7,8],[9,10]]},{"type":"MultiLineString","coordinates":[[[11,12],[13,14]]]},{"type":"Polygon","coordinates":[[[0,0],[2,0],[0,2],[0,0]]]},{"type":"MultiPolygon","coordinates":[[[[3,0],[5,0],[3,2],[3,0]]]]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testAntimeridian(): void + { + $collection = new GeometryCollection(0, [new LineString([[170, 45], [-170, 45]])]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"LineString","coordinates":[[170,45],[-170,45]]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"LineString","coordinates":[[170,45],[-170,45]]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testEmptyXy(): void + { + $collection = new GeometryCollection(0, []); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testEmptyXyz(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\GeometryCollection(0, []); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testHomogeneous(): void + { + $collection = new GeometryCollection(0, [new Point(3, 4), new Point(1, 2), new Point(3, 4)]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[3,4]},{"type":"Point","coordinates":[1,2]},{"type":"Point","coordinates":[3,4]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[3,4]},{"type":"Point","coordinates":[1,2]},{"type":"Point","coordinates":[3,4]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testNestedEmpty(): void + { + $collection = new GeometryCollection(0, [new GeometryCollection(0, [new Point()]), new GeometryCollection(0, [])]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[]}]},{"type":"GeometryCollection","geometries":[]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[]}]},{"type":"GeometryCollection","geometries":[]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectClockwisePolygon(): void + { + $collection = new GeometryCollection(0, [new Point(1, 2), new Polygon([[[0, 0], [0, 2], [2, 0], [0, 0]]])]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectDimension3m(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\GeometryCollection(0, [new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\Point(1, 2, 3)]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectDimension4zm(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\GeometryCollection(0, [new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\Point(1, 2, 3, 4)]); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectEmptyDimension3m(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Geometry\GeometryCollection(0, []); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectEmptyDimension4zm(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\Geometry\GeometryCollection(0, []); + + $this->expectException(UnsupportedDimensionException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectEmptyMultiPointMember(): void + { + $collection = new GeometryCollection(0, [new Point(1, 2), new MultiPoint([new Point()])]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectNestedSingletonLine(): void + { + $collection = new GeometryCollection(0, [new Point(1, 2), new GeometryCollection(0, [new LineString([[3, 4]])])]); + + $this->expectException(UnsupportedGeometryStructureException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Propagate failure without returning a partial collection. */ + public function testRejectNestedTriangle(): void + { + $collection = new GeometryCollection(0, [new Point(1, 2), new GeometryCollection(0, [new Triangle([[[0, 0], [2, 0], [0, 2], [0, 0]]])])]); + + $this->expectException(UnsupportedSpatialTypeException::class); + (new Writer(new GeoJsonStrategy()))->convert($collection); + } + + /** Preserve the complete collection structure and values. */ + public function testXy(): void + { + $collection = new GeometryCollection(4326, [new Point(1, 2, 4326), new LineString([[3, 4], [5, 6]], 4326)]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]},{"type":"LineString","coordinates":[[3,4],[5,6]]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } + + /** Preserve the complete collection structure and values. */ + public function testXyzSingleton(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\GeometryCollection(new SpatialReference(999999, 'CUSTOM'), [new \LongitudeOne\SpatialTypes\Types\Dimension3z\Geometry\Point(1, 2, 3, new SpatialReference(999999, 'CUSTOM'))]); + + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2,3]}]}', (new Writer(new GeoJsonStrategy()))->convert($collection)); + static::assertJsonStringEqualsJsonString('{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2,3]}]}', (new GeoJsonStrategy())->executeStrategy($collection)); + } +} diff --git a/tests/README.md b/tests/README.md index c508b94..cae9d15 100644 --- a/tests/README.md +++ b/tests/README.md @@ -203,3 +203,11 @@ aggregates, incompatible exterior/interior winding and zero signed area. Measure XYM/XYZM aggregates are rejected including EMPTY. The shared exception tests cover a missing MultiPolygon interface and a real non-finite altitude with the original JSON exception preserved. + +GeometryCollection and GeographyCollection examples cover all six integrated +member types, XY/XYZ, EMPTY collections and nested EMPTY members, singleton and +homogeneous preservation, member order, Z, reference omission and antimeridian +preservation. Real nested Triangle and incompatible members verify failure +propagation; XYM/XYZM are rejected including EMPTY. The exception tests cover a +missing collection interface and a nested non-finite Point using its ordinary +constructor, preserving the original JSON exception.