diff --git a/setup.cfg b/setup.cfg
index 47924d4..f00e6f7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -144,6 +144,9 @@ per-file-ignores =
src/urban_meal_delivery/console/forecasts.py:
# The module is not too complex.
WPS232,
+ src/urban_meal_delivery/db/addresses_addresses.py:
+ # The module does not have too many imports.
+ WPS201,
src/urban_meal_delivery/db/customers.py:
# The module is not too complex.
WPS232,
@@ -217,6 +220,7 @@ allowed-domain-names =
obj,
param,
result,
+ results,
value,
max-name-length = 40
# darglint
@@ -267,12 +271,18 @@ cache_dir = .cache/mypy
[mypy-folium.*]
ignore_missing_imports = true
+[mypy-geopy.*]
+ignore_missing_imports = true
+[mypy-googlemaps.*]
+ignore_missing_imports = true
[mypy-matplotlib.*]
ignore_missing_imports = true
[mypy-nox.*]
ignore_missing_imports = true
[mypy-numpy.*]
ignore_missing_imports = true
+[mypy-ordered_set.*]
+ignore_missing_imports = true
[mypy-packaging]
ignore_missing_imports = true
[mypy-pandas]
diff --git a/src/urban_meal_delivery/configuration.py b/src/urban_meal_delivery/configuration.py
index e303523..5d1a5c3 100644
--- a/src/urban_meal_delivery/configuration.py
+++ b/src/urban_meal_delivery/configuration.py
@@ -64,6 +64,7 @@ class Config:
# --------------------------------
DATABASE_URI = os.getenv('DATABASE_URI')
+ GOOGLE_MAPS_API_KEY = os.getenv('GOOGLE_MAPS_API_KEY')
# The PostgreSQL schema that holds the tables with the original data.
ORIGINAL_SCHEMA = os.getenv('ORIGINAL_SCHEMA') or 'public'
diff --git a/src/urban_meal_delivery/db/addresses_addresses.py b/src/urban_meal_delivery/db/addresses_addresses.py
index de175b4..7a997ef 100644
--- a/src/urban_meal_delivery/db/addresses_addresses.py
+++ b/src/urban_meal_delivery/db/addresses_addresses.py
@@ -1,12 +1,20 @@
"""Model for the relationship between two `Address` objects (= distance matrix)."""
+from __future__ import annotations
+
+import itertools
import json
from typing import List
+import googlemaps as gm
+import ordered_set
import sqlalchemy as sa
+from geopy import distance as geo_distance
from sqlalchemy import orm
from sqlalchemy.dialects import postgresql
+from urban_meal_delivery import config
+from urban_meal_delivery import db
from urban_meal_delivery.db import meta
from urban_meal_delivery.db import utils
@@ -92,6 +100,118 @@ class DistanceMatrix(meta.Base):
# We do not implement a `.__init__()` method and leave that to SQLAlchemy.
# Instead, we use `hasattr()` to check for uninitialized attributes. grep:86ffc14e
+ @classmethod
+ def from_addresses(
+ cls, *addresses: db.Address, google_maps: bool = False,
+ ) -> List[DistanceMatrix]:
+ """Calculate pair-wise distances for `Address` objects.
+
+ This is the main constructor method for the class.
+
+ It handles the "sorting" of the `Address` objects by `.id`, which is
+ the logic that enforces the symmetric graph behind the distances.
+
+ Args:
+ *addresses: to calculate the pair-wise distances for;
+ must contain at least two `Address` objects
+ google_maps: if `.bicylce_distance` and `.directions` should be
+ populated with a query to the Google Maps Directions API;
+ by default, only the `.air_distance` is calculated with `geopy`
+
+ Returns:
+ distances
+ """
+ distances = []
+
+ # We consider all 2-tuples of `Address`es. The symmetric graph is ...
+ for first, second in itertools.combinations(addresses, 2):
+ # ... implicitly enforced by a precedence constraint for the `.id`s.
+ first, second = ( # noqa:WPS211
+ (first, second) if first.id < second.id else (second, first)
+ )
+
+ # If there is no `DistaneMatrix` object in the database ...
+ distance = ( # noqa:ECE001
+ db.session.query(db.DistanceMatrix)
+ .filter(db.DistanceMatrix.first_address == first)
+ .filter(db.DistanceMatrix.second_address == second)
+ .first()
+ )
+ # ... create a new one.
+ if distance is None:
+ air_distance = geo_distance.great_circle(
+ first.location.lat_lng, second.location.lat_lng,
+ )
+
+ distance = cls(
+ first_address=first,
+ second_address=second,
+ air_distance=round(air_distance.meters),
+ )
+
+ db.session.add(distance)
+ db.session.commit()
+
+ distances.append(distance)
+
+ if google_maps:
+ for distance in distances: # noqa:WPS440
+ distance.sync_with_google_maps()
+
+ return distances
+
+ def sync_with_google_maps(self) -> None:
+ """Fill in `.bicycle_distance` and `.directions` with Google Maps.
+
+ `.directions` will not contain the coordinates of `.first_address` and
+ `.second_address`.
+
+ This uses the Google Maps Directions API.
+
+ Further info:
+ https://developers.google.com/maps/documentation/directions
+ """
+ # To save costs, we do not make an API call
+ # if we already have data from Google Maps.
+ if self.bicycle_distance is not None:
+ return
+
+ client = gm.Client(config.GOOGLE_MAPS_API_KEY)
+ response = client.directions(
+ origin=self.first_address.location.lat_lng,
+ destination=self.second_address.location.lat_lng,
+ mode='bicycling',
+ alternatives=False,
+ )
+ # Without "alternatives" and "waypoints", the `response` contains
+ # exactly one "route" that consists of exactly one "leg".
+ # Source: https://developers.google.com/maps/documentation/directions/get-directions#Legs # noqa:E501
+ route = response[0]['legs'][0]
+
+ self.bicycle_distance = route['distance']['value'] # noqa:WPS601
+ self.bicycle_duration = route['duration']['value'] # noqa:WPS601
+
+ # Each route consists of many "steps" that are instructions as to how to
+ # get from A to B. As a step's "start_location" may equal the previous step's
+ # "end_location", we use an `OrderedSet` to find the unique latitude-longitude
+ # pairs that make up the path from `.first_address` to `.second_address`.
+ steps = ordered_set.OrderedSet()
+ for step in route['steps']:
+ steps.add( # noqa:WPS221
+ (step['start_location']['lat'], step['start_location']['lng']),
+ )
+ steps.add( # noqa:WPS221
+ (step['end_location']['lat'], step['end_location']['lng']),
+ )
+
+ steps.discard(self.first_address.location.lat_lng)
+ steps.discard(self.second_address.location.lat_lng)
+
+ self.directions = json.dumps(list(steps)) # noqa:WPS601
+
+ db.session.add(self)
+ db.session.commit()
+
@property
def path(self) -> List[utils.Location]:
"""The couriers' path from `.first_address` to `.second_address`.
diff --git a/src/urban_meal_delivery/db/utils/locations.py b/src/urban_meal_delivery/db/utils/locations.py
index b6ef41e..4c92889 100644
--- a/src/urban_meal_delivery/db/utils/locations.py
+++ b/src/urban_meal_delivery/db/utils/locations.py
@@ -7,7 +7,7 @@ from typing import Optional, Tuple
import utm
-class Location:
+class Location: # noqa:WPS214
"""A location represented in WGS84 and UTM coordinates.
WGS84:
@@ -67,6 +67,11 @@ class Location:
"""
return self._longitude
+ @property
+ def lat_lng(self) -> Tuple[float, float]:
+ """The `.latitude` and `.longitude` as a 2-`tuple`."""
+ return (self._latitude, self._longitude)
+
@property
def easting(self) -> int:
"""The easting of the location in meters (UTM)."""
diff --git a/tests/db/test_addresses_addresses.py b/tests/db/test_addresses_addresses.py
index d6f43d4..0435622 100644
--- a/tests/db/test_addresses_addresses.py
+++ b/tests/db/test_addresses_addresses.py
@@ -2,6 +2,7 @@
import json
+import googlemaps
import pytest
import sqlalchemy as sqla
from geopy import distance
@@ -21,8 +22,7 @@ def another_address(make_address):
def assoc(address, another_address, make_address):
"""An association between `address` and `another_address`."""
air_distance = distance.great_circle( # noqa:WPS317
- (address.latitude, address.longitude),
- (another_address.latitude, another_address.longitude),
+ address.location.lat_lng, another_address.location.lat_lng,
).meters
# We put 5 latitude-longitude pairs as the "path" from
@@ -195,6 +195,417 @@ class TestConstraints:
db_session.commit()
+@pytest.mark.db
+class TestFromAddresses:
+ """Test the alternative constructor `DistanceMatrix.from_addresses()`."""
+
+ @pytest.fixture
+ def _prepare_db(self, db_session, address):
+ """Put the `address` into the database.
+
+ `Address`es must be in the database as otherwise the `.city_id` column
+ cannot be resolved in `DistanceMatrix.from_addresses()`.
+ """
+ db_session.add(address)
+
+ @pytest.mark.usefixtures('_prepare_db')
+ def test_make_distance_matrix_instance(
+ self, db_session, address, another_address,
+ ):
+ """Test instantiation of a new `DistanceMatrix` instance."""
+ assert db_session.query(db.DistanceMatrix).count() == 0
+
+ db.DistanceMatrix.from_addresses(address, another_address)
+
+ assert db_session.query(db.DistanceMatrix).count() == 1
+
+ @pytest.mark.usefixtures('_prepare_db')
+ def test_make_the_same_distance_matrix_instance_twice(
+ self, db_session, address, another_address,
+ ):
+ """Test instantiation of a new `DistanceMatrix` instance."""
+ assert db_session.query(db.DistanceMatrix).count() == 0
+
+ db.DistanceMatrix.from_addresses(address, another_address)
+
+ assert db_session.query(db.DistanceMatrix).count() == 1
+
+ db.DistanceMatrix.from_addresses(another_address, address)
+
+ assert db_session.query(db.DistanceMatrix).count() == 1
+
+ @pytest.mark.usefixtures('_prepare_db')
+ def test_structure_of_return_value(self, db_session, address, another_address):
+ """Test instantiation of a new `DistanceMatrix` instance."""
+ results = db.DistanceMatrix.from_addresses(address, another_address)
+
+ assert isinstance(results, list)
+
+ @pytest.mark.usefixtures('_prepare_db')
+ def test_instances_must_have_air_distance(
+ self, db_session, address, another_address,
+ ):
+ """Test instantiation of a new `DistanceMatrix` instance."""
+ distances = db.DistanceMatrix.from_addresses(address, another_address)
+
+ result = distances[0]
+
+ assert result.air_distance is not None
+
+ @pytest.mark.usefixtures('_prepare_db')
+ def test_do_not_sync_instances_with_google_maps(
+ self, db_session, address, another_address,
+ ):
+ """Test instantiation of a new `DistanceMatrix` instance."""
+ distances = db.DistanceMatrix.from_addresses(address, another_address)
+
+ result = distances[0]
+
+ assert result.bicycle_distance is None
+ assert result.bicycle_duration is None
+
+ @pytest.mark.usefixtures('_prepare_db')
+ def test_sync_instances_with_google_maps(
+ self, db_session, address, another_address, monkeypatch,
+ ):
+ """Test instantiation of a new `DistanceMatrix` instance."""
+
+ def sync(self):
+ self.bicycle_distance = 1.25 * self.air_distance
+ self.bicycle_duration = 300
+
+ monkeypatch.setattr(db.DistanceMatrix, 'sync_with_google_maps', sync)
+
+ distances = db.DistanceMatrix.from_addresses(
+ address, another_address, google_maps=True,
+ )
+
+ result = distances[0]
+
+ assert result.bicycle_distance is not None
+ assert result.bicycle_duration is not None
+
+ @pytest.mark.usefixtures('_prepare_db')
+ def test_one_distance_for_two_addresses(self, db_session, address, another_address):
+ """Test instantiation of a new `DistanceMatrix` instance."""
+ result = len(db.DistanceMatrix.from_addresses(address, another_address))
+
+ assert result == 1
+
+ @pytest.mark.usefixtures('_prepare_db')
+ def test_two_distances_for_three_addresses(self, db_session, make_address):
+ """Test instantiation of a new `DistanceMatrix` instance."""
+ result = len(
+ db.DistanceMatrix.from_addresses(*[make_address() for _ in range(3)]),
+ )
+
+ assert result == 3
+
+ @pytest.mark.usefixtures('_prepare_db')
+ def test_six_distances_for_four_addresses(self, db_session, make_address):
+ """Test instantiation of a new `DistanceMatrix` instance."""
+ result = len(
+ db.DistanceMatrix.from_addresses(*[make_address() for _ in range(4)]),
+ )
+
+ assert result == 6
+
+
+@pytest.mark.db
+class TestSyncWithGoogleMaps:
+ """Test the `DistanceMatrix.sync_with_google_maps()` method."""
+
+ @pytest.fixture
+ def api_response(self):
+ """A typical (shortened) response by the Google Maps Directions API."""
+ return [ # noqa:ECE001
+ {
+ 'bounds': {
+ 'northeast': {'lat': 44.8554284, 'lng': -0.5652398},
+ 'southwest': {'lat': 44.8342256, 'lng': -0.5708206},
+ },
+ 'copyrights': 'Map data ©2021',
+ 'legs': [
+ {
+ 'distance': {'text': '3.0 km', 'value': 2969},
+ 'duration': {'text': '10 mins', 'value': 596},
+ 'end_address': '13 Place Paul et Jean Paul Avisseau, ...',
+ 'end_location': {'lat': 44.85540839999999, 'lng': -0.5672105},
+ 'start_address': '59 Rue Saint-François, 33000 Bordeaux, ...',
+ 'start_location': {'lat': 44.8342256, 'lng': -0.570372},
+ 'steps': [
+ {
+ 'distance': {'text': '0.1 km', 'value': 108},
+ 'duration': {'text': '1 min', 'value': 43},
+ 'end_location': {
+ 'lat': 44.83434380000001,
+ 'lng': -0.5690105999999999,
+ },
+ 'html_instructions': 'Head east on ...',
+ 'polyline': {'points': '}tspGxknBKcDIkB'},
+ 'start_location': {'lat': 44.8342256, 'lng': -0.57032},
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.1 km', 'value': 115},
+ 'duration': {'text': '1 min', 'value': 22},
+ 'end_location': {'lat': 44.8353651, 'lng': -0.569199},
+ 'html_instructions': 'Turn left onto ...',
+ 'maneuver': 'turn-left',
+ 'polyline': {'points': 'suspGhcnBc@JE@_@DiAHA?w@F'},
+ 'start_location': {
+ 'lat': 44.83434380000001,
+ 'lng': -0.5690105999999999,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.3 km', 'value': 268},
+ 'duration': {'text': '1 min', 'value': 59},
+ 'end_location': {'lat': 44.8362675, 'lng': -0.5660914},
+ 'html_instructions': 'Turn right onto ...',
+ 'maneuver': 'turn-right',
+ 'polyline': {
+ 'points': 'a|spGndnBEYEQKi@Mi@Is@CYCOE]CQIq@ ...',
+ },
+ 'start_location': {'lat': 44.8353651, 'lng': -0.56919},
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.1 km', 'value': 95},
+ 'duration': {'text': '1 min', 'value': 29},
+ 'end_location': {'lat': 44.8368458, 'lng': -0.5652398},
+ 'html_instructions': 'Slight left onto ...',
+ 'maneuver': 'turn-slight-left',
+ 'polyline': {
+ 'points': 'uatpG`qmBg@aAGM?ACE[k@CICGACEGCCAAEAG?',
+ },
+ 'start_location': {
+ 'lat': 44.8362675,
+ 'lng': -0.5660914,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '23 m', 'value': 23},
+ 'duration': {'text': '1 min', 'value': 4},
+ 'end_location': {'lat': 44.83697, 'lng': -0.5654425},
+ 'html_instructions': 'Slight left to stay ...',
+ 'maneuver': 'turn-slight-left',
+ 'polyline': {
+ 'points': 'ietpGvkmBA@C?CBCBEHA@AB?B?B?B?@',
+ },
+ 'start_location': {
+ 'lat': 44.8368458,
+ 'lng': -0.5652398,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.2 km', 'value': 185},
+ 'duration': {'text': '1 min', 'value': 23},
+ 'end_location': {'lat': 44.8382126, 'lng': -0.5669969},
+ 'html_instructions': 'Take the ramp to Le Lac ...',
+ 'polyline': {
+ 'points': 'aftpG~lmBY^[^sAdB]`@CDKLQRa@h@A@IZ',
+ },
+ 'start_location': {'lat': 44.83697, 'lng': -0.5654425},
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.3 km', 'value': 253},
+ 'duration': {'text': '1 min', 'value': 43},
+ 'end_location': {'lat': 44.840163, 'lng': -0.5686525},
+ 'html_instructions': 'Merge onto Quai Richelieu',
+ 'maneuver': 'merge',
+ 'polyline': {
+ 'points': 'ymtpGvvmBeAbAe@b@_@ZUN[To@f@e@^A?g ...',
+ },
+ 'start_location': {
+ 'lat': 44.8382126,
+ 'lng': -0.5669969,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.1 km', 'value': 110},
+ 'duration': {'text': '1 min', 'value': 21},
+ 'end_location': {'lat': 44.841079, 'lng': -0.5691835},
+ 'html_instructions': 'Continue onto Quai de la ...',
+ 'polyline': {'points': '_ztpG`anBUNQLULUJOHMFKDWN'},
+ 'start_location': {'lat': 44.840163, 'lng': -0.56865},
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.3 km', 'value': 262},
+ 'duration': {'text': '1 min', 'value': 44},
+ 'end_location': {'lat': 44.8433375, 'lng': -0.5701161},
+ 'html_instructions': 'Continue onto Quai du ...',
+ 'polyline': {
+ 'points': 'w_upGjdnBeBl@sBn@gA^[JIBc@Nk@Nk@L',
+ },
+ 'start_location': {'lat': 44.841079, 'lng': -0.56915},
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.6 km', 'value': 550},
+ 'duration': {'text': '2 mins', 'value': 97},
+ 'end_location': {
+ 'lat': 44.84822339999999,
+ 'lng': -0.5705307,
+ },
+ 'html_instructions': 'Continue onto Quai ...',
+ 'polyline': {
+ 'points': '{mupGfjnBYFI@IBaAPUD{AX}@NK@]Fe@H ...',
+ },
+ 'start_location': {
+ 'lat': 44.8433375,
+ 'lng': -0.5701161,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.5 km', 'value': 508},
+ 'duration': {'text': '1 min', 'value': 87},
+ 'end_location': {'lat': 44.8523224, 'lng': -0.5678223},
+ 'html_instructions': 'Continue onto ...',
+ 'polyline': {
+ 'points': 'klvpGxlnBWEUGWGSGMEOEOE[KMEQGIA] ...',
+ },
+ 'start_location': {
+ 'lat': 44.84822339999999,
+ 'lng': -0.5705307,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '28 m', 'value': 28},
+ 'duration': {'text': '1 min', 'value': 45},
+ 'end_location': {
+ 'lat': 44.85245620000001,
+ 'lng': -0.5681259,
+ },
+ 'html_instructions': 'Turn left onto ...',
+ 'maneuver': 'turn-left',
+ 'polyline': {'points': '_fwpGz{mBGLADGPCFEN'},
+ 'start_location': {
+ 'lat': 44.8523224,
+ 'lng': -0.5678223,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.2 km', 'value': 176},
+ 'duration': {'text': '1 min', 'value': 31},
+ 'end_location': {'lat': 44.8536857, 'lng': -0.5667282},
+ 'html_instructions': 'Turn right onto ...',
+ 'maneuver': 'turn-right',
+ 'polyline': {
+ 'points': '{fwpGx}mB_@c@mAuAOQi@m@m@y@_@c@',
+ },
+ 'start_location': {
+ 'lat': 44.85245620000001,
+ 'lng': -0.5681259,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.2 km', 'value': 172},
+ 'duration': {'text': '1 min', 'value': 28},
+ 'end_location': {'lat': 44.8547766, 'lng': -0.5682825},
+ 'html_instructions': 'Turn left onto ... ',
+ 'maneuver': 'turn-left',
+ 'polyline': {'points': 'qnwpG`umBW`@UkDtF'},
+ 'start_location': {
+ 'lat': 44.8536857,
+ 'lng': -0.5667282,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '0.1 km', 'value': 101},
+ 'duration': {'text': '1 min', 'value': 17},
+ 'end_location': {'lat': 44.8554284, 'lng': -0.5673822},
+ 'html_instructions': 'Turn right onto ...',
+ 'maneuver': 'turn-right',
+ 'polyline': {'points': 'kuwpGv~mBa@q@cA_B[a@'},
+ 'start_location': {
+ 'lat': 44.8547766,
+ 'lng': -0.5682825,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ {
+ 'distance': {'text': '15 m', 'value': 15},
+ 'duration': {'text': '1 min', 'value': 3},
+ 'end_location': {
+ 'lat': 44.85540839999999,
+ 'lng': -0.5672105,
+ },
+ 'html_instructions': 'Turn right onto ...',
+ 'maneuver': 'turn-right',
+ 'polyline': {'points': 'mywpGbymBBC@C?E?C?E?EAC'},
+ 'start_location': {
+ 'lat': 44.8554284,
+ 'lng': -0.5673822,
+ },
+ 'travel_mode': 'BICYCLING',
+ },
+ ],
+ 'traffic_speed_entry': [],
+ 'via_waypoint': [],
+ },
+ ],
+ 'overview_polyline': {
+ 'points': '}tspGxknBUoGi@LcDVe@_CW{Ba@sC[eA_@} ...',
+ },
+ 'summary': 'Quai des Chartrons',
+ 'warnings': ['Bicycling directions are in beta ...'],
+ 'waypoint_order': [],
+ },
+ ]
+
+ @pytest.fixture
+ def _fake_google_api(self, api_response, monkeypatch):
+ """Patch out the call to the Google Maps Directions API."""
+
+ def directions(self, *args, **kwargs):
+ return api_response
+
+ monkeypatch.setattr(googlemaps.Client, 'directions', directions)
+
+ @pytest.mark.usefixtures('_fake_google_api')
+ def test_sync_instances_with_google_maps(self, db_session, assoc):
+ """Call the method for a `DistanceMatrix` object without Google data."""
+ assoc.bicycle_distance = None
+ assoc.bicycle_duration = None
+ assoc.directions = None
+
+ assoc.sync_with_google_maps()
+
+ assert assoc.bicycle_distance == 2_969
+ assert assoc.bicycle_duration == 596
+ assert assoc.directions is not None
+
+ @pytest.mark.usefixtures('_fake_google_api')
+ def test_repeated_sync_instances_with_google_maps(self, db_session, assoc):
+ """Call the method for a `DistanceMatrix` object with Google data.
+
+ That call should immediately return without changing any data.
+
+ We use the `assoc`'s "Google" data from above.
+ """
+ old_distance = assoc.bicycle_distance
+ old_duration = assoc.bicycle_duration
+ old_directions = assoc.directions
+
+ assoc.sync_with_google_maps()
+
+ assert assoc.bicycle_distance is old_distance
+ assert assoc.bicycle_duration is old_duration
+ assert assoc.directions is old_directions
+
+
class TestProperties:
"""Test properties in `DistanceMatrix`."""
diff --git a/tests/db/utils/test_locations.py b/tests/db/utils/test_locations.py
index 8eb0263..0a3d3d2 100644
--- a/tests/db/utils/test_locations.py
+++ b/tests/db/utils/test_locations.py
@@ -122,6 +122,15 @@ class TestProperties:
assert result == pytest.approx(float(address.longitude))
+ def test_lat_lng(self, location, address):
+ """Test `Location.lat_lng` property."""
+ result = location.lat_lng
+
+ assert result == (
+ pytest.approx(float(address.latitude)),
+ pytest.approx(float(address.longitude)),
+ )
+
def test_easting(self, location):
"""Test `Location.easting` property."""
result = location.easting