"""Tests that the geometry base classes in :mod:`pyrate.plan.geometry.geospatial` work correctly.""" # Python standard from copy import copy from copy import deepcopy from json import loads # Typing from typing import Any from typing import Sequence from typing import Tuple # Generic testing from unittest import TestCase # Hypothesis testing from hypothesis import given from hypothesis import HealthCheck from hypothesis import Phase from hypothesis import settings import hypothesis.strategies as st # Package under test from pyrate.plan.geometry import CartesianLocation from pyrate.plan.geometry import CartesianPolygon from pyrate.plan.geometry import CartesianRoute from pyrate.plan.geometry import Direction from pyrate.plan.geometry import Geospatial from pyrate.plan.geometry import PolarLocation from pyrate.plan.geometry import PolarPolygon from pyrate.plan.geometry import PolarRoute # Hypothesis testing from pyrate.common.testing.strategies.geometry import geospatial_objects _CARTESIAN_LOCATION_1 = CartesianLocation(5003.0, 139.231) _CARTESIAN_LOCATION_2 = CartesianLocation(600.1, 139.231) _POLAR_LOCATION_1 = PolarLocation(65.01, -180.0) _POLAR_LOCATION_2 = PolarLocation(-80.3, -180.0) class TestStringRepresentations(TestCase): """Makes sure that the string conversion with ``__str__`` and ``__repr__`` works.""" _GROUND_TRUTH: Sequence[Tuple[Geospatial, str]] = [ ( _CARTESIAN_LOCATION_1, "CartesianLocation(east=5003.0, north=139.231)", ), ( PolarLocation(65.01, -180.0), "PolarLocation(latitude=65.00999999999999, longitude=-180.0)", ), ( CartesianPolygon([_CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_1]), "CartesianPolygon(locations=[(5003.0, 139.231), (5003.0, 139.231), (5003.0, 139.231), " "(5003.0, 139.231)])", ), ( PolarPolygon([_POLAR_LOCATION_1, _POLAR_LOCATION_1, _POLAR_LOCATION_1]), "PolarPolygon(locations=[PolarLocation(latitude=65.00999999999999, longitude=-180.0), " "PolarLocation(latitude=65.00999999999999, longitude=-180.0), " "PolarLocation(latitude=65.00999999999999, longitude=-180.0)])", ), ( CartesianRoute([_CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_2, _CARTESIAN_LOCATION_1]), "CartesianRoute(locations=[(5003.0, 139.231), (600.1, 139.231), (5003.0, 139.231)])", ), ( PolarRoute([_POLAR_LOCATION_1, _POLAR_LOCATION_2, _POLAR_LOCATION_1, _POLAR_LOCATION_1]), "PolarRoute(locations=[PolarLocation(latitude=65.00999999999999, longitude=-180.0), " "PolarLocation(latitude=-80.3, longitude=-180.0), " "PolarLocation(latitude=65.00999999999999, longitude=-180.0), " "PolarLocation(latitude=65.00999999999999, longitude=-180.0)])", ), ] def test_conversions(self) -> None: """Makes sure that all given geospatial objects can be converted.""" for geospatial, desired_str in TestStringRepresentations._GROUND_TRUTH: with self.subTest(f"{type(geospatial)}.__str__"): self.assertEqual(str(geospatial), desired_str) with self.subTest(f"{type(geospatial)}.__repr__"): self.assertEqual(repr(geospatial), desired_str) class TestGeoJsonRepresentations(TestCase): """Makes sure that the conversion to GeoJSON via the common property ``__geo_interface__`` works.""" _GROUND_TRUTH: Sequence[Tuple[Geospatial, str]] = [ ( _CARTESIAN_LOCATION_1, '{"type": "Feature", "geometry": {"type": "Point", ' '"coordinates": [5003.0, 139.231]}, "properties": {}}', ), ( PolarLocation(65.01, -180.0), '{"type": "Feature", "geometry": {"type": "Point", "coordinates": [-180.0, 65.01]}, ' '"properties": {}}', ), ( CartesianPolygon([_CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_1]), '{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": ' "[[[5003.0, 139.231], [5003.0, 139.231], [5003.0, 139.231], [5003.0, 139.231]]]}, " '"properties": {}}', ), ( PolarPolygon([_POLAR_LOCATION_1, _POLAR_LOCATION_1, _POLAR_LOCATION_1]), '{"type": "Feature", "geometry": {"type": "Polygon", ' '"coordinates": [[[-180.0, 65.01], [-180.0, 65.01], [-180.0, 65.01]]]}, "properties": {}}', ), ( CartesianRoute([_CARTESIAN_LOCATION_1, _CARTESIAN_LOCATION_2, _CARTESIAN_LOCATION_1]), '{"type": "Feature", "geometry": {"type": "LineString", "coordinates": ' '[[5003.0, 139.231], [600.1, 139.231], [5003.0, 139.231]]}, "properties": {}}', ), ( PolarRoute([_POLAR_LOCATION_1, _POLAR_LOCATION_2, _POLAR_LOCATION_1]), '{"type": "Feature", "geometry": {"type": "LineString", "coordinates": ' '[[-180.0, 65.01], [-180.0, -80.3], [-180.0, 65.01]]}, "properties": {}}', ), ] def test_conversions(self) -> None: """Makes sure that all given geospatial objects can be converted.""" for geospatial, desired_geojson in TestGeoJsonRepresentations._GROUND_TRUTH: for indent in (None, 1, 8): with self.subTest(f"{type(geospatial)} with indent={indent}"): geojson = geospatial.to_geo_json(indent=indent) # load as JSON get get better error messages and become whitespace independent self.assertDictEqual(loads(geojson), loads(desired_geojson)) class TestIdentifiers(TestCase): """Makes sure that identifiers are validated correctly. The test is only performed on polar locations for simplicity and because validation is handled in the abstract common parent class :class:`pyrate.plan.geometry.Geospatial` anyway. """ @given(st.integers(min_value=0, max_value=(2**63) - 1)) def test_on_locations_success(self, integer: int) -> None: # pylint: disable=no-self-use """Tests that valid identifiers are accepted.""" PolarLocation(latitude=0.0, longitude=0.0, identifier=integer) @given( st.one_of( st.integers(max_value=-1), # negative numbers st.integers(min_value=2**63), # very large numbers ) ) def test_on_locations_rejected(self, integer: int) -> None: """Tests that invalid identifiers are rejected.""" with self.assertRaises(AssertionError): PolarLocation(latitude=0.0, longitude=0.0, identifier=integer) class TestEqualityMethods(TestCase): """Test the various equality methods.""" @given(geospatial_objects(stable=True)) @settings( max_examples=200, suppress_health_check=(HealthCheck.data_too_large,), phases=(Phase.explicit, Phase.reuse, Phase.generate, Phase.target), # Do not shrink as it takes long ) def test_equality_after_translation(self, geospatial: Any) -> None: """Tests that translated objects are only equal under sufficient tolerance.""" # We discard the second output since it differs between cartesian and polar objects translated, _ = geospatial.translate(direction=Direction.North, distance=0.5) # Try since generated primitives might cause an exception to be thrown # e.g. if projected routes become length 0 try: # They should not be equal self.assertNotEqual(geospatial, translated) self.assertFalse(geospatial.equals(translated)) self.assertFalse(geospatial.equals_exact(translated, tolerance=0.0)) if hasattr(geospatial, "equals_almost_congruent"): self.assertFalse( geospatial.equals_almost_congruent(translated, abs_tolerance=0.0, rel_tolerance=0.0) ) # They should be equal within some tolerance (the tolerance needs to be large for the polar # variants) # TODO(Someone): re-enable; see #114 # self.assertTrue(geospatial.equals_exact(translated, tolerance=5)) # self.assertTrue(geospatial.almost_equals(translated, decimal=-1)) # We do not use `equals_almost_congruent` as it is not a per-coordinate difference and might cause # a very large symmetric difference on very large objects except ValueError: pass class TestCopyAndDeepcopy(TestCase): """Tests that all geometric objects can be deep-copied.""" @given(geospatial_objects()) @settings(max_examples=500) def test_is_copyable(self, geospatial: Any) -> None: """Tests that copies can be made and are equal to the original.""" # Check copy copied = copy(geospatial) self.assertEqual(geospatial, copied) # Check deepcopy deep_copied = deepcopy(geospatial) self.assertEqual(geospatial, deep_copied)