import pytest from src.services.discovery import SegmentDiscoveryService from src.models.activity import Activity from unittest.mock import MagicMock from datetime import datetime from unittest.mock import patch def test_decimate_points(): service = SegmentDiscoveryService(None) # 3 points in a line, 20m apart. Min dist 30m. # p0 (0,0) -> 20m -> p1 (20m, 0) -> 20m -> p2 (40m, 0) # Result should be p0, p2. # Approx: 0.00018 deg lat ~ 20m points = [ [0.0, 0.0], [0.0, 0.00018], [0.0, 0.00036] ] decimated = service._decimate_points(points, min_dist=30.0) assert len(decimated) == 2 assert decimated[0] == [0.0, 0.0] assert decimated[1] == [0.0, 0.00036] def test_discovery_integration(): # Setup mock_db = MagicMock() service = SegmentDiscoveryService(mock_db) # Mock Activity Objects act1 = MagicMock(spec=Activity) act1.id = 101 act1.activity_type = "cycling" act1.start_time = datetime(2025,1,1) act1.file_content = b"mock_content" act1.file_type = "fit" act2 = MagicMock(spec=Activity) act2.id = 102 act2.activity_type = "cycling" act2.start_time = datetime(2025,1,2) act2.file_content = b"mock_content" act2.file_type = "fit" # Mock extract_points_from_file to return overlapping paths # They share a segment: (0,0) -> (0, 0.0005) -> (0, 0.0010) # act1 goes further: -> (0, 0.0020) # act2 stops or goes elsewhere # Approx 0.0002 deg grid step (~20m) # 0.0000 -> 0.0010 is about 100m. Might be too short for 200m cutoff. # Let's make it longer. 0.0030 deg is ~300m. path_shared = [[0.0, y*0.0001] for y in range(30)] # 0 to 0.0029 service._decimate_points = MagicMock(side_effect=[ path_shared, # act1 path_shared # act2 ]) # Mock DB query # First query is for Activities mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = [act1, act2] # Second query is for Segments (deduplication) - called in _deduplicate_against_db # It calls db.query(Segment).filter(...).all() # We need to distinguish them or just ensure the Segment query returns []. # But current mock chain is generic. # 'query' returns a Mock. Calling 'filter' on it returns SAME/NEW Mock. # If we reuse the same mock chain, it returns [act1, act2]. # We should distinguish based on call args or set up side_effect. def side_effect_query(model): m = MagicMock() if model == Activity: # Depth 3 m.filter.return_value.filter.return_value.filter.return_value.all.return_value = [act1, act2] # Depth 2 (type + start_date) m.filter.return_value.filter.return_value.all.return_value = [act1, act2] # Depth 1 (type only) m.filter.return_value.all.return_value = [act1, act2] else: # Segment m.filter.return_value.all.return_value = [] return m mock_db.query.side_effect = side_effect_query # We need to mock extract_points_from_file at module level or patch it # But since we mocked _decimate_points, extract_points_from_file is called but result ignored/passed to decimate. # Wait, discovery.py calls: # raw_points = extract_points_from_file(...) # simplified = self._decimate_points(raw_points, ...) # So we need to patch extract_points_from_file to avoid error on "mock_content" with patch('src.services.discovery.extract_points_from_file', return_value=[[0,0]]) as mock_extract: candidates = service.discover_segments("cycling", datetime(2025,1,1)) # Assertions # Since we mocked decimate to return identical long paths, they should cluster. # result should contain 1 candidate. # activity_ids should validly contain [101, 102] assert len(candidates) == 1 c = candidates[0] assert 101 in c.activity_ids assert 102 in c.activity_ids assert isinstance(c.activity_ids[0], int) def test_connected_components(): service = SegmentDiscoveryService(None) cells = { (0,0): {1}, (0,1): {1}, (0,2): {1}, # Vertical loose chain (0,3): {1}, (0,4): {1}, (0,5): {1}, # Total 6 (10,10): {2}, (10,11): {2} # Separate small cluster (length 2) } # Connected components with min size 5 # Since we set min cluster size to 5 in implementation comps = service._find_connected_components(cells, 0.0002) assert len(comps) == 1 assert len(comps[0]) == 6 assert (0,0) in comps[0] assert (10,10) not in comps[0]