File size: 15,686 Bytes
08e15f1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
"""
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