dawarich/spec/services/tracks/boundary_detector_spec.rb

342 lines
14 KiB
Ruby
Raw Permalink Normal View History

# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tracks::BoundaryDetector do
let(:user) { create(:user) }
let(:detector) { described_class.new(user) }
2025-08-29 05:52:56 -04:00
let(:safe_settings) { user.safe_settings }
before do
2025-08-29 05:52:56 -04:00
# 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)
2025-08-29 08:23:38 -04:00
# 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
2025-08-29 05:52:56 -04:00
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)
2025-08-29 08:23:38 -04:00
# 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
2025-08-29 08:23:38 -04:00
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
2025-08-29 08:23:38 -04:00
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) }
2025-08-29 05:52:56 -04:00
# 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]])
2025-08-29 08:23:38 -04:00
# 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]
2025-08-29 08:23:38 -04:00
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
2025-08-29 05:52:56 -04:00
# 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)
2025-08-29 08:23:38 -04:00
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)
2025-08-29 08:23:38 -04:00
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
2025-08-29 08:23:38 -04:00
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)
2025-08-29 08:23:38 -04:00
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
2025-08-29 05:52:56 -04:00
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)
2025-08-29 05:52:56 -04:00
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)
2025-08-29 05:52:56 -04:00
expect(safe_settings).to have_received(:minutes_between_routes).once
end
end
end
2025-08-29 08:23:38 -04:00
end