""" Unit tests for the routing module. Tests cover: - RouteResult dataclass - DistanceMatrix operations - Haversine fallback calculations - Polyline encoding/decoding roundtrip - Location class integration with distance matrix """ import pytest import polyline from vehicle_routing.domain import Location from vehicle_routing.routing import ( RouteResult, DistanceMatrix, _haversine_driving_time, _haversine_distance_meters, _straight_line_geometry, compute_distance_matrix_with_progress, ) class TestRouteResult: """Tests for the RouteResult dataclass.""" def test_create_route_result(self): """Test creating a basic RouteResult.""" result = RouteResult( duration_seconds=3600, distance_meters=50000, geometry="encodedPolyline" ) assert result.duration_seconds == 3600 assert result.distance_meters == 50000 assert result.geometry == "encodedPolyline" def test_route_result_optional_geometry(self): """Test RouteResult with no geometry.""" result = RouteResult(duration_seconds=100, distance_meters=1000) assert result.geometry is None class TestDistanceMatrix: """Tests for the DistanceMatrix class.""" def test_empty_matrix(self): """Test empty distance matrix returns None.""" matrix = DistanceMatrix() loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) assert matrix.get_route(loc1, loc2) is None def test_set_and_get_route(self): """Test setting and retrieving a route.""" matrix = DistanceMatrix() loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) result = RouteResult( duration_seconds=3600, distance_meters=100000, geometry="test_geometry" ) matrix.set_route(loc1, loc2, result) retrieved = matrix.get_route(loc1, loc2) assert retrieved is not None assert retrieved.duration_seconds == 3600 assert retrieved.distance_meters == 100000 assert retrieved.geometry == "test_geometry" def test_get_route_different_direction(self): """Test that routes are directional (A->B != B->A by default).""" matrix = DistanceMatrix() loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) result = RouteResult(duration_seconds=3600, distance_meters=100000) matrix.set_route(loc1, loc2, result) # Should find loc1 -> loc2 assert matrix.get_route(loc1, loc2) is not None # Should NOT find loc2 -> loc1 (wasn't set) assert matrix.get_route(loc2, loc1) is None def test_get_driving_time_from_matrix(self): """Test getting driving time from matrix.""" matrix = DistanceMatrix() loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) result = RouteResult(duration_seconds=3600, distance_meters=100000) matrix.set_route(loc1, loc2, result) assert matrix.get_driving_time(loc1, loc2) == 3600 def test_get_driving_time_falls_back_to_haversine(self): """Test that missing routes fall back to haversine.""" matrix = DistanceMatrix() loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) # Don't set any route - should use haversine fallback time = matrix.get_driving_time(loc1, loc2) assert time > 0 # Should return some positive value from haversine def test_get_geometry(self): """Test getting geometry from matrix.""" matrix = DistanceMatrix() loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) result = RouteResult( duration_seconds=3600, distance_meters=100000, geometry="test_encoded_polyline" ) matrix.set_route(loc1, loc2, result) assert matrix.get_geometry(loc1, loc2) == "test_encoded_polyline" def test_get_geometry_missing_returns_none(self): """Test that missing routes return None for geometry.""" matrix = DistanceMatrix() loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) assert matrix.get_geometry(loc1, loc2) is None class TestHaversineFunctions: """Tests for standalone haversine functions.""" def test_haversine_driving_time_same_location(self): """Same location should return 0 driving time.""" loc = Location(latitude=40.0, longitude=-75.0) assert _haversine_driving_time(loc, loc) == 0 def test_haversine_driving_time_realistic(self): """Test haversine driving time with realistic coordinates.""" philadelphia = Location(latitude=39.95, longitude=-75.17) new_york = Location(latitude=40.71, longitude=-74.01) time = _haversine_driving_time(philadelphia, new_york) # ~130 km at 50 km/h = ~9400 seconds assert 8500 < time < 10500 def test_haversine_distance_meters_same_location(self): """Same location should return 0 distance.""" loc = Location(latitude=40.0, longitude=-75.0) assert _haversine_distance_meters(loc, loc) == 0 def test_haversine_distance_meters_one_degree(self): """Test one degree of latitude is approximately 111 km.""" loc1 = Location(latitude=0, longitude=0) loc2 = Location(latitude=1, longitude=0) distance = _haversine_distance_meters(loc1, loc2) # 1 degree latitude = ~111.32 km assert 110000 < distance < 113000 def test_straight_line_geometry(self): """Test straight line geometry encoding.""" loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) encoded = _straight_line_geometry(loc1, loc2) # Decode and verify points = polyline.decode(encoded) assert len(points) == 2 assert abs(points[0][0] - 40.0) < 0.0001 assert abs(points[0][1] - (-75.0)) < 0.0001 assert abs(points[1][0] - 41.0) < 0.0001 assert abs(points[1][1] - (-74.0)) < 0.0001 class TestPolylineRoundtrip: """Tests for polyline encoding/decoding.""" def test_encode_decode_roundtrip(self): """Test that encoding and decoding preserves coordinates.""" coordinates = [(39.9526, -75.1652), (39.9535, -75.1589)] encoded = polyline.encode(coordinates, precision=5) decoded = polyline.decode(encoded, precision=5) assert len(decoded) == 2 for orig, dec in zip(coordinates, decoded): assert abs(orig[0] - dec[0]) < 0.00001 assert abs(orig[1] - dec[1]) < 0.00001 def test_encode_single_point(self): """Test encoding a single point.""" coordinates = [(40.0, -75.0)] encoded = polyline.encode(coordinates, precision=5) decoded = polyline.decode(encoded, precision=5) assert len(decoded) == 1 assert abs(decoded[0][0] - 40.0) < 0.00001 assert abs(decoded[0][1] - (-75.0)) < 0.00001 def test_encode_many_points(self): """Test encoding many points (like a real route).""" coordinates = [ (39.9526, -75.1652), (39.9535, -75.1589), (39.9543, -75.1690), (39.9520, -75.1685), (39.9505, -75.1660), ] encoded = polyline.encode(coordinates, precision=5) decoded = polyline.decode(encoded, precision=5) assert len(decoded) == len(coordinates) for orig, dec in zip(coordinates, decoded): assert abs(orig[0] - dec[0]) < 0.00001 assert abs(orig[1] - dec[1]) < 0.00001 class TestLocationDistanceMatrixIntegration: """Tests for Location class integration with DistanceMatrix.""" def setup_method(self): """Clear any existing distance matrix before each test.""" Location.clear_distance_matrix() def teardown_method(self): """Clear distance matrix after each test.""" Location.clear_distance_matrix() def test_location_uses_haversine_without_matrix(self): """Without matrix, Location should use haversine.""" loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) # Should use haversine (no matrix set) time = loc1.driving_time_to(loc2) assert time > 0 def test_location_uses_matrix_when_set(self): """With matrix set, Location should use matrix values.""" matrix = DistanceMatrix() loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) # Set a specific value in matrix result = RouteResult(duration_seconds=12345, distance_meters=100000) matrix.set_route(loc1, loc2, result) # Set the matrix on Location class Location.set_distance_matrix(matrix) # Should return the matrix value, not haversine time = loc1.driving_time_to(loc2) assert time == 12345 def test_location_falls_back_when_route_not_in_matrix(self): """If route not in matrix, Location should fall back to haversine.""" matrix = DistanceMatrix() loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) loc3 = Location(latitude=42.0, longitude=-73.0) # Only set loc1 -> loc2 result = RouteResult(duration_seconds=12345, distance_meters=100000) matrix.set_route(loc1, loc2, result) Location.set_distance_matrix(matrix) # loc1 -> loc2 should use matrix assert loc1.driving_time_to(loc2) == 12345 # loc1 -> loc3 should fall back to haversine (not in matrix) time = loc1.driving_time_to(loc3) assert time != 12345 # Should be haversine calculated value assert time > 0 def test_get_distance_matrix(self): """Test getting the current distance matrix.""" assert Location.get_distance_matrix() is None matrix = DistanceMatrix() Location.set_distance_matrix(matrix) assert Location.get_distance_matrix() is matrix def test_clear_distance_matrix(self): """Test clearing the distance matrix.""" matrix = DistanceMatrix() Location.set_distance_matrix(matrix) assert Location.get_distance_matrix() is not None Location.clear_distance_matrix() assert Location.get_distance_matrix() is None class TestDistanceMatrixSameLocation: """Tests for handling same-location routes.""" def test_same_location_zero_time(self): """Same location should have zero driving time.""" loc = Location(latitude=40.0, longitude=-75.0) matrix = DistanceMatrix() result = RouteResult( duration_seconds=0, distance_meters=0, geometry=polyline.encode([(40.0, -75.0)], precision=5) ) matrix.set_route(loc, loc, result) assert matrix.get_driving_time(loc, loc) == 0 class TestComputeDistanceMatrixWithProgress: """Tests for the compute_distance_matrix_with_progress function.""" def test_empty_locations_returns_empty_matrix(self): """Empty location list should return empty matrix.""" matrix = compute_distance_matrix_with_progress([], use_osm=False) assert matrix is not None # Empty matrix - no routes to check def test_haversine_mode_computes_all_pairs(self): """Haversine mode should compute all location pairs.""" locations = [ Location(latitude=40.0, longitude=-75.0), Location(latitude=41.0, longitude=-74.0), Location(latitude=42.0, longitude=-73.0), ] matrix = compute_distance_matrix_with_progress( locations, use_osm=False ) # Should have all 9 pairs (3x3) for origin in locations: for dest in locations: result = matrix.get_route(origin, dest) assert result is not None if origin is dest: assert result.duration_seconds == 0 assert result.distance_meters == 0 else: assert result.duration_seconds > 0 assert result.distance_meters > 0 assert result.geometry is not None def test_progress_callback_is_called(self): """Progress callback should be called during computation.""" locations = [ Location(latitude=40.0, longitude=-75.0), Location(latitude=41.0, longitude=-74.0), ] progress_calls = [] def callback(phase, message, percent, detail=""): progress_calls.append({ "phase": phase, "message": message, "percent": percent, "detail": detail }) compute_distance_matrix_with_progress( locations, use_osm=False, progress_callback=callback ) # Should have received progress callbacks assert len(progress_calls) > 0 # Should have a "complete" phase at the end assert any(p["phase"] == "complete" for p in progress_calls) # All percentages should be between 0 and 100 for call in progress_calls: assert 0 <= call["percent"] <= 100 def test_haversine_mode_skips_network_phase(self): """In haversine mode, should not have network download messages.""" locations = [ Location(latitude=40.0, longitude=-75.0), Location(latitude=41.0, longitude=-74.0), ] progress_calls = [] def callback(phase, message, percent, detail=""): progress_calls.append({ "phase": phase, "message": message }) compute_distance_matrix_with_progress( locations, use_osm=False, progress_callback=callback ) # Should have a "network" phase but with haversine message network_messages = [p for p in progress_calls if p["phase"] == "network"] assert len(network_messages) > 0 assert "haversine" in network_messages[0]["message"].lower() def test_bbox_is_used_when_provided(self): """Provided bounding box should be used.""" locations = [ Location(latitude=40.0, longitude=-75.0), Location(latitude=41.0, longitude=-74.0), ] bbox = (42.0, 39.0, -73.0, -76.0) # north, south, east, west # Should complete without error with provided bbox matrix = compute_distance_matrix_with_progress( locations, bbox=bbox, use_osm=False ) assert matrix is not None def test_geometries_are_straight_lines_in_haversine_mode(self): """In haversine mode, geometries should be straight lines.""" loc1 = Location(latitude=40.0, longitude=-75.0) loc2 = Location(latitude=41.0, longitude=-74.0) matrix = compute_distance_matrix_with_progress( [loc1, loc2], use_osm=False ) result = matrix.get_route(loc1, loc2) assert result is not None assert result.geometry is not None # Decode and verify it's a straight line (2 points) points = polyline.decode(result.geometry) assert len(points) == 2