diff --git a/app/jobs/incremental_track_generator_job.rb b/app/jobs/incremental_track_generator_job.rb new file mode 100644 index 00000000..e85487fb --- /dev/null +++ b/app/jobs/incremental_track_generator_job.rb @@ -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 diff --git a/app/models/track.rb b/app/models/track.rb index b785bac8..f2a27742 100644 --- a/app/models/track.rb +++ b/app/models/track.rb @@ -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 diff --git a/app/services/tracks/create_from_points.rb b/app/services/tracks/create_from_points.rb index fdf783be..b5804cf1 100644 --- a/app/services/tracks/create_from_points.rb +++ b/app/services/tracks/create_from_points.rb @@ -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 diff --git a/app/services/tracks/redis_buffer.rb b/app/services/tracks/redis_buffer.rb new file mode 100644 index 00000000..55bc4d82 --- /dev/null +++ b/app/services/tracks/redis_buffer.rb @@ -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] 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] 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 diff --git a/app/services/tracks/segmentation.rb b/app/services/tracks/segmentation.rb new file mode 100644 index 00000000..e5c61387 --- /dev/null +++ b/app/services/tracks/segmentation.rb @@ -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 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] [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 diff --git a/app/services/tracks/track_builder.rb b/app/services/tracks/track_builder.rb new file mode 100644 index 00000000..25262456 --- /dev/null +++ b/app/services/tracks/track_builder.rb @@ -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] 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] 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] 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] 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] 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 diff --git a/config/environments/test.rb b/config/environments/test.rb index 048b1342..e138d076 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -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 diff --git a/spec/models/track_spec.rb b/spec/models/track_spec.rb index 59557010..91e821f5 100644 --- a/spec/models/track_spec.rb +++ b/spec/models/track_spec.rb @@ -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) } diff --git a/spec/services/tracks/redis_buffer_spec.rb b/spec/services/tracks/redis_buffer_spec.rb new file mode 100644 index 00000000..e50ab4cc --- /dev/null +++ b/spec/services/tracks/redis_buffer_spec.rb @@ -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 diff --git a/spec/services/tracks/track_builder_spec.rb b/spec/services/tracks/track_builder_spec.rb new file mode 100644 index 00000000..b97b5c48 --- /dev/null +++ b/spec/services/tracks/track_builder_spec.rb @@ -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 diff --git a/spec/support/redis.rb b/spec/support/redis.rb new file mode 100644 index 00000000..6ffa0528 --- /dev/null +++ b/spec/support/redis.rb @@ -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