From beae74a8c35df6ed1665a0f281acd3cfbd7247e1 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 1 May 2024 17:50:27 +0100 Subject: [PATCH] add hypothesis tests for functions --- pygeoif/factories.py | 7 +- pygeoif/functions.py | 17 +- pygeoif/geometry.py | 11 +- pygeoif/types.py | 12 +- pyproject.toml | 1 + tests/hypothesis/test_functions.py | 309 +++++++++++++++++++++++++++++ tests/test_functions.py | 3 +- tests/test_line.py | 6 + tests/test_multipoint.py | 6 + 9 files changed, 354 insertions(+), 18 deletions(-) create mode 100644 tests/hypothesis/test_functions.py diff --git a/pygeoif/factories.py b/pygeoif/factories.py index 660f722..c139434 100644 --- a/pygeoif/factories.py +++ b/pygeoif/factories.py @@ -150,15 +150,12 @@ def shape( ) raise TypeError(msg) - constructor = type_map.get(geometry["type"]) - if constructor: + if constructor := type_map.get(geometry["type"]): return constructor._from_dict( # type: ignore [attr-defined, no-any-return] geometry, ) if geometry["type"] == "GeometryCollection": - geometries = [ - shape(fi) for fi in geometry["geometries"] # type: ignore [typeddict-item] - ] + geometries = [shape(fi) for fi in geometry["geometries"]] return GeometryCollection(geometries) msg = f"[{geometry['type']} is not implemented" raise NotImplementedError(msg) diff --git a/pygeoif/functions.py b/pygeoif/functions.py index 276dd6a..0e12b2c 100644 --- a/pygeoif/functions.py +++ b/pygeoif/functions.py @@ -41,6 +41,8 @@ def signed_area(coords: LineType) -> float: Linear time algorithm: http://www.cgafaq.info/wiki/Polygon_Area. A value >= 0 indicates a counter-clockwise oriented ring. """ + if len(coords) < 3: # noqa: PLR2004 + return 0.0 xs, ys = map(list, zip(*(coord[:2] for coord in coords))) xs.append(xs[1]) # pragma: no mutate ys.append(ys[1]) # pragma: no mutate @@ -53,7 +55,6 @@ def signed_area(coords: LineType) -> float: def centroid(coords: LineType) -> Tuple[Point2D, float]: """Calculate the coordinates of the centroid and the area of a LineString.""" ans: List[float] = [0, 0] - n = len(coords) signed_area = 0.0 @@ -68,6 +69,9 @@ def centroid(coords: LineType) -> Tuple[Point2D, float]: ans[0] += (coord[0] + next_coord[0]) * area ans[1] += (coord[1] + next_coord[1]) * area + if signed_area == 0 or math.isnan(signed_area): + return ((math.nan, math.nan), signed_area) + ans[0] = ans[0] / (3 * signed_area) ans[1] = ans[1] / (3 * signed_area) @@ -167,13 +171,13 @@ def compare_geo_interface( return all( compare_geo_interface(first=g1, second=g2) # type: ignore [arg-type] for g1, g2 in zip_longest( - first["geometries"], # type: ignore [typeddict-item] + first["geometries"], second["geometries"], # type: ignore [typeddict-item] fillvalue={"type": None, "coordinates": ()}, ) ) return compare_coordinates( - coords=first["coordinates"], # type: ignore [typeddict-item] + coords=first["coordinates"], other=second["coordinates"], # type: ignore [typeddict-item] ) except KeyError: @@ -220,6 +224,8 @@ def move_coordinates( >>> move_coordinates(((0, 0), (-1, 1)), (-1, 1, 0)) ((-1, 1, 0), (-2, 2, 0)) """ + if not coordinates: + return coordinates if isinstance(coordinates[0], (int, float)): return move_coordinate(cast(PointType, coordinates), move_by) return cast( @@ -237,14 +243,13 @@ def move_geo_interface( return { "type": "GeometryCollection", "geometries": tuple( - move_geo_interface(g, move_by) - for g in interface["geometries"] # type: ignore [typeddict-item] + move_geo_interface(g, move_by) for g in interface["geometries"] ), } return { "type": interface["type"], "coordinates": move_coordinates( - interface["coordinates"], # type: ignore [typeddict-item, arg-type] + interface["coordinates"], # type: ignore [arg-type] move_by, ), } diff --git a/pygeoif/geometry.py b/pygeoif/geometry.py index 688dc86..4d8d82a 100644 --- a/pygeoif/geometry.py +++ b/pygeoif/geometry.py @@ -41,6 +41,7 @@ from pygeoif.types import Bounds from pygeoif.types import GeoCollectionInterface from pygeoif.types import GeoInterface +from pygeoif.types import GeomType from pygeoif.types import GeoType from pygeoif.types import LineType from pygeoif.types import Point2D @@ -166,7 +167,7 @@ def __geo_interface__(self) -> GeoInterface: msg = "Empty Geometry" raise AttributeError(msg) return { - "type": self.geom_type, + "type": cast(GeomType, self.geom_type), "bbox": cast(Bounds, self.bounds), "coordinates": (), } @@ -481,9 +482,9 @@ def centroid(self) -> Optional[Point]: if self.has_z: msg = "Centeroid is only implemented for 2D coordinates" raise DimensionError(msg) - try: - cent, area = centroid(self.coords) - except ZeroDivisionError: + + cent, area = centroid(self.coords) + if any(math.isnan(coord) for coord in cent): return None return ( Point(x=cent[0], y=cent[1]) @@ -625,6 +626,8 @@ def from_linear_rings(cls, shell: LinearRing, *args: LinearRing) -> "Polygon": @classmethod def _from_dict(cls, geo_interface: GeoInterface) -> "Polygon": cls._check_dict(geo_interface) + if not geo_interface["coordinates"]: + return cls(shell=(), holes=()) return cls( shell=cast(LineType, geo_interface["coordinates"][0]), holes=cast(Tuple[LineType], geo_interface["coordinates"][1:]), diff --git a/pygeoif/types.py b/pygeoif/types.py index f12ec89..c933a1b 100644 --- a/pygeoif/types.py +++ b/pygeoif/types.py @@ -57,11 +57,21 @@ ] MultiCoordinatesType = Sequence[CoordinatesType] +GeomType = Literal[ + "Point", + "LineString", + "LinearRing", + "Polygon", + "MultiPoint", + "MultiLineString", + "MultiPolygon", +] + class GeoInterface(TypedDict): """Required keys for the GeoInterface.""" - type: str + type: GeomType coordinates: Union[CoordinatesType, MultiCoordinatesType] bbox: NotRequired[Bounds] diff --git a/pyproject.toml b/pyproject.toml index 17a3994..69daa01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ linting = [ ] tests = [ "hypothesis", + "more_itertools", "pytest", "pytest-cov", ] diff --git a/tests/hypothesis/test_functions.py b/tests/hypothesis/test_functions.py new file mode 100644 index 0000000..6a7ce69 --- /dev/null +++ b/tests/hypothesis/test_functions.py @@ -0,0 +1,309 @@ +"""Hypothesis test cases for the `pygeoif.functions` module.""" + +# This test code was written by the `hypothesis.extra.ghostwriter` module +# and is provided under the Creative Commons Zero public domain dedication. + +import math +import typing + +import more_itertools +from hypothesis import given +from hypothesis import strategies as st + +import pygeoif.functions +import pygeoif.types + + +@given( + coords=st.one_of( + st.lists( + st.tuples( + st.floats(allow_subnormal=False), + st.floats(allow_subnormal=False), + ), + ), + st.lists( + st.tuples( + st.floats(allow_subnormal=False), + st.floats(allow_subnormal=False), + st.floats(allow_subnormal=False), + ), + ), + ), +) +def test_fuzz_centroid( + coords: typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ], +) -> None: + center, area = pygeoif.functions.centroid(coords=coords) + if area == 0 or math.isnan(area): + assert math.isnan(center[0]) + assert math.isnan(center[1]) + else: + assert isinstance(center[0], float) + assert isinstance(center[1], float) + assert len(center) == 2 + + +@given( + coords=st.one_of( + st.floats(), + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + other=st.one_of( + st.floats(), + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), +) +def test_fuzz_compare_coordinates( + coords: typing.Union[ + float, + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + typing.Sequence[ + typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + ] + ], + ], + other: typing.Union[ + float, + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + typing.Sequence[ + typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + ] + ], + ], +) -> None: + assert isinstance( + pygeoif.functions.compare_coordinates(coords=coords, other=other), + bool, + ) + + flat_coords = ( + [coords] if isinstance(coords, float) else more_itertools.collapse(coords) + ) + flat_other = [other] if isinstance(other, float) else more_itertools.collapse(other) + + if any(math.isnan(c) for c in flat_coords): + assert not pygeoif.functions.compare_coordinates(coords=coords, other=coords) + else: + assert pygeoif.functions.compare_coordinates(coords=coords, other=coords) + if any(math.isnan(c) for c in flat_other): + assert not pygeoif.functions.compare_coordinates(coords=other, other=other) + else: + assert pygeoif.functions.compare_coordinates(coords=other, other=other) + + +@given( + first=st.from_type(pygeoif.types.GeoInterface), + second=st.from_type(pygeoif.types.GeoInterface), +) +def test_fuzz_compare_geo_interface( + first: pygeoif.types.GeoInterface, + second: pygeoif.types.GeoInterface, +) -> None: + assert isinstance( + pygeoif.functions.compare_geo_interface(first=first, second=second), + bool, + ) + + +@given( + points=st.lists( + st.tuples( + st.floats(allow_nan=False, allow_infinity=False, allow_subnormal=False), + st.floats(allow_nan=False, allow_infinity=False, allow_subnormal=False), + ), + ), +) +def test_fuzz_convex_hull(points: typing.List[typing.Tuple[float, float]]) -> None: + hull = pygeoif.functions.convex_hull(points=points) + + for coord in hull: + assert coord in points + assert len(hull) <= len(points) + 1 + + +@given( + coords=st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), +) +def test_fuzz_dedupe( + coords: typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ], +) -> None: + deduped = pygeoif.functions.dedupe(coords=coords) + + assert len(deduped) <= len(coords) + for coord in deduped: + assert coord in coords + + +@given( + coordinate=st.one_of( + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + move_by=st.one_of( + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), +) +def test_fuzz_move_coordinate( + coordinate: typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + ], + move_by: typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + ], +) -> None: + moved = pygeoif.functions.move_coordinate(coordinate=coordinate, move_by=move_by) + + assert len(moved) == len(move_by) + + +@given( + coordinates=st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + move_by=st.one_of( + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), +) +def test_fuzz_move_coordinates( + coordinates: typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + ], + move_by: typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + ], +) -> None: + moved = pygeoif.functions.move_coordinates(coordinates=coordinates, move_by=move_by) + + assert moved if coordinates else not moved + + +@given( + coords=st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), +) +def test_fuzz_signed_area( + coords: typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ], +) -> None: + assert isinstance(pygeoif.functions.signed_area(coords=coords), float) diff --git a/tests/test_functions.py b/tests/test_functions.py index b56f83f..13ddf64 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -72,8 +72,7 @@ def test_signed_area2() -> None: def test_centroid_line() -> None: a0 = [(0, 0), (1, 1), (0, 0)] - with pytest.raises(ZeroDivisionError): - assert centroid(a0) + assert centroid(a0) == ((math.nan, math.nan), 0) def test_signed_area_0_3d() -> None: diff --git a/tests/test_line.py b/tests/test_line.py index 1c69aab..777c90e 100644 --- a/tests/test_line.py +++ b/tests/test_line.py @@ -232,3 +232,9 @@ def test_empty_coords() -> None: line = geometry.LineString([]) assert line.coords == () + + +def test_empty_coords_nan() -> None: + line = geometry.LineString(((math.nan, math.nan),)) + + assert line.coords == () diff --git a/tests/test_multipoint.py b/tests/test_multipoint.py index 7590775..3cdde6a 100644 --- a/tests/test_multipoint.py +++ b/tests/test_multipoint.py @@ -167,3 +167,9 @@ def test_empty_bounds() -> None: multipoint = geometry.MultiPoint([(None, None)]) assert multipoint.bounds == () + + +def test_empty_geoms() -> None: + multipoint = geometry.MultiPoint([(math.nan, math.nan)]) + + assert not list(multipoint.geoms)