mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Handle unfinished tracks
This commit is contained in:
parent
7619feff69
commit
92a15c8ad3
11 changed files with 1210 additions and 147 deletions
171
app/jobs/incremental_track_generator_job.rb
Normal file
171
app/jobs/incremental_track_generator_job.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
app/services/tracks/redis_buffer.rb
Normal file
78
app/services/tracks/redis_buffer.rb
Normal 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
|
||||
121
app/services/tracks/segmentation.rb
Normal file
121
app/services/tracks/segmentation.rb
Normal 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
|
||||
129
app/services/tracks/track_builder.rb
Normal file
129
app/services/tracks/track_builder.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
238
spec/services/tracks/redis_buffer_spec.rb
Normal file
238
spec/services/tracks/redis_buffer_spec.rb
Normal 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
|
||||
346
spec/services/tracks/track_builder_spec.rb
Normal file
346
spec/services/tracks/track_builder_spec.rb
Normal 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
8
spec/support/redis.rb
Normal 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
|
||||
Loading…
Reference in a new issue