2025-07-28 16:30:36 -04:00
|
|
|
# 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 }
|
2025-07-28 16:30:36 -04:00
|
|
|
|
|
|
|
|
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
|
|
|
|
2025-07-28 16:30:36 -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)
|
2025-07-28 16:30:36 -04:00
|
|
|
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
|
|
|
|
2025-07-28 16:30:36 -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
|
|
|
|
2025-07-28 16:30:36 -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
|
2025-07-28 16:30:36 -04:00
|
|
|
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) }
|
|
|
|
|
|
2025-07-28 16:30:36 -04:00
|
|
|
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
|
|
|
|
2025-07-28 16:30:36 -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
|
|
|
|
2025-07-28 16:30:36 -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
|
2025-07-28 16:30:36 -04:00
|
|
|
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
|
|
|
|
2025-07-28 16:30:36 -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
|
|
|
|
2025-07-28 16:30:36 -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
|
|
|
|
2025-07-28 16:30:36 -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
|
|
|
|
2025-07-28 16:30:36 -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
|
|
|
|
|
|
2025-07-28 16:30:36 -04:00
|
|
|
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
|
2025-07-28 16:30:36 -04:00
|
|
|
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
|
2025-07-28 16:30:36 -04:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
2025-08-29 08:23:38 -04:00
|
|
|
end
|