Handle unfinished tracks

This commit is contained in:
Eugene Burmakin 2025-07-07 18:59:42 +02:00
parent 7619feff69
commit 92a15c8ad3
11 changed files with 1210 additions and 147 deletions

View file

@ -0,0 +1,171 @@
# frozen_string_literal: true
class IncrementalTrackGeneratorJob < ApplicationJob
include Tracks::Segmentation
include Tracks::TrackBuilder
queue_as :default
sidekiq_options retry: 3
attr_reader :user, :day, :grace_period_minutes
# Process incremental track generation for a user
# @param user_id [Integer] ID of the user to process
# @param day [String, Date] day to process (defaults to today)
# @param grace_period_minutes [Integer] grace period to avoid finalizing recent tracks (default 5)
def perform(user_id, day = nil, grace_period_minutes = 5)
@user = User.find(user_id)
@day = day ? Date.parse(day.to_s) : Date.current
@grace_period_minutes = grace_period_minutes
Rails.logger.info "Starting incremental track generation for user #{user.id}, day #{@day}"
Track.transaction do
process_incremental_tracks
end
rescue StandardError => e
Rails.logger.error "IncrementalTrackGeneratorJob failed for user #{user_id}, day #{@day}: #{e.message}"
ExceptionReporter.call(e, 'Incremental track generation failed')
raise e
end
private
def process_incremental_tracks
# 1. Find the last track for this day
last_track = Track.last_for_day(user, day)
# 2. Load new points (after the last track)
new_points = load_new_points(last_track)
return if new_points.empty?
# 3. Load any buffered points from Redis
buffer = Tracks::RedisBuffer.new(user.id, day)
buffered_points = buffer.retrieve
# 4. Merge buffered points with new points
all_points = merge_and_sort_points(buffered_points, new_points)
return if all_points.empty?
# 5. Apply segmentation logic
segments = split_points_into_segments(all_points)
# 6. Process each segment
segments.each do |segment_points|
process_segment(segment_points, buffer)
end
Rails.logger.info "Completed incremental track generation for user #{user.id}, day #{day}"
end
def load_new_points(last_track)
# Start from the end of the last track, or beginning of day if no tracks exist
start_timestamp = if last_track
last_track.end_at.to_i + 1 # Start from 1 second after last track ended
else
day.beginning_of_day.to_i
end
end_timestamp = day.end_of_day.to_i
user.tracked_points
.where.not(lonlat: nil)
.where.not(timestamp: nil)
.where(timestamp: start_timestamp..end_timestamp)
.where(track_id: nil) # Only process points not already assigned to tracks
.order(:timestamp)
.to_a
end
def merge_and_sort_points(buffered_points, new_points)
# Convert buffered point hashes back to a format we can work with
combined_points = []
# Add buffered points (they're hashes, so we need to handle them appropriately)
combined_points.concat(buffered_points) if buffered_points.any?
# Add new points (these are Point objects)
combined_points.concat(new_points)
# Sort by timestamp
combined_points.sort_by { |point| point_timestamp(point) }
end
def process_segment(segment_points, buffer)
return if segment_points.size < 2
if should_finalize_segment?(segment_points, grace_period_minutes)
# This segment has a large enough gap - finalize it as a track
finalize_segment_as_track(segment_points)
# Clear any related buffer since these points are now in a finalized track
buffer.clear if segment_includes_buffered_points?(segment_points)
else
# This segment is still in progress - store it in Redis buffer
store_segment_in_buffer(segment_points, buffer)
end
end
def finalize_segment_as_track(segment_points)
# Separate Point objects from hashes
point_objects = segment_points.select { |p| p.is_a?(Point) }
point_hashes = segment_points.select { |p| p.is_a?(Hash) }
# For point hashes, we need to load the actual Point objects
if point_hashes.any?
point_ids = point_hashes.map { |p| p[:id] || p['id'] }.compact
hash_point_objects = Point.where(id: point_ids).to_a
point_objects.concat(hash_point_objects)
end
# Sort by timestamp to ensure correct order
point_objects.sort_by!(&:timestamp)
return if point_objects.size < 2
# Create the track using existing logic
track = create_track_from_points(point_objects)
if track&.persisted?
Rails.logger.info "Finalized track #{track.id} with #{point_objects.size} points for user #{user.id}"
else
Rails.logger.error "Failed to create track from #{point_objects.size} points for user #{user.id}"
end
end
def store_segment_in_buffer(segment_points, buffer)
# Only store Point objects in buffer (convert hashes to Point objects if needed)
points_to_store = segment_points.select { |p| p.is_a?(Point) }
# If we have hashes, load the corresponding Point objects
point_hashes = segment_points.select { |p| p.is_a?(Hash) }
if point_hashes.any?
point_ids = point_hashes.map { |p| p[:id] || p['id'] }.compact
hash_point_objects = Point.where(id: point_ids).to_a
points_to_store.concat(hash_point_objects)
end
points_to_store.sort_by!(&:timestamp)
buffer.store(points_to_store)
Rails.logger.debug "Stored #{points_to_store.size} points in buffer for user #{user.id}, day #{day}"
end
def segment_includes_buffered_points?(segment_points)
# Check if any points in the segment are hashes (indicating they came from buffer)
segment_points.any? { |p| p.is_a?(Hash) }
end
# Required by Tracks::Segmentation module
def distance_threshold_meters
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i || 500
end
def time_threshold_minutes
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i || 60
end
end

View file

@ -10,4 +10,18 @@ class Track < ApplicationRecord
validates :distance, :avg_speed, :duration, numericality: { greater_than_or_equal_to: 0 }
after_update :recalculate_path_and_distance!, if: -> { points.exists? && (saved_change_to_start_at? || saved_change_to_end_at?) }
# Find the last track for a user on a specific day
# @param user [User] the user to find tracks for
# @param day [Date, Time] the day to search for tracks
# @return [Track, nil] the last track for that day or nil if none found
def self.last_for_day(user, day)
day_start = day.beginning_of_day
day_end = day.end_of_day
where(user: user)
.where(end_at: day_start..day_end)
.order(end_at: :desc)
.first
end
end

View file

@ -1,6 +1,9 @@
# frozen_string_literal: true
class Tracks::CreateFromPoints
include Tracks::Segmentation
include Tracks::TrackBuilder
attr_reader :user, :distance_threshold_meters, :time_threshold_minutes, :start_at, :end_at
def initialize(user, start_at: nil, end_at: nil)
@ -22,7 +25,7 @@ class Tracks::CreateFromPoints
tracks_to_delete = start_at || end_at ? scoped_tracks_for_deletion : user.tracks
tracks_to_delete.destroy_all
track_segments = split_points_into_tracks
track_segments = split_points_into_segments(user_points)
track_segments.each do |segment_points|
next if segment_points.size < 2
@ -63,149 +66,4 @@ class Tracks::CreateFromPoints
Time.zone.at(end_at), Time.zone.at(start_at)
)
end
def split_points_into_tracks
return [] if user_points.empty?
track_segments = []
current_segment = []
# Use .each instead of find_each to preserve sequential processing
# find_each processes in batches which breaks track segmentation logic
user_points.each do |point|
if should_start_new_track?(point, current_segment.last)
# Finalize current segment if it has enough points
track_segments << current_segment if current_segment.size >= 2
current_segment = [point]
else
current_segment << point
end
end
# Don't forget the last segment
track_segments << current_segment if current_segment.size >= 2
track_segments
end
def should_start_new_track?(current_point, previous_point)
return false if previous_point.nil?
# Check time threshold (convert minutes to seconds)
time_diff_seconds = current_point.timestamp - previous_point.timestamp
time_threshold_seconds = time_threshold_minutes.to_i * 60
return true if time_diff_seconds > time_threshold_seconds
# Check distance threshold - convert km to meters to match frontend logic
distance_km = calculate_distance_kilometers(previous_point, current_point)
distance_meters = distance_km * 1000 # Convert km to meters
return true if distance_meters > distance_threshold_meters
false
end
def calculate_distance_kilometers(point1, point2)
# Use Geocoder to match behavior with frontend (same library used elsewhere in app)
Geocoder::Calculations.distance_between(
[point1.lat, point1.lon], [point2.lat, point2.lon], units: :km
)
end
def create_track_from_points(points)
track = Track.new(
user_id: user.id,
start_at: Time.zone.at(points.first.timestamp),
end_at: Time.zone.at(points.last.timestamp),
original_path: build_path(points)
)
# Calculate track statistics
track.distance = calculate_track_distance(points)
track.duration = calculate_duration(points)
track.avg_speed = calculate_average_speed(track.distance, track.duration)
# Calculate elevation statistics
elevation_stats = calculate_elevation_stats(points)
track.elevation_gain = elevation_stats[:gain]
track.elevation_loss = elevation_stats[:loss]
track.elevation_max = elevation_stats[:max]
track.elevation_min = elevation_stats[:min]
if track.save!
Point.where(id: points.map(&:id)).update_all(track_id: track.id)
track
else
Rails.logger.error "Failed to create track for user #{user.id}: #{track.errors.full_messages.join(', ')}"
nil
end
end
def build_path(points)
Tracks::BuildPath.new(points.map(&:lonlat)).call
end
def calculate_track_distance(points)
# Use the existing total_distance method with user's preferred unit
distance_in_user_unit = Point.total_distance(points, user.safe_settings.distance_unit || 'km')
# Convert to meters for storage (Track model expects distance in meters)
case user.safe_settings.distance_unit
when 'miles', 'mi'
(distance_in_user_unit * 1609.344).round # miles to meters
else
(distance_in_user_unit * 1000).round # km to meters
end
end
def calculate_duration(points)
# Duration in seconds
points.last.timestamp - points.first.timestamp
end
def calculate_average_speed(distance_meters, duration_seconds)
return 0.0 if duration_seconds <= 0 || distance_meters <= 0
# Speed in meters per second, then convert to km/h for storage
speed_mps = distance_meters.to_f / duration_seconds
(speed_mps * 3.6).round(2) # m/s to km/h
end
def calculate_elevation_stats(points)
altitudes = points.map(&:altitude).compact
return default_elevation_stats if altitudes.empty?
elevation_gain = 0
elevation_loss = 0
previous_altitude = altitudes.first
altitudes[1..].each do |altitude|
diff = altitude - previous_altitude
if diff > 0
elevation_gain += diff
else
elevation_loss += diff.abs
end
previous_altitude = altitude
end
{
gain: elevation_gain.round,
loss: elevation_loss.round,
max: altitudes.max,
min: altitudes.min
}
end
def default_elevation_stats
{
gain: 0,
loss: 0,
max: 0,
min: 0
}
end
end

View file

@ -0,0 +1,78 @@
# frozen_string_literal: true
class Tracks::RedisBuffer
BUFFER_PREFIX = 'track_buffer'
BUFFER_EXPIRY = 7.days
attr_reader :user_id, :day
def initialize(user_id, day)
@user_id = user_id
@day = day.is_a?(Date) ? day : Date.parse(day.to_s)
end
# Store buffered points for an incomplete track segment
# @param points [Array<Point>] array of Point objects to buffer
def store(points)
return if points.empty?
points_data = serialize_points(points)
redis_key = buffer_key
Rails.cache.write(redis_key, points_data, expires_in: BUFFER_EXPIRY)
Rails.logger.debug "Stored #{points.size} points in buffer for user #{user_id}, day #{day}"
end
# Retrieve buffered points for the user/day combination
# @return [Array<Hash>] array of point hashes or empty array if no buffer exists
def retrieve
redis_key = buffer_key
cached_data = Rails.cache.read(redis_key)
return [] unless cached_data
deserialize_points(cached_data)
rescue StandardError => e
Rails.logger.error "Failed to retrieve buffered points for user #{user_id}, day #{day}: #{e.message}"
[]
end
# Clear the buffer for the user/day combination
def clear
redis_key = buffer_key
Rails.cache.delete(redis_key)
Rails.logger.debug "Cleared buffer for user #{user_id}, day #{day}"
end
# Check if a buffer exists for the user/day combination
# @return [Boolean] true if buffer exists, false otherwise
def exists?
Rails.cache.exist?(buffer_key)
end
private
def buffer_key
"#{BUFFER_PREFIX}:#{user_id}:#{day.strftime('%Y-%m-%d')}"
end
def serialize_points(points)
points.map do |point|
{
id: point.id,
lonlat: point.lonlat.to_s,
timestamp: point.timestamp,
lat: point.lat,
lon: point.lon,
altitude: point.altitude,
velocity: point.velocity,
battery: point.battery,
user_id: point.user_id
}
end
end
def deserialize_points(points_data)
points_data || []
end
end

View file

@ -0,0 +1,121 @@
# frozen_string_literal: true
module Tracks::Segmentation
extend ActiveSupport::Concern
private
# Split an array of points into track segments based on time and distance thresholds
# @param points [Array] array of Point objects or point hashes
# @return [Array<Array>] array of point segments
def split_points_into_segments(points)
return [] if points.empty?
segments = []
current_segment = []
points.each do |point|
if should_start_new_segment?(point, current_segment.last)
# Finalize current segment if it has enough points
segments << current_segment if current_segment.size >= 2
current_segment = [point]
else
current_segment << point
end
end
# Don't forget the last segment
segments << current_segment if current_segment.size >= 2
segments
end
# Check if a new segment should start based on time and distance thresholds
# @param current_point [Point, Hash] current point (Point object or hash)
# @param previous_point [Point, Hash, nil] previous point or nil
# @return [Boolean] true if new segment should start
def should_start_new_segment?(current_point, previous_point)
return false if previous_point.nil?
# Check time threshold (convert minutes to seconds)
current_timestamp = point_timestamp(current_point)
previous_timestamp = point_timestamp(previous_point)
time_diff_seconds = current_timestamp - previous_timestamp
time_threshold_seconds = time_threshold_minutes.to_i * 60
return true if time_diff_seconds > time_threshold_seconds
# Check distance threshold - convert km to meters to match frontend logic
distance_km = calculate_distance_kilometers_between_points(previous_point, current_point)
distance_meters = distance_km * 1000 # Convert km to meters
return true if distance_meters > distance_threshold_meters
false
end
# Calculate distance between two points in kilometers
# @param point1 [Point, Hash] first point
# @param point2 [Point, Hash] second point
# @return [Float] distance in kilometers
def calculate_distance_kilometers_between_points(point1, point2)
lat1, lon1 = point_coordinates(point1)
lat2, lon2 = point_coordinates(point2)
# Use Geocoder to match behavior with frontend (same library used elsewhere in app)
Geocoder::Calculations.distance_between([lat1, lon1], [lat2, lon2], units: :km)
end
# Check if a segment should be finalized (has a large enough gap at the end)
# @param segment_points [Array] array of points in the segment
# @param grace_period_minutes [Integer] grace period in minutes (default 5)
# @return [Boolean] true if segment should be finalized
def should_finalize_segment?(segment_points, grace_period_minutes = 5)
return false if segment_points.size < 2
last_point = segment_points.last
last_timestamp = point_timestamp(last_point)
current_time = Time.current.to_i
# Don't finalize if the last point is too recent (within grace period)
time_since_last_point = current_time - last_timestamp
grace_period_seconds = grace_period_minutes * 60
time_since_last_point > grace_period_seconds
end
# Extract timestamp from point (handles both Point objects and hashes)
# @param point [Point, Hash] point object or hash
# @return [Integer] timestamp as integer
def point_timestamp(point)
if point.respond_to?(:timestamp)
point.timestamp
elsif point.is_a?(Hash)
point[:timestamp] || point['timestamp']
else
raise ArgumentError, "Invalid point type: #{point.class}"
end
end
# Extract coordinates from point (handles both Point objects and hashes)
# @param point [Point, Hash] point object or hash
# @return [Array<Float>] [lat, lon] coordinates
def point_coordinates(point)
if point.respond_to?(:lat) && point.respond_to?(:lon)
[point.lat, point.lon]
elsif point.is_a?(Hash)
[point[:lat] || point['lat'], point[:lon] || point['lon']]
else
raise ArgumentError, "Invalid point type: #{point.class}"
end
end
# These methods need to be implemented by the including class
def distance_threshold_meters
raise NotImplementedError, "Including class must implement distance_threshold_meters"
end
def time_threshold_minutes
raise NotImplementedError, "Including class must implement time_threshold_minutes"
end
end

View file

@ -0,0 +1,129 @@
# frozen_string_literal: true
module Tracks::TrackBuilder
extend ActiveSupport::Concern
# Create a track from an array of points
# @param points [Array<Point>] array of Point objects
# @return [Track, nil] created track or nil if creation failed
def create_track_from_points(points)
return nil if points.size < 2
track = Track.new(
user_id: user.id,
start_at: Time.zone.at(points.first.timestamp),
end_at: Time.zone.at(points.last.timestamp),
original_path: build_path(points)
)
# Calculate track statistics
track.distance = calculate_track_distance(points)
track.duration = calculate_duration(points)
track.avg_speed = calculate_average_speed(track.distance, track.duration)
# Calculate elevation statistics
elevation_stats = calculate_elevation_stats(points)
track.elevation_gain = elevation_stats[:gain]
track.elevation_loss = elevation_stats[:loss]
track.elevation_max = elevation_stats[:max]
track.elevation_min = elevation_stats[:min]
if track.save!
Point.where(id: points.map(&:id)).update_all(track_id: track.id)
track
else
Rails.logger.error "Failed to create track for user #{user.id}: #{track.errors.full_messages.join(', ')}"
nil
end
end
# Build path from points using existing BuildPath service
# @param points [Array<Point>] array of Point objects
# @return [String] LineString representation of the path
def build_path(points)
Tracks::BuildPath.new(points.map(&:lonlat)).call
end
# Calculate track distance in meters for storage
# @param points [Array<Point>] array of Point objects
# @return [Integer] distance in meters
def calculate_track_distance(points)
distance_in_user_unit = Point.total_distance(points, user.safe_settings.distance_unit || 'km')
# Convert to meters for storage (Track model expects distance in meters)
case user.safe_settings.distance_unit
when 'miles', 'mi'
(distance_in_user_unit * 1609.344).round # miles to meters
else
(distance_in_user_unit * 1000).round # km to meters
end
end
# Calculate track duration in seconds
# @param points [Array<Point>] array of Point objects
# @return [Integer] duration in seconds
def calculate_duration(points)
points.last.timestamp - points.first.timestamp
end
# Calculate average speed in km/h
# @param distance_meters [Numeric] distance in meters
# @param duration_seconds [Numeric] duration in seconds
# @return [Float] average speed in km/h
def calculate_average_speed(distance_meters, duration_seconds)
return 0.0 if duration_seconds <= 0 || distance_meters <= 0
# Speed in meters per second, then convert to km/h for storage
speed_mps = distance_meters.to_f / duration_seconds
(speed_mps * 3.6).round(2) # m/s to km/h
end
# Calculate elevation statistics from points
# @param points [Array<Point>] array of Point objects
# @return [Hash] elevation statistics hash
def calculate_elevation_stats(points)
altitudes = points.map(&:altitude).compact
return default_elevation_stats if altitudes.empty?
elevation_gain = 0
elevation_loss = 0
previous_altitude = altitudes.first
altitudes[1..].each do |altitude|
diff = altitude - previous_altitude
if diff > 0
elevation_gain += diff
else
elevation_loss += diff.abs
end
previous_altitude = altitude
end
{
gain: elevation_gain.round,
loss: elevation_loss.round,
max: altitudes.max,
min: altitudes.min
}
end
# Default elevation statistics when no altitude data is available
# @return [Hash] default elevation statistics
def default_elevation_stats
{
gain: 0,
loss: 0,
max: 0,
min: 0
}
end
private
# This method must be implemented by the including class
# @return [User] the user for which tracks are being created
def user
raise NotImplementedError, "Including class must implement user method"
end
end

View file

@ -28,7 +28,7 @@ Rails.application.configure do
# Show full error reports and disable caching.
config.consider_all_requests_local = true
config.action_controller.perform_caching = false
config.cache_store = :null_store
config.cache_store = :redis_cache_store, { url: "#{ENV.fetch('REDIS_URL', 'redis://localhost:6379')}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" }
# Render exception templates for rescuable exceptions and raise for other exceptions.
config.action_dispatch.show_exceptions = :rescuable

View file

@ -17,6 +17,106 @@ RSpec.describe Track, type: :model do
it { is_expected.to validate_numericality_of(:duration).is_greater_than_or_equal_to(0) }
end
describe '.last_for_day' do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:target_day) { Date.current }
context 'when user has tracks on the target day' do
let!(:early_track) do
create(:track, user: user,
start_at: target_day.beginning_of_day + 1.hour,
end_at: target_day.beginning_of_day + 2.hours)
end
let!(:late_track) do
create(:track, user: user,
start_at: target_day.beginning_of_day + 3.hours,
end_at: target_day.beginning_of_day + 4.hours)
end
let!(:other_user_track) do
create(:track, user: other_user,
start_at: target_day.beginning_of_day + 5.hours,
end_at: target_day.beginning_of_day + 6.hours)
end
it 'returns the track that ends latest on that day for the user' do
result = Track.last_for_day(user, target_day)
expect(result).to eq(late_track)
end
it 'does not return tracks from other users' do
result = Track.last_for_day(user, target_day)
expect(result).not_to eq(other_user_track)
end
end
context 'when user has tracks on different days' do
let!(:yesterday_track) do
create(:track, user: user,
start_at: target_day.yesterday.beginning_of_day + 1.hour,
end_at: target_day.yesterday.beginning_of_day + 2.hours)
end
let!(:tomorrow_track) do
create(:track, user: user,
start_at: target_day.tomorrow.beginning_of_day + 1.hour,
end_at: target_day.tomorrow.beginning_of_day + 2.hours)
end
let!(:target_day_track) do
create(:track, user: user,
start_at: target_day.beginning_of_day + 1.hour,
end_at: target_day.beginning_of_day + 2.hours)
end
it 'returns only the track from the target day' do
result = Track.last_for_day(user, target_day)
expect(result).to eq(target_day_track)
end
end
context 'when user has no tracks on the target day' do
let!(:yesterday_track) do
create(:track, user: user,
start_at: target_day.yesterday.beginning_of_day + 1.hour,
end_at: target_day.yesterday.beginning_of_day + 2.hours)
end
it 'returns nil' do
result = Track.last_for_day(user, target_day)
expect(result).to be_nil
end
end
context 'when passing a Time object instead of Date' do
let!(:track) do
create(:track, user: user,
start_at: target_day.beginning_of_day + 1.hour,
end_at: target_day.beginning_of_day + 2.hours)
end
it 'correctly handles Time objects' do
result = Track.last_for_day(user, target_day.to_time)
expect(result).to eq(track)
end
end
context 'when track spans midnight' do
let!(:spanning_track) do
create(:track, user: user,
start_at: target_day.beginning_of_day - 1.hour,
end_at: target_day.beginning_of_day + 1.hour)
end
it 'includes tracks that end on the target day' do
result = Track.last_for_day(user, target_day)
expect(result).to eq(spanning_track)
end
end
end
describe 'Calculateable concern' do
let(:user) { create(:user) }
let(:track) { create(:track, user: user, distance: 1000, avg_speed: 25, duration: 3600) }

View file

@ -0,0 +1,238 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tracks::RedisBuffer do
let(:user_id) { 123 }
let(:day) { Date.current }
let(:buffer) { described_class.new(user_id, day) }
describe '#initialize' do
it 'stores user_id and converts day to Date' do
expect(buffer.user_id).to eq(user_id)
expect(buffer.day).to eq(day)
expect(buffer.day).to be_a(Date)
end
it 'handles string date input' do
buffer = described_class.new(user_id, '2024-01-15')
expect(buffer.day).to eq(Date.parse('2024-01-15'))
end
it 'handles Time input' do
time = Time.current
buffer = described_class.new(user_id, time)
expect(buffer.day).to eq(time.to_date)
end
end
describe '#store' do
let(:user) { create(:user) }
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)', timestamp: 30.minutes.ago.to_i)
]
end
it 'stores points in Redis cache' do
expect(Rails.cache).to receive(:write).with(
"track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}",
anything,
expires_in: 7.days
)
buffer.store(points)
end
it 'serializes points correctly' do
buffer.store(points)
stored_data = Rails.cache.read("track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}")
expect(stored_data).to be_an(Array)
expect(stored_data.size).to eq(2)
first_point = stored_data.first
expect(first_point[:id]).to eq(points.first.id)
expect(first_point[:timestamp]).to eq(points.first.timestamp)
expect(first_point[:lat]).to eq(points.first.lat)
expect(first_point[:lon]).to eq(points.first.lon)
expect(first_point[:user_id]).to eq(points.first.user_id)
end
it 'does nothing when given empty array' do
expect(Rails.cache).not_to receive(:write)
buffer.store([])
end
it 'logs debug message when storing points' do
expect(Rails.logger).to receive(:debug).with(
"Stored 2 points in buffer for user #{user_id}, day #{day}"
)
buffer.store(points)
end
end
describe '#retrieve' do
context 'when buffer exists' do
let(:stored_data) do
[
{
id: 1,
lonlat: 'POINT(-74.0060 40.7128)',
timestamp: 1.hour.ago.to_i,
lat: 40.7128,
lon: -74.0060,
altitude: 100,
velocity: 5.0,
battery: 80,
user_id: user_id
},
{
id: 2,
lonlat: 'POINT(-74.0070 40.7130)',
timestamp: 30.minutes.ago.to_i,
lat: 40.7130,
lon: -74.0070,
altitude: 105,
velocity: 6.0,
battery: 75,
user_id: user_id
}
]
end
before do
Rails.cache.write(
"track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}",
stored_data
)
end
it 'returns the stored point data' do
result = buffer.retrieve
expect(result).to eq(stored_data)
expect(result.size).to eq(2)
end
end
context 'when buffer does not exist' do
it 'returns empty array' do
result = buffer.retrieve
expect(result).to eq([])
end
end
context 'when Redis read fails' do
before do
allow(Rails.cache).to receive(:read).and_raise(StandardError.new('Redis error'))
end
it 'returns empty array and logs error' do
expect(Rails.logger).to receive(:error).with(
"Failed to retrieve buffered points for user #{user_id}, day #{day}: Redis error"
)
result = buffer.retrieve
expect(result).to eq([])
end
end
end
describe '#clear' do
before do
Rails.cache.write(
"track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}",
[{ id: 1, timestamp: 1.hour.ago.to_i }]
)
end
it 'deletes the buffer from cache' do
buffer.clear
expect(Rails.cache.read("track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}")).to be_nil
end
it 'logs debug message' do
expect(Rails.logger).to receive(:debug).with(
"Cleared buffer for user #{user_id}, day #{day}"
)
buffer.clear
end
end
describe '#exists?' do
context 'when buffer exists' do
before do
Rails.cache.write(
"track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}",
[{ id: 1 }]
)
end
it 'returns true' do
expect(buffer.exists?).to be true
end
end
context 'when buffer does not exist' do
it 'returns false' do
expect(buffer.exists?).to be false
end
end
end
describe 'buffer key generation' do
it 'generates correct Redis key format' do
expected_key = "track_buffer:#{user_id}:#{day.strftime('%Y-%m-%d')}"
# Access private method for testing
actual_key = buffer.send(:buffer_key)
expect(actual_key).to eq(expected_key)
end
it 'handles different date formats consistently' do
date_as_string = '2024-03-15'
date_as_date = Date.parse(date_as_string)
buffer1 = described_class.new(user_id, date_as_string)
buffer2 = described_class.new(user_id, date_as_date)
expect(buffer1.send(:buffer_key)).to eq(buffer2.send(:buffer_key))
end
end
describe 'integration test' do
let(:user) { create(:user) }
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 2.hours.ago.to_i),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)', timestamp: 1.hour.ago.to_i)
]
end
it 'stores and retrieves points correctly' do
# Store points
buffer.store(points)
expect(buffer.exists?).to be true
# Retrieve points
retrieved_points = buffer.retrieve
expect(retrieved_points.size).to eq(2)
# Verify data integrity
expect(retrieved_points.first[:id]).to eq(points.first.id)
expect(retrieved_points.last[:id]).to eq(points.last.id)
# Clear buffer
buffer.clear
expect(buffer.exists?).to be false
expect(buffer.retrieve).to eq([])
end
end
end

View file

@ -0,0 +1,346 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Tracks::TrackBuilder do
# Create a test class that includes the concern for testing
let(:test_class) do
Class.new do
include Tracks::TrackBuilder
def initialize(user)
@user = user
end
private
attr_reader :user
end
end
let(:user) { create(:user) }
let(:builder) { test_class.new(user) }
before do
# Set up user settings for consistent testing
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return('km')
end
describe '#create_track_from_points' do
context 'with valid points' do
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)',
timestamp: 2.hours.ago.to_i, altitude: 100),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)',
timestamp: 1.hour.ago.to_i, altitude: 110),
create(:point, user: user, lonlat: 'POINT(-74.0080 40.7132)',
timestamp: 30.minutes.ago.to_i, altitude: 105)
]
end
it 'creates a track with correct attributes' do
track = builder.create_track_from_points(points)
expect(track).to be_persisted
expect(track.user).to eq(user)
expect(track.start_at).to be_within(1.second).of(Time.zone.at(points.first.timestamp))
expect(track.end_at).to be_within(1.second).of(Time.zone.at(points.last.timestamp))
expect(track.distance).to be > 0
expect(track.duration).to eq(90.minutes.to_i)
expect(track.avg_speed).to be > 0
expect(track.original_path).to be_present
end
it 'calculates elevation statistics correctly' do
track = builder.create_track_from_points(points)
expect(track.elevation_gain).to eq(10) # 110 - 100
expect(track.elevation_loss).to eq(5) # 110 - 105
expect(track.elevation_max).to eq(110)
expect(track.elevation_min).to eq(100)
end
it 'associates points with the track' do
track = builder.create_track_from_points(points)
points.each(&:reload)
expect(points.map(&:track)).to all(eq(track))
end
end
context 'with insufficient points' do
let(:single_point) { [create(:point, user: user)] }
it 'returns nil for single point' do
result = builder.create_track_from_points(single_point)
expect(result).to be_nil
end
it 'returns nil for empty array' do
result = builder.create_track_from_points([])
expect(result).to be_nil
end
end
context 'when track save fails' do
let(:points) do
[
create(:point, user: user, timestamp: 1.hour.ago.to_i),
create(:point, user: user, timestamp: 30.minutes.ago.to_i)
]
end
before do
allow_any_instance_of(Track).to receive(:save!).and_return(false)
end
it 'returns nil and logs error' do
expect(Rails.logger).to receive(:error).with(
/Failed to create track for user #{user.id}/
)
result = builder.create_track_from_points(points)
expect(result).to be_nil
end
end
end
describe '#build_path' do
let(:points) do
[
create(:point, lonlat: 'POINT(-74.0060 40.7128)'),
create(:point, lonlat: 'POINT(-74.0070 40.7130)')
]
end
it 'builds path using Tracks::BuildPath service' do
expect(Tracks::BuildPath).to receive(:new).with(
points.map(&:lonlat)
).and_call_original
result = builder.build_path(points)
expect(result).to respond_to(:as_text) # RGeo geometry object
end
end
describe '#calculate_track_distance' do
let(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)'),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)')
]
end
context 'with km unit' do
before do
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return('km')
allow(Point).to receive(:total_distance).and_return(1.5) # 1.5 km
end
it 'converts km to meters' do
result = builder.calculate_track_distance(points)
expect(result).to eq(1500) # 1.5 km = 1500 meters
end
end
context 'with miles unit' do
before do
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return('miles')
allow(Point).to receive(:total_distance).and_return(1.0) # 1 mile
end
it 'converts miles to meters' do
result = builder.calculate_track_distance(points)
expect(result).to eq(1609) # 1 mile ≈ 1609 meters
end
end
context 'with nil distance unit (defaults to km)' do
before do
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return(nil)
allow(Point).to receive(:total_distance).and_return(2.0)
end
it 'defaults to km and converts to meters' do
result = builder.calculate_track_distance(points)
expect(result).to eq(2000)
end
end
end
describe '#calculate_duration' do
let(:start_time) { 2.hours.ago.to_i }
let(:end_time) { 1.hour.ago.to_i }
let(:points) do
[
double(timestamp: start_time),
double(timestamp: end_time)
]
end
it 'calculates duration in seconds' do
result = builder.calculate_duration(points)
expect(result).to eq(1.hour.to_i)
end
end
describe '#calculate_average_speed' do
context 'with valid distance and duration' do
it 'calculates speed in km/h' do
distance_meters = 1000 # 1 km
duration_seconds = 3600 # 1 hour
result = builder.calculate_average_speed(distance_meters, duration_seconds)
expect(result).to eq(1.0) # 1 km/h
end
it 'rounds to 2 decimal places' do
distance_meters = 1500 # 1.5 km
duration_seconds = 1800 # 30 minutes
result = builder.calculate_average_speed(distance_meters, duration_seconds)
expect(result).to eq(3.0) # 3 km/h
end
end
context 'with invalid inputs' do
it 'returns 0.0 for zero duration' do
result = builder.calculate_average_speed(1000, 0)
expect(result).to eq(0.0)
end
it 'returns 0.0 for zero distance' do
result = builder.calculate_average_speed(0, 3600)
expect(result).to eq(0.0)
end
it 'returns 0.0 for negative duration' do
result = builder.calculate_average_speed(1000, -3600)
expect(result).to eq(0.0)
end
end
end
describe '#calculate_elevation_stats' do
context 'with elevation data' do
let(:points) do
[
double(altitude: 100),
double(altitude: 150),
double(altitude: 120),
double(altitude: 180),
double(altitude: 160)
]
end
it 'calculates elevation gain correctly' do
result = builder.calculate_elevation_stats(points)
expect(result[:gain]).to eq(110) # (150-100) + (180-120) = 50 + 60 = 110
end
it 'calculates elevation loss correctly' do
result = builder.calculate_elevation_stats(points)
expect(result[:loss]).to eq(50) # (150-120) + (180-160) = 30 + 20 = 50
end
it 'finds max elevation' do
result = builder.calculate_elevation_stats(points)
expect(result[:max]).to eq(180)
end
it 'finds min elevation' do
result = builder.calculate_elevation_stats(points)
expect(result[:min]).to eq(100)
end
end
context 'with no elevation data' do
let(:points) do
[
double(altitude: nil),
double(altitude: nil)
]
end
it 'returns default elevation stats' do
result = builder.calculate_elevation_stats(points)
expect(result).to eq({
gain: 0,
loss: 0,
max: 0,
min: 0
})
end
end
context 'with mixed elevation data' do
let(:points) do
[
double(altitude: 100),
double(altitude: nil),
double(altitude: 150)
]
end
it 'ignores nil values' do
result = builder.calculate_elevation_stats(points)
expect(result[:gain]).to eq(50) # 150 - 100
expect(result[:loss]).to eq(0)
expect(result[:max]).to eq(150)
expect(result[:min]).to eq(100)
end
end
end
describe '#default_elevation_stats' do
it 'returns hash with zero values' do
result = builder.default_elevation_stats
expect(result).to eq({
gain: 0,
loss: 0,
max: 0,
min: 0
})
end
end
describe 'user method requirement' do
let(:invalid_class) do
Class.new do
include Tracks::TrackBuilder
# Does not implement user method
end
end
it 'raises NotImplementedError when user method is not implemented' do
invalid_builder = invalid_class.new
expect { invalid_builder.send(:user) }.to raise_error(
NotImplementedError,
"Including class must implement user method"
)
end
end
describe 'integration test' do
let!(:points) do
[
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)',
timestamp: 2.hours.ago.to_i, altitude: 100),
create(:point, user: user, lonlat: 'POINT(-74.0070 40.7130)',
timestamp: 1.hour.ago.to_i, altitude: 120)
]
end
it 'creates a complete track end-to-end' do
expect { builder.create_track_from_points(points) }.to change(Track, :count).by(1)
track = Track.last
expect(track.user).to eq(user)
expect(track.points).to match_array(points)
expect(track.distance).to be > 0
expect(track.duration).to eq(1.hour.to_i)
expect(track.elevation_gain).to eq(20)
end
end
end

8
spec/support/redis.rb Normal file
View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
RSpec.configure do |config|
config.before(:each) do
# Clear the cache before each test
Rails.cache.clear
end
end