# frozen_string_literal: true require 'rails_helper' RSpec.describe Tracks::BoundaryDetector do let(:user) { create(:user) } let(:detector) { described_class.new(user) } let(:safe_settings) { user.safe_settings } before do # Spy on user settings - ensure we're working with the same object allow(user).to receive(:safe_settings).and_return(safe_settings) allow(safe_settings).to receive(:minutes_between_routes).and_return(30) allow(safe_settings).to receive(:meters_between_routes).and_return(500) # Stub Geocoder for consistent distance calculations allow_any_instance_of(Point).to receive(:distance_to_geocoder).and_return(100) # 100 meters allow(Point).to receive(:calculate_distance_for_array_geocoder).and_return(1000) # 1000 meters end describe '#initialize' do it 'sets the user' do expect(detector.user).to eq(user) end end describe '#resolve_cross_chunk_tracks' do context 'when no recent tracks exist' do it 'returns 0' do expect(detector.resolve_cross_chunk_tracks).to eq(0) end it 'does not log boundary operations when no candidates found' do # This test may log other things, but should not log boundary-related messages result = detector.resolve_cross_chunk_tracks expect(result).to eq(0) end end context 'when no boundary candidates are found' do let!(:track1) { create(:track, user: user, created_at: 30.minutes.ago) } let!(:track2) { create(:track, user: user, created_at: 25.minutes.ago) } before do # Create points that are far apart (no spatial connection) create(:point, user: user, track: track1, latitude: 40.0, longitude: -74.0, timestamp: 2.hours.ago.to_i) create(:point, user: user, track: track2, latitude: 41.0, longitude: -73.0, timestamp: 1.hour.ago.to_i) # Mock distance to be greater than threshold allow_any_instance_of(Point).to receive(:distance_to_geocoder).and_return(1000) # 1000 meters > 500 threshold end it 'returns 0' do expect(detector.resolve_cross_chunk_tracks).to eq(0) end end context 'when boundary candidates exist' do let!(:track1) { create(:track, user: user, created_at: 30.minutes.ago, start_at: 2.hours.ago, end_at: 1.5.hours.ago) } let!(:track2) { create(:track, user: user, created_at: 25.minutes.ago, start_at: 1.hour.ago, end_at: 30.minutes.ago) } let!(:point1_start) { create(:point, user: user, track: track1, latitude: 40.0, longitude: -74.0, timestamp: 2.hours.ago.to_i) } let!(:point1_end) { create(:point, user: user, track: track1, latitude: 40.01, longitude: -74.01, timestamp: 1.5.hours.ago.to_i) } let!(:point2_start) { create(:point, user: user, track: track2, latitude: 40.01, longitude: -74.01, timestamp: 1.hour.ago.to_i) } let!(:point2_end) { create(:point, user: user, track: track2, latitude: 40.02, longitude: -74.02, timestamp: 30.minutes.ago.to_i) } before do # Mock close distance for connected tracks allow_any_instance_of(Point).to receive(:distance_to_geocoder).and_return(100) # Within 500m threshold end it 'finds and resolves boundary tracks' do expect(detector.resolve_cross_chunk_tracks).to eq(1) end it 'creates a merged track with all points' do expect { detector.resolve_cross_chunk_tracks }.to change { user.tracks.count }.by(-1) # 2 tracks become 1 merged_track = user.tracks.first expect(merged_track.points.count).to eq(4) # All points from both tracks end it 'deletes original tracks' do original_track_ids = [track1.id, track2.id] detector.resolve_cross_chunk_tracks expect(Track.where(id: original_track_ids)).to be_empty end end context 'when merge fails' do let!(:track1) { create(:track, user: user, created_at: 30.minutes.ago) } let!(:track2) { create(:track, user: user, created_at: 25.minutes.ago) } # Ensure tracks have points so merge gets to the create_track_from_points step let!(:point1) { create(:point, user: user, track: track1, timestamp: 2.hours.ago.to_i) } let!(:point2) { create(:point, user: user, track: track2, timestamp: 1.hour.ago.to_i) } before do # Mock tracks as connected allow(detector).to receive(:find_boundary_track_candidates).and_return([[track1, track2]]) # Mock merge failure allow(detector).to receive(:create_track_from_points).and_return(nil) end it 'returns 0 and logs warning' do expect(detector.resolve_cross_chunk_tracks).to eq(0) end it 'does not delete original tracks' do detector.resolve_cross_chunk_tracks expect(Track.exists?(track1.id)).to be true expect(Track.exists?(track2.id)).to be true end end end describe 'private methods' do describe '#find_connected_tracks' do let!(:base_track) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.5.hours.ago) } let!(:connected_track) { create(:track, user: user, start_at: 1.hour.ago, end_at: 30.minutes.ago) } let!(:distant_track) { create(:track, user: user, start_at: 5.hours.ago, end_at: 4.hours.ago) } let!(:base_point_end) { create(:point, user: user, track: base_track, timestamp: 1.5.hours.ago.to_i) } let!(:connected_point_start) { create(:point, user: user, track: connected_track, timestamp: 1.hour.ago.to_i) } let!(:distant_point) { create(:point, user: user, track: distant_track, timestamp: 4.hours.ago.to_i) } let(:all_tracks) { [base_track, connected_track, distant_track] } before do # Mock distance for spatially connected tracks allow(base_point_end).to receive(:distance_to_geocoder).with(connected_point_start, :m).and_return(100) allow(base_point_end).to receive(:distance_to_geocoder).with(distant_point, :m).and_return(2000) end it 'finds temporally and spatially connected tracks' do connected = detector.send(:find_connected_tracks, base_track, all_tracks) expect(connected).to include(connected_track) expect(connected).not_to include(distant_track) end it 'excludes the base track itself' do connected = detector.send(:find_connected_tracks, base_track, all_tracks) expect(connected).not_to include(base_track) end it 'handles tracks with no points' do track_no_points = create(:track, user: user, start_at: 1.hour.ago, end_at: 30.minutes.ago) all_tracks_with_empty = all_tracks + [track_no_points] expect { detector.send(:find_connected_tracks, base_track, all_tracks_with_empty) }.not_to raise_error end end describe '#tracks_spatially_connected?' do let!(:track1) { create(:track, user: user) } let!(:track2) { create(:track, user: user) } context 'when tracks have no points' do it 'returns false' do result = detector.send(:tracks_spatially_connected?, track1, track2) expect(result).to be false end end context 'when tracks have points' do let!(:track1_start) { create(:point, user: user, track: track1, timestamp: 2.hours.ago.to_i) } let!(:track1_end) { create(:point, user: user, track: track1, timestamp: 1.5.hours.ago.to_i) } let!(:track2_start) { create(:point, user: user, track: track2, timestamp: 1.hour.ago.to_i) } let!(:track2_end) { create(:point, user: user, track: track2, timestamp: 30.minutes.ago.to_i) } context 'when track1 end connects to track2 start' do before do # Mock specific point-to-point distance calls that the method will make allow(track1_end).to receive(:distance_to_geocoder).with(track2_start, :m).and_return(100) # Connected allow(track2_end).to receive(:distance_to_geocoder).with(track1_start, :m).and_return(1000) # Not connected allow(track1_start).to receive(:distance_to_geocoder).with(track2_start, :m).and_return(1000) # Not connected allow(track1_end).to receive(:distance_to_geocoder).with(track2_end, :m).and_return(1000) # Not connected end it 'returns true' do result = detector.send(:tracks_spatially_connected?, track1, track2) expect(result).to be true end end context 'when tracks are not spatially connected' do before do allow_any_instance_of(Point).to receive(:distance_to_geocoder).and_return(1000) # All points far apart end it 'returns false' do result = detector.send(:tracks_spatially_connected?, track1, track2) expect(result).to be false end end end end describe '#points_are_close?' do let(:point1) { create(:point, user: user) } let(:point2) { create(:point, user: user) } let(:threshold) { 500 } it 'returns true when points are within threshold' do allow(point1).to receive(:distance_to_geocoder).with(point2, :m).and_return(300) result = detector.send(:points_are_close?, point1, point2, threshold) expect(result).to be true end it 'returns false when points exceed threshold' do allow(point1).to receive(:distance_to_geocoder).with(point2, :m).and_return(700) result = detector.send(:points_are_close?, point1, point2, threshold) expect(result).to be false end it 'returns false when points are nil' do result = detector.send(:points_are_close?, nil, point2, threshold) expect(result).to be false result = detector.send(:points_are_close?, point1, nil, threshold) expect(result).to be false end end describe '#valid_boundary_group?' do let!(:track1) { create(:track, user: user, start_at: 3.hours.ago, end_at: 2.hours.ago) } let!(:track2) { create(:track, user: user, start_at: 1.5.hours.ago, end_at: 1.hour.ago) } let!(:track3) { create(:track, user: user, start_at: 45.minutes.ago, end_at: 30.minutes.ago) } it 'returns false for single track groups' do result = detector.send(:valid_boundary_group?, [track1]) expect(result).to be false end it 'returns true for valid sequential groups' do result = detector.send(:valid_boundary_group?, [track1, track2, track3]) expect(result).to be true end it 'returns false for groups with large time gaps' do distant_track = create(:track, user: user, start_at: 10.hours.ago, end_at: 9.hours.ago) result = detector.send(:valid_boundary_group?, [distant_track, track1]) expect(result).to be false end end describe '#merge_boundary_tracks' do let!(:track1) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.5.hours.ago) } let!(:track2) { create(:track, user: user, start_at: 1.hour.ago, end_at: 30.minutes.ago) } let!(:point1) { create(:point, user: user, track: track1, timestamp: 2.hours.ago.to_i) } let!(:point2) { create(:point, user: user, track: track1, timestamp: 1.5.hours.ago.to_i) } let!(:point3) { create(:point, user: user, track: track2, timestamp: 1.hour.ago.to_i) } let!(:point4) { create(:point, user: user, track: track2, timestamp: 30.minutes.ago.to_i) } it 'returns false for groups with less than 2 tracks' do result = detector.send(:merge_boundary_tracks, [track1]) expect(result).to be false end it 'successfully merges tracks with sufficient points' do # Mock successful track creation merged_track = create(:track, user: user) allow(detector).to receive(:create_track_from_points).and_return(merged_track) result = detector.send(:merge_boundary_tracks, [track1, track2]) expect(result).to be true end it 'collects all points from all tracks' do # Capture the points passed to create_track_from_points captured_points = nil allow(detector).to receive(:create_track_from_points) do |points, _distance| captured_points = points create(:track, user: user) end detector.send(:merge_boundary_tracks, [track1, track2]) expect(captured_points).to contain_exactly(point1, point2, point3, point4) end it 'sorts points by timestamp' do # Create points out of order point_early = create(:point, user: user, track: track2, timestamp: 3.hours.ago.to_i) captured_points = nil allow(detector).to receive(:create_track_from_points) do |points, _distance| captured_points = points create(:track, user: user) end detector.send(:merge_boundary_tracks, [track1, track2]) timestamps = captured_points.map(&:timestamp) expect(timestamps).to eq(timestamps.sort) end it 'handles insufficient points gracefully' do # Remove points to have less than 2 total Point.where(track: [track1, track2]).limit(3).destroy_all result = detector.send(:merge_boundary_tracks, [track1, track2]) expect(result).to be false end end describe 'user settings integration' do before do # Reset the memoized values for each test detector.instance_variable_set(:@distance_threshold_meters, nil) detector.instance_variable_set(:@time_threshold_minutes, nil) end it 'uses cached distance threshold' do # Call multiple times to test memoization detector.send(:distance_threshold_meters) detector.send(:distance_threshold_meters) expect(safe_settings).to have_received(:meters_between_routes).once end it 'uses cached time threshold' do # Call multiple times to test memoization detector.send(:time_threshold_minutes) detector.send(:time_threshold_minutes) expect(safe_settings).to have_received(:minutes_between_routes).once end end end end