mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
323 lines
12 KiB
Ruby
323 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
|
|
RSpec.describe Visits::Detector do
|
|
# Constants from the class to make tests more maintainable
|
|
let(:minimum_visit_duration) { described_class::MINIMUM_VISIT_DURATION }
|
|
let(:maximum_visit_gap) { described_class::MAXIMUM_VISIT_GAP }
|
|
let(:minimum_points_for_visit) { described_class::MINIMUM_POINTS_FOR_VISIT }
|
|
|
|
# Base time for tests
|
|
let(:base_time) { Time.zone.now }
|
|
|
|
# Create points for a typical visit scenario
|
|
let(:points) do
|
|
[
|
|
# First visit - multiple points close together
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i),
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: (base_time - 50.minutes).to_i),
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)', timestamp: (base_time - 40.minutes).to_i),
|
|
|
|
# Gap in time (> MAXIMUM_VISIT_GAP)
|
|
|
|
# Second visit - different location
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)', timestamp: (base_time - 10.minutes).to_i),
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0501 40.7501)', timestamp: (base_time - 5.minutes).to_i)
|
|
]
|
|
end
|
|
|
|
subject { described_class.new(points) }
|
|
|
|
describe '#detect_potential_visits' do
|
|
context 'with valid visit data' do
|
|
before do
|
|
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
|
end
|
|
|
|
it 'identifies separate visits based on time gaps and location changes' do
|
|
visits = subject.detect_potential_visits
|
|
|
|
expect(visits.size).to eq(2)
|
|
expect(visits.first[:points].size).to eq(3)
|
|
expect(visits.last[:points].size).to eq(2)
|
|
end
|
|
|
|
it 'calculates correct visit properties' do
|
|
visits = subject.detect_potential_visits
|
|
first_visit = visits.first
|
|
|
|
# The center should be the average of the first 3 points
|
|
expected_lat = (40.7128 + 40.7129 + 40.7130) / 3
|
|
expected_lon = (-74.0060 + -74.0061 + -74.0062) / 3
|
|
|
|
expect(first_visit[:start_time]).to eq((base_time - 1.hour).to_i)
|
|
expect(first_visit[:end_time]).to eq((base_time - 40.minutes).to_i)
|
|
expect(first_visit[:duration]).to eq(20.minutes.to_i)
|
|
expect(first_visit[:center_lat]).to be_within(0.0001).of(expected_lat)
|
|
expect(first_visit[:center_lon]).to be_within(0.0001).of(expected_lon)
|
|
expect(first_visit[:radius]).to be > 0
|
|
expect(first_visit[:suggested_name]).to eq('Test Place')
|
|
end
|
|
end
|
|
|
|
context 'with visits that are too short in duration' do
|
|
let(:short_duration_points) do
|
|
[
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i),
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)',
|
|
timestamp: (base_time - 1.hour + 2.minutes).to_i)
|
|
]
|
|
end
|
|
|
|
subject { described_class.new(short_duration_points) }
|
|
|
|
it 'filters out visits that are too short' do
|
|
visits = subject.detect_potential_visits
|
|
expect(visits).to be_empty
|
|
end
|
|
end
|
|
|
|
context 'with insufficient points for a visit' do
|
|
let(:single_point) do
|
|
[
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i)
|
|
]
|
|
end
|
|
|
|
subject { described_class.new(single_point) }
|
|
|
|
it 'does not create a visit with just one point' do
|
|
visits = subject.detect_potential_visits
|
|
expect(visits).to be_empty
|
|
end
|
|
end
|
|
|
|
context 'with points that create multiple valid visits' do
|
|
let(:multi_visit_points) do
|
|
[
|
|
# First visit
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 3.hours).to_i),
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: (base_time - 2.5.hours).to_i),
|
|
|
|
# Second visit (different location, after a gap)
|
|
build_stubbed(:point, lonlat: 'POINT(-73.9800 40.7600)', timestamp: (base_time - 1.5.hours).to_i),
|
|
build_stubbed(:point, lonlat: 'POINT(-73.9801 40.7601)', timestamp: (base_time - 1.hour).to_i),
|
|
|
|
# Third visit (another location, after another gap)
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)', timestamp: (base_time - 30.minutes).to_i),
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0501 40.7501)', timestamp: (base_time - 20.minutes).to_i)
|
|
]
|
|
end
|
|
|
|
subject { described_class.new(multi_visit_points) }
|
|
|
|
before do
|
|
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
|
end
|
|
|
|
it 'correctly identifies all valid visits' do
|
|
visits = subject.detect_potential_visits
|
|
|
|
expect(visits.size).to eq(3)
|
|
expect(visits[0][:points].size).to eq(2)
|
|
expect(visits[1][:points].size).to eq(2)
|
|
expect(visits[2][:points].size).to eq(2)
|
|
end
|
|
end
|
|
|
|
context 'with points having small time gaps but in same area' do
|
|
let(:same_area_points) do
|
|
[
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i),
|
|
# Small gap (less than MAXIMUM_VISIT_GAP)
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)',
|
|
timestamp: (base_time - 1.hour + 25.minutes).to_i),
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)',
|
|
timestamp: (base_time - 1.hour + 40.minutes).to_i)
|
|
]
|
|
end
|
|
|
|
subject { described_class.new(same_area_points) }
|
|
|
|
before do
|
|
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
|
end
|
|
|
|
it 'groups points into a single visit despite small gaps' do
|
|
visits = subject.detect_potential_visits
|
|
|
|
expect(visits.size).to eq(1)
|
|
expect(visits.first[:points].size).to eq(3)
|
|
expect(visits.first[:duration]).to eq(40.minutes.to_i)
|
|
end
|
|
end
|
|
|
|
context 'with no points' do
|
|
subject { described_class.new([]) }
|
|
|
|
it 'returns an empty array' do
|
|
visits = subject.detect_potential_visits
|
|
expect(visits).to be_empty
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'private methods' do
|
|
describe '#belongs_to_current_visit?' do
|
|
let(:current_visit) do
|
|
{
|
|
start_time: (base_time - 1.hour).to_i,
|
|
end_time: (base_time - 50.minutes).to_i,
|
|
center_lat: 40.7128,
|
|
center_lon: -74.0060,
|
|
points: []
|
|
}
|
|
end
|
|
|
|
it 'returns true for a point with small time gap and close to center' do
|
|
point = build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)',
|
|
timestamp: (base_time - 45.minutes).to_i)
|
|
|
|
result = subject.send(:belongs_to_current_visit?, point, current_visit)
|
|
expect(result).to be true
|
|
end
|
|
|
|
it 'returns false for a point with large time gap' do
|
|
point = build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)',
|
|
timestamp: (base_time - 10.minutes).to_i)
|
|
|
|
result = subject.send(:belongs_to_current_visit?, point, current_visit)
|
|
expect(result).to be false
|
|
end
|
|
|
|
it 'returns false for a point far from the center' do
|
|
point = build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)',
|
|
timestamp: (base_time - 49.minutes).to_i)
|
|
|
|
result = subject.send(:belongs_to_current_visit?, point, current_visit)
|
|
expect(result).to be false
|
|
end
|
|
end
|
|
|
|
describe '#calculate_max_radius' do
|
|
it 'returns larger radius for longer visits' do
|
|
short_radius = subject.send(:calculate_max_radius, 5.minutes.to_i)
|
|
long_radius = subject.send(:calculate_max_radius, 1.hour.to_i)
|
|
|
|
expect(long_radius).to be > short_radius
|
|
end
|
|
|
|
it 'has a minimum radius even for very short visits' do
|
|
radius = subject.send(:calculate_max_radius, 1.minute.to_i)
|
|
expect(radius).to be > 0
|
|
end
|
|
|
|
it 'caps the radius at maximum value' do
|
|
radius = subject.send(:calculate_max_radius, 24.hours.to_i)
|
|
expect(radius).to be <= 0.5 # Cap at 500 meters
|
|
end
|
|
end
|
|
|
|
describe '#calculate_visit_radius' do
|
|
let(:center) { [40.7128, -74.0060] }
|
|
let(:test_points) do
|
|
[
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)'), # At center
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0070 40.7138)'), # ~100m away
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0080 40.7148)') # ~200m away
|
|
]
|
|
end
|
|
|
|
it 'returns the distance to the furthest point as radius' do
|
|
radius = subject.send(:calculate_visit_radius, test_points, center)
|
|
|
|
# Approximately 200 meters, but with some tolerance
|
|
expect(radius).to be_within(50).of(200)
|
|
end
|
|
|
|
it 'ensures a minimum radius even with close points' do
|
|
close_points = [
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)'),
|
|
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)')
|
|
]
|
|
|
|
radius = subject.send(:calculate_visit_radius, close_points, center)
|
|
expect(radius).to be >= 15 # Minimum 15 meters
|
|
end
|
|
end
|
|
|
|
describe '#suggest_place_name' do
|
|
let(:point_with_geodata) do
|
|
build_stubbed(:point,
|
|
geodata: {
|
|
'features' => [
|
|
{
|
|
'properties' => {
|
|
'type' => 'restaurant',
|
|
'name' => 'Awesome Pizza',
|
|
'street' => 'Main St',
|
|
'city' => 'New York',
|
|
'state' => 'NY'
|
|
}
|
|
}
|
|
]
|
|
})
|
|
end
|
|
|
|
let(:point_with_different_geodata) do
|
|
build_stubbed(:point,
|
|
geodata: {
|
|
'features' => [
|
|
{
|
|
'properties' => {
|
|
'type' => 'park',
|
|
'name' => 'Central Park',
|
|
'city' => 'New York',
|
|
'state' => 'NY'
|
|
}
|
|
}
|
|
]
|
|
})
|
|
end
|
|
|
|
let(:point_without_geodata) do
|
|
build_stubbed(:point, geodata: nil)
|
|
end
|
|
|
|
it 'extracts the most common feature name' do
|
|
test_points = [point_with_geodata, point_with_geodata]
|
|
name = subject.send(:suggest_place_name, test_points)
|
|
|
|
expect(name).to eq('Awesome Pizza, Main St, New York, NY')
|
|
end
|
|
|
|
it 'returns nil for points without geodata' do
|
|
test_points = [point_without_geodata, point_without_geodata]
|
|
name = subject.send(:suggest_place_name, test_points)
|
|
|
|
expect(name).to be_nil
|
|
end
|
|
|
|
it 'uses the most common feature type across multiple points' do
|
|
restaurant_points = Array.new(3) { point_with_geodata }
|
|
park_points = Array.new(2) { point_with_different_geodata }
|
|
|
|
test_points = restaurant_points + park_points
|
|
name = subject.send(:suggest_place_name, test_points)
|
|
|
|
expect(name).to eq('Awesome Pizza, Main St, New York, NY')
|
|
end
|
|
|
|
it 'handles empty or invalid geodata gracefully' do
|
|
point_with_empty_features = build_stubbed(:point, geodata: { 'features' => [] })
|
|
point_with_invalid_geodata = build_stubbed(:point, geodata: { 'invalid' => 'data' })
|
|
|
|
test_points = [point_with_empty_features, point_with_invalid_geodata]
|
|
name = subject.send(:suggest_place_name, test_points)
|
|
|
|
expect(name).to be_nil
|
|
end
|
|
end
|
|
end
|
|
end
|