mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Compare commits
13 commits
d742e18145
...
5f1bab4914
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f1bab4914 | ||
|
|
5ad660ccd4 | ||
|
|
9d616c7957 | ||
|
|
7cdb7d2f21 | ||
|
|
dc8460a948 | ||
|
|
91f4cf7c7a | ||
|
|
f5ef2ab9ef | ||
|
|
1f5325d9bb | ||
|
|
10777714b1 | ||
|
|
eca09ce3eb | ||
|
|
c31d09e5c3 | ||
|
|
f92f757a7a | ||
|
|
f9c93c0d3c |
49 changed files with 4231 additions and 1901 deletions
|
|
@ -1,22 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# This job is being run on daily basis to create tracks for all users.
|
|
||||||
# For each user, it starts from the end of their last track (or from their oldest point
|
|
||||||
# if no tracks exist) and processes points until the specified end_at time.
|
|
||||||
#
|
|
||||||
# To manually run for a specific time range:
|
|
||||||
# Tracks::BulkCreatingJob.perform_later(start_at: 1.week.ago, end_at: Time.current)
|
|
||||||
#
|
|
||||||
# To run for specific users only:
|
|
||||||
# Tracks::BulkCreatingJob.perform_later(user_ids: [1, 2, 3])
|
|
||||||
#
|
|
||||||
# To let the job determine start times automatically (recommended):
|
|
||||||
# Tracks::BulkCreatingJob.perform_later(end_at: Time.current)
|
|
||||||
class Tracks::BulkCreatingJob < ApplicationJob
|
|
||||||
queue_as :tracks
|
|
||||||
sidekiq_options retry: false
|
|
||||||
|
|
||||||
def perform(start_at: nil, end_at: 1.day.ago.end_of_day, user_ids: [])
|
|
||||||
Tracks::BulkTrackCreator.new(start_at:, end_at:, user_ids:).call
|
|
||||||
end
|
|
||||||
end
|
|
||||||
31
app/jobs/tracks/cleanup_job.rb
Normal file
31
app/jobs/tracks/cleanup_job.rb
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Lightweight cleanup job that runs weekly to catch any missed track generation.
|
||||||
|
#
|
||||||
|
# This provides a safety net while avoiding the overhead of daily bulk processing.
|
||||||
|
class Tracks::CleanupJob < ApplicationJob
|
||||||
|
queue_as :tracks
|
||||||
|
sidekiq_options retry: false
|
||||||
|
|
||||||
|
def perform(older_than: 1.day.ago)
|
||||||
|
users_with_old_untracked_points(older_than).find_each do |user|
|
||||||
|
Rails.logger.info "Processing missed tracks for user #{user.id}"
|
||||||
|
|
||||||
|
# Process only the old untracked points
|
||||||
|
Tracks::Generator.new(
|
||||||
|
user,
|
||||||
|
end_at: older_than,
|
||||||
|
mode: :incremental
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def users_with_old_untracked_points(older_than)
|
||||||
|
User.active.joins(:tracked_points)
|
||||||
|
.where(tracked_points: { track_id: nil, timestamp: ..older_than.to_i })
|
||||||
|
.having('COUNT(tracked_points.id) >= 2') # Only users with enough points for tracks
|
||||||
|
.group(:id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,11 +1,25 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Tracks::CreateJob < ApplicationJob
|
class Tracks::CreateJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :tracks
|
||||||
|
|
||||||
def perform(user_id, start_at: nil, end_at: nil, cleaning_strategy: :replace)
|
def perform(user_id, start_at: nil, end_at: nil, mode: :daily)
|
||||||
user = User.find(user_id)
|
user = User.find(user_id)
|
||||||
tracks_created = Tracks::CreateFromPoints.new(user, start_at:, end_at:, cleaning_strategy:).call
|
|
||||||
|
# Translate mode parameter to Generator mode
|
||||||
|
generator_mode = case mode
|
||||||
|
when :daily then :daily
|
||||||
|
when :none then :incremental
|
||||||
|
else :bulk
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate tracks and get the count of tracks created
|
||||||
|
tracks_created = Tracks::Generator.new(
|
||||||
|
user,
|
||||||
|
start_at: start_at,
|
||||||
|
end_at: end_at,
|
||||||
|
mode: generator_mode
|
||||||
|
).call
|
||||||
|
|
||||||
create_success_notification(user, tracks_created)
|
create_success_notification(user, tracks_created)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
|
|
|
||||||
12
app/jobs/tracks/incremental_check_job.rb
Normal file
12
app/jobs/tracks/incremental_check_job.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Tracks::IncrementalCheckJob < ApplicationJob
|
||||||
|
queue_as :tracks
|
||||||
|
|
||||||
|
def perform(user_id, point_id)
|
||||||
|
user = User.find(user_id)
|
||||||
|
point = Point.find(point_id)
|
||||||
|
|
||||||
|
Tracks::IncrementalProcessor.new(user, point).call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Tracks::IncrementalGeneratorJob < ApplicationJob
|
|
||||||
queue_as :default
|
|
||||||
sidekiq_options retry: 3
|
|
||||||
|
|
||||||
def perform(user_id, day = nil, grace_period_minutes = 5)
|
|
||||||
user = User.find(user_id)
|
|
||||||
day = day ? Date.parse(day.to_s) : Date.current
|
|
||||||
|
|
||||||
Rails.logger.info "Starting incremental track generation for user #{user.id}, day #{day}"
|
|
||||||
|
|
||||||
generator(user, day, grace_period_minutes).call
|
|
||||||
rescue StandardError => e
|
|
||||||
ExceptionReporter.call(e, 'Incremental track generation failed')
|
|
||||||
|
|
||||||
raise e
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generator(user, day, grace_period_minutes)
|
|
||||||
@generator ||= Tracks::Generator.new(
|
|
||||||
user,
|
|
||||||
point_loader: Tracks::PointLoaders::IncrementalLoader.new(user, day),
|
|
||||||
incomplete_segment_handler: Tracks::IncompleteSegmentHandlers::BufferHandler.new(user, day, grace_period_minutes),
|
|
||||||
track_cleaner: Tracks::Cleaners::NoOpCleaner.new(user)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -105,9 +105,6 @@ class Point < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def trigger_incremental_track_generation
|
def trigger_incremental_track_generation
|
||||||
point_date = Time.zone.at(timestamp).to_date
|
Tracks::IncrementalCheckJob.perform_later(user.id, id)
|
||||||
return if point_date < 1.day.ago.to_date
|
|
||||||
|
|
||||||
Tracks::IncrementalGeneratorJob.perform_later(user_id, point_date.to_s, 5)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ class PointsLimitExceeded
|
||||||
|
|
||||||
def call
|
def call
|
||||||
return false if DawarichSettings.self_hosted?
|
return false if DawarichSettings.self_hosted?
|
||||||
return true if @user.points.count >= points_limit
|
return true if @user.tracked_points.count >= points_limit
|
||||||
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Tracks
|
|
||||||
class BulkTrackCreator
|
|
||||||
def initialize(start_at: nil, end_at: 1.day.ago.end_of_day, user_ids: [])
|
|
||||||
@start_at = start_at&.to_datetime
|
|
||||||
@end_at = end_at&.to_datetime
|
|
||||||
@user_ids = user_ids
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
|
||||||
users.find_each do |user|
|
|
||||||
next if user.tracked_points.empty?
|
|
||||||
|
|
||||||
user_start_at = start_at || start_time(user)
|
|
||||||
|
|
||||||
next unless user.tracked_points.where(timestamp: user_start_at.to_i..end_at.to_i).exists?
|
|
||||||
|
|
||||||
Tracks::CreateJob.perform_later(
|
|
||||||
user.id,
|
|
||||||
start_at: user_start_at,
|
|
||||||
end_at:,
|
|
||||||
cleaning_strategy: :daily
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
attr_reader :start_at, :end_at, :user_ids
|
|
||||||
|
|
||||||
def users
|
|
||||||
user_ids.any? ? User.active.where(id: user_ids) : User.active
|
|
||||||
end
|
|
||||||
|
|
||||||
def start_time(user)
|
|
||||||
latest_track = user.tracks.order(end_at: :desc).first
|
|
||||||
|
|
||||||
if latest_track
|
|
||||||
latest_track.end_at
|
|
||||||
else
|
|
||||||
oldest_point = user.tracked_points.order(:timestamp).first
|
|
||||||
oldest_point ? Time.zone.at(oldest_point.timestamp) : 1.day.ago.beginning_of_day
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Track cleaning strategy for daily track processing.
|
|
||||||
#
|
|
||||||
# This cleaner handles tracks that overlap with the specified time window,
|
|
||||||
# ensuring proper handling of cross-day tracks and preventing orphaned points.
|
|
||||||
#
|
|
||||||
# How it works:
|
|
||||||
# 1. Finds tracks that overlap with the time window (not just those completely contained)
|
|
||||||
# 2. For overlapping tracks, removes only points within the time window
|
|
||||||
# 3. Deletes tracks that become empty after point removal
|
|
||||||
# 4. Preserves tracks that extend beyond the time window with their remaining points
|
|
||||||
#
|
|
||||||
# Key differences from ReplaceCleaner:
|
|
||||||
# - Handles tracks that span multiple days correctly
|
|
||||||
# - Uses overlap logic instead of containment logic
|
|
||||||
# - Preserves track portions outside the processing window
|
|
||||||
# - Prevents orphaned points from cross-day tracks
|
|
||||||
#
|
|
||||||
# Used primarily for:
|
|
||||||
# - Daily track processing that handles 24-hour windows
|
|
||||||
# - Incremental processing that respects existing cross-day tracks
|
|
||||||
# - Scenarios where tracks may span the processing boundary
|
|
||||||
#
|
|
||||||
# Example usage:
|
|
||||||
# cleaner = Tracks::Cleaners::DailyCleaner.new(user, start_at: 1.day.ago.beginning_of_day, end_at: 1.day.ago.end_of_day)
|
|
||||||
# cleaner.cleanup
|
|
||||||
#
|
|
||||||
module Tracks
|
|
||||||
module Cleaners
|
|
||||||
class DailyCleaner
|
|
||||||
attr_reader :user, :start_at, :end_at
|
|
||||||
|
|
||||||
def initialize(user, start_at: nil, end_at: nil)
|
|
||||||
@user = user
|
|
||||||
@start_at = start_at
|
|
||||||
@end_at = end_at
|
|
||||||
end
|
|
||||||
|
|
||||||
def cleanup
|
|
||||||
return unless start_at.present? && end_at.present?
|
|
||||||
|
|
||||||
overlapping_tracks = find_overlapping_tracks
|
|
||||||
|
|
||||||
return if overlapping_tracks.empty?
|
|
||||||
|
|
||||||
Rails.logger.info "Processing #{overlapping_tracks.count} overlapping tracks for user #{user.id} in time window #{start_at} to #{end_at}"
|
|
||||||
|
|
||||||
overlapping_tracks.each do |track|
|
|
||||||
process_overlapping_track(track)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def find_overlapping_tracks
|
|
||||||
# Find tracks that overlap with our time window
|
|
||||||
# A track overlaps if: track_start < window_end AND track_end > window_start
|
|
||||||
user.tracks.where(
|
|
||||||
'(start_at < ? AND end_at > ?)',
|
|
||||||
Time.zone.at(end_at),
|
|
||||||
Time.zone.at(start_at)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def process_overlapping_track(track)
|
|
||||||
# Find points within our time window that belong to this track
|
|
||||||
points_in_window = track.points.where(
|
|
||||||
'timestamp >= ? AND timestamp <= ?',
|
|
||||||
start_at.to_i,
|
|
||||||
end_at.to_i
|
|
||||||
)
|
|
||||||
|
|
||||||
if points_in_window.empty?
|
|
||||||
Rails.logger.debug "Track #{track.id} has no points in time window, skipping"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Remove these points from the track
|
|
||||||
points_in_window.update_all(track_id: nil)
|
|
||||||
|
|
||||||
Rails.logger.debug "Removed #{points_in_window.count} points from track #{track.id}"
|
|
||||||
|
|
||||||
# Check if the track has any remaining points
|
|
||||||
remaining_points_count = track.points.count
|
|
||||||
|
|
||||||
if remaining_points_count == 0
|
|
||||||
# Track is now empty, delete it
|
|
||||||
Rails.logger.debug "Track #{track.id} is now empty, deleting"
|
|
||||||
track.destroy!
|
|
||||||
elsif remaining_points_count < 2
|
|
||||||
# Track has too few points to be valid, delete it and orphan remaining points
|
|
||||||
Rails.logger.debug "Track #{track.id} has insufficient points (#{remaining_points_count}), deleting"
|
|
||||||
track.points.update_all(track_id: nil)
|
|
||||||
track.destroy!
|
|
||||||
else
|
|
||||||
# Track still has valid points outside our window, update its boundaries
|
|
||||||
Rails.logger.debug "Track #{track.id} still has #{remaining_points_count} points, updating boundaries"
|
|
||||||
update_track_boundaries(track)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_track_boundaries(track)
|
|
||||||
remaining_points = track.points.order(:timestamp)
|
|
||||||
|
|
||||||
return if remaining_points.empty?
|
|
||||||
|
|
||||||
# Update track start/end times based on remaining points
|
|
||||||
track.update!(
|
|
||||||
start_at: Time.zone.at(remaining_points.first.timestamp),
|
|
||||||
end_at: Time.zone.at(remaining_points.last.timestamp)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Tracks
|
|
||||||
module Cleaners
|
|
||||||
class NoOpCleaner
|
|
||||||
def initialize(user)
|
|
||||||
@user = user
|
|
||||||
end
|
|
||||||
|
|
||||||
def cleanup
|
|
||||||
# No cleanup needed for incremental processing
|
|
||||||
# We only append new tracks, don't remove existing ones
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Track cleaning strategy for bulk track regeneration.
|
|
||||||
#
|
|
||||||
# This cleaner removes existing tracks before generating new ones,
|
|
||||||
# ensuring a clean slate for bulk processing without duplicate tracks.
|
|
||||||
#
|
|
||||||
# How it works:
|
|
||||||
# 1. Finds all existing tracks for the user within the specified time range
|
|
||||||
# 2. Detaches all points from these tracks (sets track_id to nil)
|
|
||||||
# 3. Destroys the existing track records
|
|
||||||
# 4. Allows the generator to create fresh tracks from the same points
|
|
||||||
#
|
|
||||||
# Used primarily for:
|
|
||||||
# - Bulk track regeneration after settings changes
|
|
||||||
# - Reprocessing historical data with updated algorithms
|
|
||||||
# - Ensuring consistency when tracks need to be rebuilt
|
|
||||||
#
|
|
||||||
# The cleaner respects optional time boundaries (start_at/end_at) to enable
|
|
||||||
# partial regeneration of tracks within specific time windows.
|
|
||||||
#
|
|
||||||
# This strategy is essential for bulk operations but should not be used
|
|
||||||
# for incremental processing where existing tracks should be preserved.
|
|
||||||
#
|
|
||||||
# Example usage:
|
|
||||||
# cleaner = Tracks::Cleaners::ReplaceCleaner.new(user, start_at: 1.week.ago, end_at: Time.current)
|
|
||||||
# cleaner.cleanup
|
|
||||||
#
|
|
||||||
module Tracks
|
|
||||||
module Cleaners
|
|
||||||
class ReplaceCleaner
|
|
||||||
attr_reader :user, :start_at, :end_at
|
|
||||||
|
|
||||||
def initialize(user, start_at: nil, end_at: nil)
|
|
||||||
@user = user
|
|
||||||
@start_at = start_at
|
|
||||||
@end_at = end_at
|
|
||||||
end
|
|
||||||
|
|
||||||
def cleanup
|
|
||||||
tracks_to_remove = find_tracks_to_remove
|
|
||||||
|
|
||||||
if tracks_to_remove.any?
|
|
||||||
Rails.logger.info "Removing #{tracks_to_remove.count} existing tracks for user #{user.id}"
|
|
||||||
|
|
||||||
Point.where(track_id: tracks_to_remove.ids).update_all(track_id: nil)
|
|
||||||
|
|
||||||
tracks_to_remove.destroy_all
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def find_tracks_to_remove
|
|
||||||
scope = user.tracks
|
|
||||||
|
|
||||||
if start_at.present?
|
|
||||||
scope = scope.where('start_at >= ?', Time.zone.at(start_at))
|
|
||||||
end
|
|
||||||
|
|
||||||
if end_at.present?
|
|
||||||
scope = scope.where('end_at <= ?', Time.zone.at(end_at))
|
|
||||||
end
|
|
||||||
|
|
||||||
scope
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class Tracks::CreateFromPoints
|
|
||||||
include Tracks::Segmentation
|
|
||||||
include Tracks::TrackBuilder
|
|
||||||
|
|
||||||
attr_reader :user, :start_at, :end_at, :cleaning_strategy
|
|
||||||
|
|
||||||
def initialize(user, start_at: nil, end_at: nil, cleaning_strategy: :replace)
|
|
||||||
@user = user
|
|
||||||
@start_at = start_at
|
|
||||||
@end_at = end_at
|
|
||||||
@cleaning_strategy = cleaning_strategy
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
|
||||||
generator = Tracks::Generator.new(
|
|
||||||
user,
|
|
||||||
point_loader: point_loader,
|
|
||||||
incomplete_segment_handler: incomplete_segment_handler,
|
|
||||||
track_cleaner: track_cleaner
|
|
||||||
)
|
|
||||||
|
|
||||||
generator.call
|
|
||||||
end
|
|
||||||
|
|
||||||
# Expose threshold properties for tests
|
|
||||||
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
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def point_loader
|
|
||||||
@point_loader ||=
|
|
||||||
Tracks::PointLoaders::BulkLoader.new(
|
|
||||||
user, start_at: start_at, end_at: end_at
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def incomplete_segment_handler
|
|
||||||
@incomplete_segment_handler ||=
|
|
||||||
Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user)
|
|
||||||
end
|
|
||||||
|
|
||||||
def track_cleaner
|
|
||||||
@track_cleaner ||=
|
|
||||||
case cleaning_strategy
|
|
||||||
when :daily
|
|
||||||
Tracks::Cleaners::DailyCleaner.new(user, start_at: start_at, end_at: end_at)
|
|
||||||
when :none
|
|
||||||
Tracks::Cleaners::NoOpCleaner.new(user)
|
|
||||||
else # :replace (default)
|
|
||||||
Tracks::Cleaners::ReplaceCleaner.new(user, start_at: start_at, end_at: end_at)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Legacy method for backward compatibility with tests
|
|
||||||
# Delegates to segmentation module logic
|
|
||||||
def should_start_new_track?(current_point, previous_point)
|
|
||||||
should_start_new_segment?(current_point, previous_point)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Legacy method for backward compatibility with tests
|
|
||||||
# Delegates to segmentation module logic
|
|
||||||
def calculate_distance_kilometers(point1, point2)
|
|
||||||
calculate_distance_kilometers_between_points(point1, point2)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,108 +1,185 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
# The core track generation engine that orchestrates the entire process of creating tracks from GPS points.
|
# This service handles both bulk and incremental track generation using a unified
|
||||||
|
# approach with different modes:
|
||||||
#
|
#
|
||||||
# This class uses a flexible strategy pattern to handle different track generation scenarios:
|
# - :bulk - Regenerates all tracks from scratch (replaces existing)
|
||||||
# - Bulk processing: Generate all tracks at once from existing points
|
# - :incremental - Processes untracked points up to a specified end time
|
||||||
# - Incremental processing: Generate tracks as new points arrive
|
# - :daily - Processes tracks on a daily basis
|
||||||
#
|
#
|
||||||
# How it works:
|
# Key features:
|
||||||
# 1. Uses a PointLoader strategy to load points from the database
|
# - Deterministic results (same algorithm for all modes)
|
||||||
# 2. Applies segmentation logic to split points into track segments based on time/distance gaps
|
# - Simple incremental processing without buffering complexity
|
||||||
# 3. Determines which segments should be finalized into tracks vs buffered for later
|
# - Configurable time and distance thresholds from user settings
|
||||||
# 4. Creates Track records from finalized segments with calculated statistics
|
# - Automatic track statistics calculation
|
||||||
# 5. Manages cleanup of existing tracks based on the chosen strategy
|
# - Proper handling of edge cases (empty points, incomplete segments)
|
||||||
#
|
#
|
||||||
# Strategy Components:
|
# Usage:
|
||||||
# - point_loader: Loads points from database (BulkLoader, IncrementalLoader)
|
# # Bulk regeneration
|
||||||
# - incomplete_segment_handler: Handles segments that aren't ready to finalize (IgnoreHandler, BufferHandler)
|
# Tracks::Generator.new(user, mode: :bulk).call
|
||||||
# - track_cleaner: Manages existing tracks when regenerating (ReplaceCleaner, NoOpCleaner)
|
|
||||||
#
|
#
|
||||||
# The class includes Tracks::Segmentation for splitting logic and Tracks::TrackBuilder for track creation.
|
# # Incremental processing
|
||||||
# Distance and time thresholds are configurable per user via their settings.
|
# Tracks::Generator.new(user, mode: :incremental).call
|
||||||
#
|
#
|
||||||
# Example usage:
|
# # Daily processing
|
||||||
# generator = Tracks::Generator.new(
|
# Tracks::Generator.new(user, start_at: Date.current, mode: :daily).call
|
||||||
# user,
|
|
||||||
# point_loader: Tracks::PointLoaders::BulkLoader.new(user),
|
|
||||||
# incomplete_segment_handler: Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user),
|
|
||||||
# track_cleaner: Tracks::Cleaners::ReplaceCleaner.new(user)
|
|
||||||
# )
|
|
||||||
# tracks_created = generator.call
|
|
||||||
#
|
#
|
||||||
module Tracks
|
class Tracks::Generator
|
||||||
class Generator
|
include Tracks::Segmentation
|
||||||
include Tracks::Segmentation
|
include Tracks::TrackBuilder
|
||||||
include Tracks::TrackBuilder
|
|
||||||
|
|
||||||
attr_reader :user, :point_loader, :incomplete_segment_handler, :track_cleaner
|
attr_reader :user, :start_at, :end_at, :mode
|
||||||
|
|
||||||
def initialize(user, point_loader:, incomplete_segment_handler:, track_cleaner:)
|
def initialize(user, start_at: nil, end_at: nil, mode: :bulk)
|
||||||
@user = user
|
@user = user
|
||||||
@point_loader = point_loader
|
@start_at = start_at
|
||||||
@incomplete_segment_handler = incomplete_segment_handler
|
@end_at = end_at
|
||||||
@track_cleaner = track_cleaner
|
@mode = mode.to_sym
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
clean_existing_tracks if should_clean_tracks?
|
||||||
|
|
||||||
|
points = load_points
|
||||||
|
Rails.logger.debug "Generator: loaded #{points.size} points for user #{user.id} in #{mode} mode"
|
||||||
|
return 0 if points.empty?
|
||||||
|
|
||||||
|
segments = split_points_into_segments(points)
|
||||||
|
Rails.logger.debug "Generator: created #{segments.size} segments"
|
||||||
|
|
||||||
|
tracks_created = 0
|
||||||
|
segments.each do |segment|
|
||||||
|
track = create_track_from_segment(segment)
|
||||||
|
tracks_created += 1 if track
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
Rails.logger.info "Generated #{tracks_created} tracks for user #{user.id} in #{mode} mode"
|
||||||
Rails.logger.info "Starting track generation for user #{user.id}"
|
tracks_created
|
||||||
|
end
|
||||||
|
|
||||||
tracks_created = 0
|
private
|
||||||
|
|
||||||
Point.transaction do
|
def should_clean_tracks?
|
||||||
# Clean up existing tracks if needed
|
case mode
|
||||||
track_cleaner.cleanup
|
when :bulk, :daily then true
|
||||||
|
else false
|
||||||
# Load points using the configured strategy
|
|
||||||
points = point_loader.load_points
|
|
||||||
|
|
||||||
if points.empty?
|
|
||||||
Rails.logger.info "No points to process for user #{user.id}"
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info "Processing #{points.size} points for user #{user.id}"
|
|
||||||
|
|
||||||
# Apply segmentation logic
|
|
||||||
segments = split_points_into_segments(points)
|
|
||||||
|
|
||||||
Rails.logger.info "Created #{segments.size} segments for user #{user.id}"
|
|
||||||
|
|
||||||
# Process each segment
|
|
||||||
segments.each do |segment_points|
|
|
||||||
next if segment_points.size < 2
|
|
||||||
|
|
||||||
if incomplete_segment_handler.should_finalize_segment?(segment_points)
|
|
||||||
# Create track from finalized segment
|
|
||||||
track = create_track_from_points(segment_points)
|
|
||||||
if track&.persisted?
|
|
||||||
tracks_created += 1
|
|
||||||
Rails.logger.debug "Created track #{track.id} with #{segment_points.size} points"
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# Handle incomplete segment according to strategy
|
|
||||||
incomplete_segment_handler.handle_incomplete_segment(segment_points)
|
|
||||||
Rails.logger.debug "Stored #{segment_points.size} points as incomplete segment"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Cleanup any processed buffered data
|
|
||||||
incomplete_segment_handler.cleanup_processed_data
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info "Completed track generation for user #{user.id}: #{tracks_created} tracks created"
|
|
||||||
tracks_created
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def load_points
|
||||||
|
case mode
|
||||||
|
when :bulk then load_bulk_points
|
||||||
|
when :incremental then load_incremental_points
|
||||||
|
when :daily then load_daily_points
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Unknown mode: #{mode}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_bulk_points
|
||||||
|
scope = user.tracked_points.order(:timestamp)
|
||||||
|
scope = scope.where(timestamp: timestamp_range) if time_range_defined?
|
||||||
|
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_incremental_points
|
||||||
|
# For incremental mode, we process untracked points
|
||||||
|
# If end_at is specified, only process points up to that time
|
||||||
|
scope = user.tracked_points.where(track_id: nil).order(:timestamp)
|
||||||
|
scope = scope.where(timestamp: ..end_at.to_i) if end_at.present?
|
||||||
|
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_daily_points
|
||||||
|
day_range = daily_time_range
|
||||||
|
|
||||||
|
user.tracked_points.where(timestamp: day_range).order(:timestamp)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_track_from_segment(segment)
|
||||||
|
Rails.logger.debug "Generator: processing segment with #{segment.size} points"
|
||||||
|
return unless segment.size >= 2
|
||||||
|
|
||||||
|
track = create_track_from_points(segment)
|
||||||
|
Rails.logger.debug "Generator: created track #{track&.id}"
|
||||||
|
track
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_range_defined?
|
||||||
|
start_at.present? || end_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_range
|
||||||
|
return nil unless time_range_defined?
|
||||||
|
|
||||||
|
start_time = start_at&.to_i
|
||||||
|
end_time = end_at&.to_i
|
||||||
|
|
||||||
|
if start_time && end_time
|
||||||
|
Time.zone.at(start_time)..Time.zone.at(end_time)
|
||||||
|
elsif start_time
|
||||||
|
Time.zone.at(start_time)..
|
||||||
|
elsif end_time
|
||||||
|
..Time.zone.at(end_time)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def timestamp_range
|
||||||
|
return nil unless time_range_defined?
|
||||||
|
|
||||||
|
start_time = start_at&.to_i
|
||||||
|
end_time = end_at&.to_i
|
||||||
|
|
||||||
|
if start_time && end_time
|
||||||
|
start_time..end_time
|
||||||
|
elsif start_time
|
||||||
|
start_time..
|
||||||
|
elsif end_time
|
||||||
|
..end_time
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def daily_time_range
|
||||||
|
day = start_at&.to_date || Date.current
|
||||||
|
day.beginning_of_day.to_i..day.end_of_day.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def incremental_mode?
|
||||||
|
mode == :incremental
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_existing_tracks
|
||||||
|
case mode
|
||||||
|
when :bulk then clean_bulk_tracks
|
||||||
|
when :daily then clean_daily_tracks
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Unknown mode: #{mode}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_bulk_tracks
|
||||||
|
scope = user.tracks
|
||||||
|
scope = scope.where(start_at: time_range) if time_range_defined?
|
||||||
|
|
||||||
|
scope.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
def clean_daily_tracks
|
||||||
|
day_range = daily_time_range
|
||||||
|
range = Time.zone.at(day_range.begin)..Time.zone.at(day_range.end)
|
||||||
|
|
||||||
|
scope = user.tracks.where(start_at: range)
|
||||||
|
scope.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
# Threshold methods from safe_settings
|
||||||
|
def distance_threshold_meters
|
||||||
|
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_threshold_minutes
|
||||||
|
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Tracks
|
|
||||||
module IncompleteSegmentHandlers
|
|
||||||
class BufferHandler
|
|
||||||
attr_reader :user, :day, :grace_period_minutes, :redis_buffer
|
|
||||||
|
|
||||||
def initialize(user, day = nil, grace_period_minutes = 5)
|
|
||||||
@user = user
|
|
||||||
@day = day || Date.current
|
|
||||||
@grace_period_minutes = grace_period_minutes
|
|
||||||
@redis_buffer = Tracks::RedisBuffer.new(user.id, @day)
|
|
||||||
end
|
|
||||||
|
|
||||||
def should_finalize_segment?(segment_points)
|
|
||||||
return false if segment_points.empty?
|
|
||||||
|
|
||||||
# Check if the last point is old enough (grace period)
|
|
||||||
last_point_time = Time.zone.at(segment_points.last.timestamp)
|
|
||||||
grace_period_cutoff = Time.current - grace_period_minutes.minutes
|
|
||||||
|
|
||||||
last_point_time < grace_period_cutoff
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_incomplete_segment(segment_points)
|
|
||||||
redis_buffer.store(segment_points)
|
|
||||||
Rails.logger.debug "Stored #{segment_points.size} points in buffer for user #{user.id}, day #{day}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def cleanup_processed_data
|
|
||||||
redis_buffer.clear
|
|
||||||
Rails.logger.debug "Cleared buffer for user #{user.id}, day #{day}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Incomplete segment handling strategy for bulk track generation.
|
|
||||||
#
|
|
||||||
# This handler always finalizes segments immediately without buffering,
|
|
||||||
# making it suitable for bulk processing where all data is historical
|
|
||||||
# and no segments are expected to grow with new incoming points.
|
|
||||||
#
|
|
||||||
# How it works:
|
|
||||||
# 1. Always returns true for should_finalize_segment? - every segment becomes a track
|
|
||||||
# 2. Ignores any incomplete segments (logs them but takes no action)
|
|
||||||
# 3. Requires no cleanup since no data is buffered
|
|
||||||
#
|
|
||||||
# Used primarily for:
|
|
||||||
# - Bulk track generation from historical data
|
|
||||||
# - One-time processing where all points are already available
|
|
||||||
# - Scenarios where you want to create tracks from every valid segment
|
|
||||||
#
|
|
||||||
# This strategy is efficient for bulk operations but not suitable for
|
|
||||||
# real-time processing where segments may grow as new points arrive.
|
|
||||||
#
|
|
||||||
# Example usage:
|
|
||||||
# handler = Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user)
|
|
||||||
# should_create_track = handler.should_finalize_segment?(segment_points)
|
|
||||||
#
|
|
||||||
module Tracks
|
|
||||||
module IncompleteSegmentHandlers
|
|
||||||
class IgnoreHandler
|
|
||||||
def initialize(user)
|
|
||||||
@user = user
|
|
||||||
end
|
|
||||||
|
|
||||||
def should_finalize_segment?(segment_points)
|
|
||||||
# Always finalize segments in bulk processing
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_incomplete_segment(segment_points)
|
|
||||||
# Ignore incomplete segments in bulk processing
|
|
||||||
Rails.logger.debug "Ignoring incomplete segment with #{segment_points.size} points"
|
|
||||||
end
|
|
||||||
|
|
||||||
def cleanup_processed_data
|
|
||||||
# No cleanup needed for ignore strategy
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
97
app/services/tracks/incremental_processor.rb
Normal file
97
app/services/tracks/incremental_processor.rb
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# This service analyzes new points as they're created and determines whether
|
||||||
|
# they should trigger incremental track generation based on time and distance
|
||||||
|
# thresholds defined in user settings.
|
||||||
|
#
|
||||||
|
# The key insight is that we should trigger track generation when there's a
|
||||||
|
# significant gap between the new point and the previous point, indicating
|
||||||
|
# the end of a journey and the start of a new one.
|
||||||
|
#
|
||||||
|
# Process:
|
||||||
|
# 1. Check if the new point should trigger processing (skip imported points)
|
||||||
|
# 2. Find the last point before the new point
|
||||||
|
# 3. Calculate time and distance differences
|
||||||
|
# 4. If thresholds are exceeded, trigger incremental generation
|
||||||
|
# 5. Set the end_at time to the previous point's timestamp for track finalization
|
||||||
|
#
|
||||||
|
# This ensures tracks are properly finalized when journeys end, not when they start.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# # In Point model after_create_commit callback
|
||||||
|
# Tracks::IncrementalProcessor.new(user, new_point).call
|
||||||
|
#
|
||||||
|
class Tracks::IncrementalProcessor
|
||||||
|
attr_reader :user, :new_point, :previous_point
|
||||||
|
|
||||||
|
def initialize(user, new_point)
|
||||||
|
@user = user
|
||||||
|
@new_point = new_point
|
||||||
|
@previous_point = find_previous_point
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return unless should_process?
|
||||||
|
|
||||||
|
start_at = find_start_time
|
||||||
|
end_at = find_end_time
|
||||||
|
|
||||||
|
Tracks::CreateJob.perform_later(
|
||||||
|
user.id,
|
||||||
|
start_at: start_at,
|
||||||
|
end_at: end_at,
|
||||||
|
mode: :none
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def should_process?
|
||||||
|
return false if new_point.import_id.present?
|
||||||
|
return true unless previous_point
|
||||||
|
|
||||||
|
exceeds_thresholds?(previous_point, new_point)
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_previous_point
|
||||||
|
@previous_point ||=
|
||||||
|
user.tracked_points
|
||||||
|
.where('timestamp < ?', new_point.timestamp)
|
||||||
|
.order(:timestamp)
|
||||||
|
.last
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_start_time
|
||||||
|
user.tracks.order(:end_at).last&.end_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_end_time
|
||||||
|
previous_point ? Time.zone.at(previous_point.timestamp) : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def exceeds_thresholds?(previous_point, current_point)
|
||||||
|
time_gap = time_difference_minutes(previous_point, current_point)
|
||||||
|
distance_gap = distance_difference_meters(previous_point, current_point)
|
||||||
|
|
||||||
|
time_exceeded = time_gap >= time_threshold_minutes
|
||||||
|
distance_exceeded = distance_gap >= distance_threshold_meters
|
||||||
|
|
||||||
|
time_exceeded || distance_exceeded
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_difference_minutes(point1, point2)
|
||||||
|
(point2.timestamp - point1.timestamp) / 60.0
|
||||||
|
end
|
||||||
|
|
||||||
|
def distance_difference_meters(point1, point2)
|
||||||
|
point1.distance_to(point2) * 1000
|
||||||
|
end
|
||||||
|
|
||||||
|
def time_threshold_minutes
|
||||||
|
@time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def distance_threshold_meters
|
||||||
|
@distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Point loading strategy for bulk track generation from existing GPS points.
|
|
||||||
#
|
|
||||||
# This loader retrieves all valid points for a user within an optional time range,
|
|
||||||
# suitable for regenerating all tracks at once or processing historical data.
|
|
||||||
#
|
|
||||||
# How it works:
|
|
||||||
# 1. Queries all points belonging to the user
|
|
||||||
# 2. Filters out points without valid coordinates or timestamps
|
|
||||||
# 3. Optionally filters by start_at/end_at time range if provided
|
|
||||||
# 4. Returns points ordered by timestamp for sequential processing
|
|
||||||
#
|
|
||||||
# Used primarily for:
|
|
||||||
# - Initial track generation when a user first enables tracks
|
|
||||||
# - Bulk regeneration of all tracks after settings changes
|
|
||||||
# - Processing historical data imports
|
|
||||||
#
|
|
||||||
# The loader is designed to be efficient for large datasets while ensuring
|
|
||||||
# data integrity by filtering out invalid points upfront.
|
|
||||||
#
|
|
||||||
# Example usage:
|
|
||||||
# loader = Tracks::PointLoaders::BulkLoader.new(user, start_at: 1.week.ago, end_at: Time.current)
|
|
||||||
# points = loader.load_points
|
|
||||||
#
|
|
||||||
module Tracks
|
|
||||||
module PointLoaders
|
|
||||||
class BulkLoader
|
|
||||||
attr_reader :user, :start_at, :end_at
|
|
||||||
|
|
||||||
def initialize(user, start_at: nil, end_at: nil)
|
|
||||||
@user = user
|
|
||||||
@start_at = start_at
|
|
||||||
@end_at = end_at
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_points
|
|
||||||
scope = Point.where(user: user)
|
|
||||||
.where.not(lonlat: nil)
|
|
||||||
.where.not(timestamp: nil)
|
|
||||||
|
|
||||||
if start_at.present?
|
|
||||||
scope = scope.where('timestamp >= ?', start_at)
|
|
||||||
end
|
|
||||||
|
|
||||||
if end_at.present?
|
|
||||||
scope = scope.where('timestamp <= ?', end_at)
|
|
||||||
end
|
|
||||||
|
|
||||||
scope.order(:timestamp)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module Tracks
|
|
||||||
module PointLoaders
|
|
||||||
class IncrementalLoader
|
|
||||||
attr_reader :user, :day, :redis_buffer
|
|
||||||
|
|
||||||
def initialize(user, day = nil)
|
|
||||||
@user = user
|
|
||||||
@day = day || Date.current
|
|
||||||
@redis_buffer = Tracks::RedisBuffer.new(user.id, @day)
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_points
|
|
||||||
# Get buffered points from Redis
|
|
||||||
buffered_points = redis_buffer.retrieve
|
|
||||||
|
|
||||||
# Find the last track for this day to determine where to start
|
|
||||||
last_track = Track.last_for_day(user, day)
|
|
||||||
|
|
||||||
# Load new points since last track
|
|
||||||
new_points = load_new_points_since_last_track(last_track)
|
|
||||||
|
|
||||||
# Combine buffered points with new points
|
|
||||||
combined_points = merge_points(buffered_points, new_points)
|
|
||||||
|
|
||||||
Rails.logger.debug "Loaded #{buffered_points.size} buffered points and #{new_points.size} new points for user #{user.id}"
|
|
||||||
|
|
||||||
combined_points
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def load_new_points_since_last_track(last_track)
|
|
||||||
scope = user.points
|
|
||||||
.where.not(lonlat: nil)
|
|
||||||
.where.not(timestamp: nil)
|
|
||||||
.where(track_id: nil) # Only process points not already assigned to tracks
|
|
||||||
|
|
||||||
if last_track
|
|
||||||
scope = scope.where('timestamp > ?', last_track.end_at.to_i)
|
|
||||||
else
|
|
||||||
# If no last track, load all points for the day
|
|
||||||
day_start = day.beginning_of_day.to_i
|
|
||||||
day_end = day.end_of_day.to_i
|
|
||||||
scope = scope.where('timestamp >= ? AND timestamp <= ?', day_start, day_end)
|
|
||||||
end
|
|
||||||
|
|
||||||
scope.order(:timestamp)
|
|
||||||
end
|
|
||||||
|
|
||||||
def merge_points(buffered_points, new_points)
|
|
||||||
# Convert buffered point hashes back to Point objects if needed
|
|
||||||
buffered_point_objects = buffered_points.map do |point_data|
|
|
||||||
# If it's already a Point object, use it directly
|
|
||||||
if point_data.is_a?(Point)
|
|
||||||
point_data
|
|
||||||
else
|
|
||||||
# Create a Point-like object from the hash
|
|
||||||
Point.new(point_data.except('id').symbolize_keys)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Combine and sort by timestamp
|
|
||||||
all_points = (buffered_point_objects + new_points.to_a).sort_by(&:timestamp)
|
|
||||||
|
|
||||||
# Remove duplicates based on timestamp and coordinates
|
|
||||||
all_points.uniq { |point| [point.timestamp, point.lat, point.lon] }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
@ -68,8 +68,8 @@ module Tracks::Segmentation
|
||||||
return false if previous_point.nil?
|
return false if previous_point.nil?
|
||||||
|
|
||||||
# Check time threshold (convert minutes to seconds)
|
# Check time threshold (convert minutes to seconds)
|
||||||
current_timestamp = point_timestamp(current_point)
|
current_timestamp = current_point.timestamp
|
||||||
previous_timestamp = point_timestamp(previous_point)
|
previous_timestamp = previous_point.timestamp
|
||||||
|
|
||||||
time_diff_seconds = current_timestamp - previous_timestamp
|
time_diff_seconds = current_timestamp - previous_timestamp
|
||||||
time_threshold_seconds = time_threshold_minutes.to_i * 60
|
time_threshold_seconds = time_threshold_minutes.to_i * 60
|
||||||
|
|
@ -79,6 +79,7 @@ module Tracks::Segmentation
|
||||||
# Check distance threshold - convert km to meters to match frontend logic
|
# Check distance threshold - convert km to meters to match frontend logic
|
||||||
distance_km = calculate_distance_kilometers_between_points(previous_point, current_point)
|
distance_km = calculate_distance_kilometers_between_points(previous_point, current_point)
|
||||||
distance_meters = distance_km * 1000 # Convert km to meters
|
distance_meters = distance_km * 1000 # Convert km to meters
|
||||||
|
|
||||||
return true if distance_meters > distance_threshold_meters
|
return true if distance_meters > distance_threshold_meters
|
||||||
|
|
||||||
false
|
false
|
||||||
|
|
@ -96,7 +97,7 @@ module Tracks::Segmentation
|
||||||
return false if segment_points.size < 2
|
return false if segment_points.size < 2
|
||||||
|
|
||||||
last_point = segment_points.last
|
last_point = segment_points.last
|
||||||
last_timestamp = point_timestamp(last_point)
|
last_timestamp = last_point.timestamp
|
||||||
current_time = Time.current.to_i
|
current_time = Time.current.to_i
|
||||||
|
|
||||||
# Don't finalize if the last point is too recent (within grace period)
|
# Don't finalize if the last point is too recent (within grace period)
|
||||||
|
|
@ -106,30 +107,10 @@ module Tracks::Segmentation
|
||||||
time_since_last_point > grace_period_seconds
|
time_since_last_point > grace_period_seconds
|
||||||
end
|
end
|
||||||
|
|
||||||
def point_timestamp(point)
|
|
||||||
if point.respond_to?(:timestamp)
|
|
||||||
# Point objects from database always have integer timestamps
|
|
||||||
point.timestamp
|
|
||||||
elsif point.is_a?(Hash)
|
|
||||||
# Hash might come from Redis buffer or test data
|
|
||||||
timestamp = point[:timestamp] || point['timestamp']
|
|
||||||
timestamp.to_i
|
|
||||||
else
|
|
||||||
raise ArgumentError, "Invalid point type: #{point.class}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def point_coordinates(point)
|
def point_coordinates(point)
|
||||||
if point.respond_to?(:lat) && point.respond_to?(:lon)
|
[point.lat, point.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
|
end
|
||||||
|
|
||||||
# These methods need to be implemented by the including class
|
|
||||||
def distance_threshold_meters
|
def distance_threshold_meters
|
||||||
raise NotImplementedError, "Including class must implement distance_threshold_meters"
|
raise NotImplementedError, "Including class must implement distance_threshold_meters"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ module Tracks::TrackBuilder
|
||||||
|
|
||||||
if track.save
|
if track.save
|
||||||
Point.where(id: points.map(&:id)).update_all(track_id: track.id)
|
Point.where(id: points.map(&:id)).update_all(track_id: track.id)
|
||||||
|
|
||||||
track
|
track
|
||||||
else
|
else
|
||||||
Rails.logger.error "Failed to create track for user #{user.id}: #{track.errors.full_messages.join(', ')}"
|
Rails.logger.error "Failed to create track for user #{user.id}: #{track.errors.full_messages.join(', ')}"
|
||||||
|
|
@ -82,7 +83,7 @@ module Tracks::TrackBuilder
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_path(points)
|
def build_path(points)
|
||||||
Tracks::BuildPath.new(points.map(&:lonlat)).call
|
Tracks::BuildPath.new(points).call
|
||||||
end
|
end
|
||||||
|
|
||||||
def calculate_track_distance(points)
|
def calculate_track_distance(points)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<p class="py-6">
|
<p class="py-6">
|
||||||
<p class='py-2'>
|
<p class='py-2'>
|
||||||
You have used <%= number_with_delimiter(current_user.points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
You have used <%= number_with_delimiter(current_user.tracked_points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
|
||||||
</p>
|
</p>
|
||||||
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.points.count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.tracked_points.count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@ cache_preheating_job:
|
||||||
class: "Cache::PreheatingJob"
|
class: "Cache::PreheatingJob"
|
||||||
queue: default
|
queue: default
|
||||||
|
|
||||||
tracks_bulk_creating_job:
|
tracks_cleanup_job:
|
||||||
cron: "10 0 * * *" # every day at 00:10
|
cron: "0 2 * * 0" # every Sunday at 02:00
|
||||||
class: "Tracks::BulkCreatingJob"
|
class: "Tracks::CleanupJob"
|
||||||
queue: tracks
|
queue: tracks
|
||||||
|
|
||||||
place_name_fetching_job:
|
place_name_fetching_job:
|
||||||
|
|
|
||||||
530
dawarich_user_scenarios.md
Normal file
530
dawarich_user_scenarios.md
Normal file
|
|
@ -0,0 +1,530 @@
|
||||||
|
# Dawarich User Scenarios Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Dawarich is a self-hosted location history tracking application that allows users to import, visualize, and analyze their location data. This document describes all user scenarios for comprehensive test coverage.
|
||||||
|
|
||||||
|
## Application Context
|
||||||
|
- **Purpose**: Self-hosted alternative to Google Timeline/Location History
|
||||||
|
- **Tech Stack**: Rails 8, PostgreSQL, Hotwire (Turbo/Stimulus), Tailwind CSS with DaisyUI
|
||||||
|
- **Key Features**: Location tracking, data visualization, import/export, statistics, visits detection
|
||||||
|
- **Deployment**: Docker-based with self-hosted and cloud options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Authentication & User Management
|
||||||
|
|
||||||
|
### 1.1 User Registration (Non-Self-Hosted Mode)
|
||||||
|
**Scenario**: New user registration process
|
||||||
|
- **Entry Point**: Home page → Sign up link
|
||||||
|
- **Steps**:
|
||||||
|
1. Navigate to registration form
|
||||||
|
2. Fill in email, password, password confirmation
|
||||||
|
3. Complete CAPTCHA (if enabled)
|
||||||
|
4. Submit registration
|
||||||
|
5. Receive confirmation (if email verification enabled)
|
||||||
|
- **Validation**: Email format, password strength, password confirmation match
|
||||||
|
- **Success**: User created, redirected to sign-in or dashboard
|
||||||
|
|
||||||
|
### 1.2 User Sign In/Out
|
||||||
|
**Scenario**: User authentication workflow
|
||||||
|
- **Entry Point**: Home page → Sign in link
|
||||||
|
- **Steps**:
|
||||||
|
1. Navigate to sign-in form
|
||||||
|
2. Enter email and password
|
||||||
|
3. Optionally check "Remember me"
|
||||||
|
4. Submit login
|
||||||
|
5. Successful login redirects to map page
|
||||||
|
- **Demo Mode**: Special demo credentials (demo@dawarich.app / password)
|
||||||
|
- **Sign Out**: User can sign out from dropdown menu
|
||||||
|
|
||||||
|
### 1.3 Password Management
|
||||||
|
**Scenario**: Password reset and change functionality
|
||||||
|
- **Forgot Password**:
|
||||||
|
1. Click "Forgot password" link
|
||||||
|
2. Enter email address
|
||||||
|
3. Receive reset email
|
||||||
|
4. Follow reset link
|
||||||
|
5. Set new password
|
||||||
|
- **Change Password** (when signed in):
|
||||||
|
1. Navigate to account settings
|
||||||
|
2. Provide current password
|
||||||
|
3. Enter new password and confirmation
|
||||||
|
4. Save changes
|
||||||
|
|
||||||
|
### 1.4 Account Settings
|
||||||
|
**Scenario**: User account management
|
||||||
|
- **Entry Point**: User dropdown → Account
|
||||||
|
- **Actions**:
|
||||||
|
1. Update email address (requires current password)
|
||||||
|
2. Change password
|
||||||
|
3. View API key
|
||||||
|
4. Generate new API key
|
||||||
|
5. Theme selection (light/dark)
|
||||||
|
- **Self-Hosted**: Limited registration options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Map Functionality & Visualization
|
||||||
|
|
||||||
|
### 2.1 Main Map Interface
|
||||||
|
**Scenario**: Core location data visualization
|
||||||
|
- **Entry Point**: Primary navigation → Map
|
||||||
|
- **Features**:
|
||||||
|
1. Interactive Leaflet map with multiple tile layers
|
||||||
|
2. Time range selector (date/time inputs)
|
||||||
|
3. Quick time range buttons (Today, Last 7 days, Last month)
|
||||||
|
4. Navigation arrows for day-by-day browsing
|
||||||
|
5. Real-time distance and points count display
|
||||||
|
|
||||||
|
### 2.2 Map Layers & Controls
|
||||||
|
**Scenario**: Map customization and layer management
|
||||||
|
- **Base Layers**:
|
||||||
|
1. Switch between OpenStreetMap and OpenTopo
|
||||||
|
2. Custom tile layer configuration
|
||||||
|
- **Overlay Layers**:
|
||||||
|
1. Toggle points display
|
||||||
|
2. Toggle route lines
|
||||||
|
3. Toggle heatmap
|
||||||
|
4. Toggle fog of war
|
||||||
|
5. Toggle areas
|
||||||
|
6. Toggle visits
|
||||||
|
- **Layer Control**: Expandable/collapsible layer panel
|
||||||
|
|
||||||
|
### 2.3 Map Data Display
|
||||||
|
**Scenario**: Location data visualization options
|
||||||
|
- **Points Rendering**:
|
||||||
|
1. Raw mode (all points)
|
||||||
|
2. Simplified mode (filtered by time/distance)
|
||||||
|
3. Point clicking reveals details popup
|
||||||
|
4. Battery level, altitude, velocity display
|
||||||
|
- **Routes**:
|
||||||
|
1. Polyline connections between points
|
||||||
|
2. Speed-colored routes option
|
||||||
|
3. Configurable route opacity
|
||||||
|
4. Route segment distance display
|
||||||
|
|
||||||
|
### 2.4 Map Settings & Configuration
|
||||||
|
**Scenario**: Map behavior customization
|
||||||
|
- **Settings Available**:
|
||||||
|
1. Route opacity (0-100%)
|
||||||
|
2. Meters between routes (distance threshold)
|
||||||
|
3. Minutes between routes (time threshold)
|
||||||
|
4. Fog of war radius
|
||||||
|
5. Speed color scale customization
|
||||||
|
6. Points rendering mode
|
||||||
|
- **Help Modals**: Contextual help for each setting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Location Data Import
|
||||||
|
|
||||||
|
### 3.1 Manual File Import
|
||||||
|
**Scenario**: Import location data from various sources
|
||||||
|
- **Entry Point**: Navigation → My data → Imports
|
||||||
|
- **Supported Sources**:
|
||||||
|
1. Google Semantic History (JSON files)
|
||||||
|
2. Google Records (Records.json)
|
||||||
|
3. Google Phone Takeout (mobile device JSON)
|
||||||
|
4. OwnTracks (.rec files)
|
||||||
|
5. GeoJSON files
|
||||||
|
6. GPX track files
|
||||||
|
- **Process**:
|
||||||
|
1. Select source type
|
||||||
|
2. Choose file(s) via file picker
|
||||||
|
3. Upload and process (background job)
|
||||||
|
4. Receive completion notification
|
||||||
|
|
||||||
|
### 3.2 Automatic File Watching
|
||||||
|
**Scenario**: Automatic import from watched directories
|
||||||
|
- **Setup**: Files placed in `/tmp/imports/watched/USER@EMAIL.TLD/`
|
||||||
|
- **Process**: System scans hourly for new files
|
||||||
|
- **Supported Formats**: GPX, JSON, REC files
|
||||||
|
- **Notification**: User receives import completion notifications
|
||||||
|
|
||||||
|
### 3.3 Photo Integration Import
|
||||||
|
**Scenario**: Import location data from photo EXIF data
|
||||||
|
- **Immich Integration**:
|
||||||
|
1. Configure Immich URL and API key in settings
|
||||||
|
2. Trigger import job
|
||||||
|
3. System extracts GPS data from photos
|
||||||
|
4. Creates location points from photo metadata
|
||||||
|
- **Photoprism Integration**:
|
||||||
|
1. Configure Photoprism URL and API key
|
||||||
|
2. Similar process to Immich
|
||||||
|
3. Supports different date ranges
|
||||||
|
|
||||||
|
### 3.4 Import Management
|
||||||
|
**Scenario**: View and manage import history
|
||||||
|
- **Import List**: View all imports with status
|
||||||
|
- **Import Details**: Points count, processing status, errors
|
||||||
|
- **Import Actions**: View details, delete imports
|
||||||
|
- **Progress Tracking**: Real-time progress updates via WebSocket
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Export
|
||||||
|
|
||||||
|
### 4.1 Export Creation
|
||||||
|
**Scenario**: Export location data in various formats
|
||||||
|
- **Entry Point**: Navigation → My data → Exports
|
||||||
|
- **Export Types**:
|
||||||
|
1. GeoJSON format (default)
|
||||||
|
2. GPX format
|
||||||
|
3. Complete user data archive (ZIP)
|
||||||
|
- **Process**:
|
||||||
|
1. Select export format
|
||||||
|
2. Choose date range (optional)
|
||||||
|
3. Submit export request
|
||||||
|
4. Background processing
|
||||||
|
5. Notification when complete
|
||||||
|
|
||||||
|
### 4.2 Export Management
|
||||||
|
**Scenario**: Manage created exports
|
||||||
|
- **Export List**: View all exports with details
|
||||||
|
- **Export Actions**:
|
||||||
|
1. Download completed exports
|
||||||
|
2. Delete old exports
|
||||||
|
3. View export status
|
||||||
|
- **File Information**: Size, creation date, download links
|
||||||
|
|
||||||
|
### 4.3 Complete Data Export
|
||||||
|
**Scenario**: Export all user data for backup/migration
|
||||||
|
- **Trigger**: Settings → Users → Export data
|
||||||
|
- **Content**: All user data, settings, files in ZIP format
|
||||||
|
- **Use Case**: Account migration, data backup
|
||||||
|
- **Process**: Background job, notification on completion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Statistics & Analytics
|
||||||
|
|
||||||
|
### 5.1 Statistics Dashboard
|
||||||
|
**Scenario**: View travel statistics and analytics
|
||||||
|
- **Entry Point**: Navigation → Stats
|
||||||
|
- **Key Metrics**:
|
||||||
|
1. Total distance traveled
|
||||||
|
2. Total tracked points
|
||||||
|
3. Countries visited
|
||||||
|
4. Cities visited
|
||||||
|
5. Reverse geocoding statistics
|
||||||
|
- **Display**: Cards with highlighted numbers and units
|
||||||
|
|
||||||
|
### 5.2 Yearly/Monthly Breakdown
|
||||||
|
**Scenario**: Detailed statistics by time period
|
||||||
|
- **View Options**:
|
||||||
|
1. Statistics by year
|
||||||
|
2. Monthly breakdown within years
|
||||||
|
3. Distance traveled per period
|
||||||
|
4. Points tracked per period
|
||||||
|
- **Actions**: Update statistics (background job)
|
||||||
|
|
||||||
|
### 5.3 Statistics Management
|
||||||
|
**Scenario**: Update and manage statistics
|
||||||
|
- **Manual Updates**:
|
||||||
|
1. Update all statistics
|
||||||
|
2. Update specific year/month
|
||||||
|
3. Background job processing
|
||||||
|
- **Automatic Updates**: Triggered by data imports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Trips Management
|
||||||
|
|
||||||
|
### 6.1 Trip Creation
|
||||||
|
**Scenario**: Create and manage travel trips
|
||||||
|
- **Entry Point**: Navigation → Trips → New trip
|
||||||
|
- **Trip Properties**:
|
||||||
|
1. Trip name
|
||||||
|
2. Start date/time
|
||||||
|
3. End date/time
|
||||||
|
4. Notes (rich text)
|
||||||
|
- **Validation**: Date ranges, required fields
|
||||||
|
|
||||||
|
### 6.2 Trip Visualization
|
||||||
|
**Scenario**: View trip details and route
|
||||||
|
- **Trip View**:
|
||||||
|
1. Interactive map with trip route
|
||||||
|
2. Trip statistics (distance, duration)
|
||||||
|
3. Countries visited during trip
|
||||||
|
4. Photo integration (if configured)
|
||||||
|
- **Photo Display**: Grid layout with links to photo sources
|
||||||
|
|
||||||
|
### 6.3 Trip Management
|
||||||
|
**Scenario**: Edit and manage existing trips
|
||||||
|
- **Trip List**: Paginated view of all trips
|
||||||
|
- **Trip Actions**:
|
||||||
|
1. Edit trip details
|
||||||
|
2. Delete trips
|
||||||
|
3. View trip details
|
||||||
|
- **Background Processing**: Distance and route calculations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Visits & Places (Beta Feature)
|
||||||
|
|
||||||
|
### 7.1 Visit Suggestions
|
||||||
|
**Scenario**: Automatic visit detection and suggestions
|
||||||
|
- **Process**: Background job analyzes location data
|
||||||
|
- **Detection**: Identifies places where user spent time
|
||||||
|
- **Suggestions**: Creates suggested visits for review
|
||||||
|
- **Notifications**: User receives visit suggestion notifications
|
||||||
|
|
||||||
|
### 7.2 Visit Management
|
||||||
|
**Scenario**: Review and manage visit suggestions
|
||||||
|
- **Entry Point**: Navigation → My data → Visits & Places
|
||||||
|
- **Visit States**:
|
||||||
|
1. Suggested (pending review)
|
||||||
|
2. Confirmed (accepted)
|
||||||
|
3. Declined (rejected)
|
||||||
|
- **Actions**: Confirm, decline, or edit visits
|
||||||
|
- **Filtering**: View by status, order by date
|
||||||
|
|
||||||
|
### 7.3 Places Management
|
||||||
|
**Scenario**: Manage detected places
|
||||||
|
- **Place List**: All places created by visit suggestions
|
||||||
|
- **Place Details**: Name, coordinates, creation date
|
||||||
|
- **Actions**: Delete places (deletes associated visits)
|
||||||
|
- **Integration**: Places linked to visits
|
||||||
|
|
||||||
|
### 7.4 Areas Creation
|
||||||
|
**Scenario**: Create custom areas for visit detection
|
||||||
|
- **Map Interface**: Draw areas on map
|
||||||
|
- **Area Properties**:
|
||||||
|
1. Name
|
||||||
|
2. Radius
|
||||||
|
3. Coordinates (center point)
|
||||||
|
- **Purpose**: Improve visit detection accuracy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Points Management
|
||||||
|
|
||||||
|
### 8.1 Points List
|
||||||
|
**Scenario**: View and manage individual location points
|
||||||
|
- **Entry Point**: Navigation → My data → Points
|
||||||
|
- **Display**: Paginated table with point details
|
||||||
|
- **Point Information**:
|
||||||
|
1. Timestamp
|
||||||
|
2. Coordinates
|
||||||
|
3. Accuracy
|
||||||
|
4. Source import
|
||||||
|
- **Filtering**: Date range, import source
|
||||||
|
|
||||||
|
### 8.2 Point Actions
|
||||||
|
**Scenario**: Individual point management
|
||||||
|
- **Point Details**: Click point for popup with full details
|
||||||
|
- **Actions**:
|
||||||
|
1. Delete individual points
|
||||||
|
2. Bulk delete points
|
||||||
|
3. View point source
|
||||||
|
- **Map Integration**: Points clickable on map
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Notifications System
|
||||||
|
|
||||||
|
### 9.1 Notification Types
|
||||||
|
**Scenario**: System notifications for various events
|
||||||
|
- **Import Notifications**:
|
||||||
|
1. Import completed
|
||||||
|
2. Import failed
|
||||||
|
3. Import progress updates
|
||||||
|
- **Export Notifications**:
|
||||||
|
1. Export completed
|
||||||
|
2. Export failed
|
||||||
|
- **System Notifications**:
|
||||||
|
1. Visit suggestions available
|
||||||
|
2. Statistics updates completed
|
||||||
|
3. Background job failures
|
||||||
|
|
||||||
|
### 9.2 Notification Management
|
||||||
|
**Scenario**: View and manage notifications
|
||||||
|
- **Entry Point**: Bell icon in navigation
|
||||||
|
- **Notification List**: All notifications with timestamps
|
||||||
|
- **Actions**:
|
||||||
|
1. Mark as read
|
||||||
|
2. Mark all as read
|
||||||
|
3. Delete notifications
|
||||||
|
4. Delete all notifications
|
||||||
|
- **Display**: Badges for unread count
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Settings & Configuration
|
||||||
|
|
||||||
|
### 10.1 Integration Settings
|
||||||
|
**Scenario**: Configure external service integrations
|
||||||
|
- **Entry Point**: Navigation → Settings → Integrations
|
||||||
|
- **Immich Integration**:
|
||||||
|
1. Configure Immich URL
|
||||||
|
2. Set API key
|
||||||
|
3. Test connection
|
||||||
|
- **Photoprism Integration**:
|
||||||
|
1. Configure Photoprism URL
|
||||||
|
2. Set API key
|
||||||
|
3. Test connection
|
||||||
|
|
||||||
|
### 10.2 Map Settings
|
||||||
|
**Scenario**: Configure map appearance and behavior
|
||||||
|
- **Entry Point**: Settings → Map
|
||||||
|
- **Options**:
|
||||||
|
1. Custom tile layer URL
|
||||||
|
2. Map layer name
|
||||||
|
3. Distance unit (km/miles)
|
||||||
|
4. Tile usage statistics
|
||||||
|
- **Preview**: Real-time map preview
|
||||||
|
|
||||||
|
### 10.3 User Settings
|
||||||
|
**Scenario**: Personal preferences and account settings
|
||||||
|
- **Theme**: Light/dark mode toggle
|
||||||
|
- **API Key**: View and regenerate API key
|
||||||
|
- **Visits Settings**: Enable/disable visit suggestions
|
||||||
|
- **Route Settings**: Default route appearance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Admin Features (Self-Hosted Only)
|
||||||
|
|
||||||
|
### 11.1 User Management
|
||||||
|
**Scenario**: Admin user management in self-hosted mode
|
||||||
|
- **Entry Point**: Settings → Users (admin only)
|
||||||
|
- **User Actions**:
|
||||||
|
1. Create new users
|
||||||
|
2. Edit user details
|
||||||
|
3. Delete users
|
||||||
|
4. View user statistics
|
||||||
|
- **User Creation**: Email and password setup
|
||||||
|
|
||||||
|
### 11.2 Background Jobs Management
|
||||||
|
**Scenario**: Admin control over background processing
|
||||||
|
- **Entry Point**: Settings → Background Jobs
|
||||||
|
- **Job Types**:
|
||||||
|
1. Reverse geocoding jobs
|
||||||
|
2. Statistics calculation
|
||||||
|
3. Visit suggestion jobs
|
||||||
|
- **Actions**: Start/stop background jobs, view job status
|
||||||
|
|
||||||
|
### 11.3 System Administration
|
||||||
|
**Scenario**: System-level administration
|
||||||
|
- **Sidekiq Dashboard**: Background job monitoring
|
||||||
|
- **System Settings**: Global configuration options
|
||||||
|
- **User Data Management**: Export/import user data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. API Functionality
|
||||||
|
|
||||||
|
### 12.1 Location Data API
|
||||||
|
**Scenario**: Programmatic location data submission
|
||||||
|
- **Endpoints**: RESTful API for location data
|
||||||
|
- **Authentication**: API key based
|
||||||
|
- **Supported Apps**:
|
||||||
|
1. Dawarich iOS app
|
||||||
|
2. Overland
|
||||||
|
3. OwnTracks
|
||||||
|
4. GPSLogger
|
||||||
|
5. Custom applications
|
||||||
|
|
||||||
|
### 12.2 Data Retrieval API
|
||||||
|
**Scenario**: Retrieve location data via API
|
||||||
|
- **Use Cases**: Third-party integrations, mobile apps
|
||||||
|
- **Data Formats**: JSON, GeoJSON
|
||||||
|
- **Authentication**: API key required
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Error Handling & Edge Cases
|
||||||
|
|
||||||
|
### 13.1 Import Errors
|
||||||
|
**Scenario**: Handle various import failure scenarios
|
||||||
|
- **File Format Errors**: Unsupported or corrupted files
|
||||||
|
- **Processing Errors**: Background job failures
|
||||||
|
- **Network Errors**: Failed downloads or API calls
|
||||||
|
- **User Feedback**: Error notifications with details
|
||||||
|
|
||||||
|
### 13.2 System Errors
|
||||||
|
**Scenario**: Handle system-level errors
|
||||||
|
- **Database Errors**: Connection issues, constraints
|
||||||
|
- **Storage Errors**: File system issues
|
||||||
|
- **Memory Errors**: Large data processing
|
||||||
|
- **User Experience**: Graceful error messages
|
||||||
|
|
||||||
|
### 13.3 Data Validation
|
||||||
|
**Scenario**: Validate user input and data integrity
|
||||||
|
- **Coordinate Validation**: Valid latitude/longitude
|
||||||
|
- **Time Validation**: Logical timestamp values
|
||||||
|
- **File Validation**: Supported formats and sizes
|
||||||
|
- **User Input**: Form validation and sanitization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Performance & Scalability
|
||||||
|
|
||||||
|
### 14.1 Large Dataset Handling
|
||||||
|
**Scenario**: Handle users with large amounts of location data
|
||||||
|
- **Map Performance**: Efficient rendering of many points
|
||||||
|
- **Data Processing**: Batch processing for imports
|
||||||
|
- **Memory Management**: Streaming for large files
|
||||||
|
- **User Experience**: Progress indicators, pagination
|
||||||
|
|
||||||
|
### 14.2 Background Processing
|
||||||
|
**Scenario**: Asynchronous task handling
|
||||||
|
- **Job Queues**: Sidekiq for background jobs
|
||||||
|
- **Progress Tracking**: Real-time job status
|
||||||
|
- **Error Recovery**: Retry mechanisms
|
||||||
|
- **User Feedback**: Job completion notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Mobile & Responsive Design
|
||||||
|
|
||||||
|
### 15.1 Mobile Interface
|
||||||
|
**Scenario**: Mobile-optimized user experience
|
||||||
|
- **Responsive Design**: Mobile-first approach
|
||||||
|
- **Touch Interactions**: Map gestures, mobile-friendly controls
|
||||||
|
- **Mobile Navigation**: Collapsible menus
|
||||||
|
- **Performance**: Optimized for mobile devices
|
||||||
|
|
||||||
|
### 15.2 Cross-Platform Compatibility
|
||||||
|
**Scenario**: Consistent experience across devices
|
||||||
|
- **Browser Support**: Modern browser compatibility
|
||||||
|
- **Device Support**: Desktop, tablet, mobile
|
||||||
|
- **Feature Parity**: Full functionality across platforms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Scenarios Priority
|
||||||
|
|
||||||
|
### High Priority (Core Functionality)
|
||||||
|
1. User authentication (sign in/out)
|
||||||
|
2. Map visualization with basic controls
|
||||||
|
3. Data import (at least one source type)
|
||||||
|
4. Basic settings configuration
|
||||||
|
5. Point display and interaction
|
||||||
|
|
||||||
|
### Medium Priority (Extended Features)
|
||||||
|
1. Trip management
|
||||||
|
2. Visit suggestions and management
|
||||||
|
3. Data export
|
||||||
|
4. Statistics viewing
|
||||||
|
5. Notification system
|
||||||
|
|
||||||
|
### Low Priority (Advanced Features)
|
||||||
|
1. Admin functions
|
||||||
|
2. API functionality
|
||||||
|
3. Complex map settings
|
||||||
|
4. Background job management
|
||||||
|
5. Error handling edge cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Test Implementation
|
||||||
|
|
||||||
|
1. **Test Data**: Use factory-generated test data for consistency
|
||||||
|
2. **API Testing**: Include both UI and API endpoint testing
|
||||||
|
3. **Background Jobs**: Test asynchronous processing
|
||||||
|
4. **File Handling**: Test various file formats and sizes
|
||||||
|
5. **Responsive Testing**: Include mobile viewport testing
|
||||||
|
6. **Performance Testing**: Test with large datasets
|
||||||
|
7. **Error Scenarios**: Include negative test cases
|
||||||
|
8. **Browser Compatibility**: Test across different browsers
|
||||||
|
|
@ -15,12 +15,11 @@ class CreateTracksFromPoints < ActiveRecord::Migration[8.0]
|
||||||
|
|
||||||
# Use explicit parameters for bulk historical processing:
|
# Use explicit parameters for bulk historical processing:
|
||||||
# - No time limits (start_at: nil, end_at: nil) = process ALL historical data
|
# - No time limits (start_at: nil, end_at: nil) = process ALL historical data
|
||||||
# - Replace strategy = clean slate, removes any existing tracks first
|
|
||||||
Tracks::CreateJob.perform_later(
|
Tracks::CreateJob.perform_later(
|
||||||
user.id,
|
user.id,
|
||||||
start_at: nil,
|
start_at: nil,
|
||||||
end_at: nil,
|
end_at: nil,
|
||||||
cleaning_strategy: :replace
|
mode: :bulk
|
||||||
)
|
)
|
||||||
|
|
||||||
processed_users += 1
|
processed_users += 1
|
||||||
|
|
|
||||||
296
e2e/README.md
Normal file
296
e2e/README.md
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
# Dawarich E2E Test Suite
|
||||||
|
|
||||||
|
This directory contains comprehensive end-to-end tests for the Dawarich location tracking application using Playwright.
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
The test suite is organized into several test files that cover different aspects of the application:
|
||||||
|
|
||||||
|
### Core Test Files
|
||||||
|
|
||||||
|
- **`auth.spec.ts`** - Authentication and user management tests
|
||||||
|
- **`map.spec.ts`** - Map functionality and visualization tests
|
||||||
|
- **`imports.spec.ts`** - Data import functionality tests
|
||||||
|
- **`settings.spec.ts`** - Application settings and configuration tests
|
||||||
|
- **`navigation.spec.ts`** - Navigation and UI interaction tests
|
||||||
|
- **`trips.spec.ts`** - Trip management and analysis tests
|
||||||
|
|
||||||
|
### Helper Files
|
||||||
|
|
||||||
|
- **`fixtures/test-helpers.ts`** - Reusable test utilities and helper functions
|
||||||
|
- **`global-setup.ts`** - Global test environment setup
|
||||||
|
- **`example.spec.ts`** - Basic example test (can be removed)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- **`playwright.config.ts`** - Playwright configuration with browser setup, timeouts, and test settings
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. Node.js and npm installed
|
||||||
|
2. Dawarich application running locally on port 3000 (or configured port)
|
||||||
|
3. Test environment properly configured
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Playwright
|
||||||
|
npm install -D @playwright/test
|
||||||
|
|
||||||
|
# Install browsers (first time only)
|
||||||
|
npx playwright install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Run tests in headed mode (see browser)
|
||||||
|
npx playwright test --headed
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
npx playwright test auth.spec.ts
|
||||||
|
|
||||||
|
# Run tests with specific browser
|
||||||
|
npx playwright test --project=chromium
|
||||||
|
|
||||||
|
# Run tests in debug mode
|
||||||
|
npx playwright test --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Reports
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate HTML report
|
||||||
|
npx playwright show-report
|
||||||
|
|
||||||
|
# View last test results
|
||||||
|
npx playwright show-report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
### High Priority Features (✅ Covered)
|
||||||
|
- User authentication (login/logout)
|
||||||
|
- Map visualization and interaction
|
||||||
|
- Data import from various sources
|
||||||
|
- Basic settings configuration
|
||||||
|
- Navigation and UI interactions
|
||||||
|
- Trip management and creation
|
||||||
|
|
||||||
|
### Medium Priority Features (✅ Covered)
|
||||||
|
- Settings management (integrations, map config)
|
||||||
|
- Mobile responsive behavior
|
||||||
|
- Data visualization and statistics
|
||||||
|
- File upload handling
|
||||||
|
- User preferences and customization
|
||||||
|
|
||||||
|
### Low Priority Features (✅ Covered)
|
||||||
|
- Advanced trip analysis
|
||||||
|
- Performance testing
|
||||||
|
- Error handling
|
||||||
|
- Accessibility testing
|
||||||
|
- Keyboard navigation
|
||||||
|
|
||||||
|
## Test Patterns
|
||||||
|
|
||||||
|
### Helper Functions
|
||||||
|
|
||||||
|
Use the `TestHelpers` class for common operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TestHelpers } from './fixtures/test-helpers';
|
||||||
|
|
||||||
|
test('example', async ({ page }) => {
|
||||||
|
const helpers = new TestHelpers(page);
|
||||||
|
await helpers.loginAsDemo();
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.waitForMap();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
Tests are organized with descriptive `test.describe` blocks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test.describe('Feature Name', () => {
|
||||||
|
test.describe('Sub-feature', () => {
|
||||||
|
test('should do something specific', async ({ page }) => {
|
||||||
|
// Test implementation
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
|
||||||
|
Use clear, descriptive assertions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
await expect(page.getByRole('heading', { name: 'Map' })).toBeVisible();
|
||||||
|
|
||||||
|
// Better with context
|
||||||
|
await expect(page.getByRole('button', { name: 'Create Trip' })).toBeVisible();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Notes
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
The tests use these environment variables:
|
||||||
|
|
||||||
|
- `BASE_URL` - Base URL for the application (defaults to http://localhost:3000)
|
||||||
|
- `CI` - Set to true in CI environments
|
||||||
|
|
||||||
|
### Test Data
|
||||||
|
|
||||||
|
Tests use the demo user credentials:
|
||||||
|
- Email: `demo@dawarich.app`
|
||||||
|
- Password: `password`
|
||||||
|
|
||||||
|
### Browser Configuration
|
||||||
|
|
||||||
|
Tests run on:
|
||||||
|
- Chromium (primary)
|
||||||
|
- Firefox
|
||||||
|
- WebKit (Safari)
|
||||||
|
- Mobile Chrome
|
||||||
|
- Mobile Safari
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Test Independence
|
||||||
|
|
||||||
|
Each test should be independent and able to run in isolation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const helpers = new TestHelpers(page);
|
||||||
|
await helpers.loginAsDemo();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Robust Selectors
|
||||||
|
|
||||||
|
Use semantic selectors that won't break easily:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
await page.getByRole('button', { name: 'Save' });
|
||||||
|
await page.getByLabel('Email');
|
||||||
|
|
||||||
|
// Avoid
|
||||||
|
await page.locator('.btn-primary');
|
||||||
|
await page.locator('#email-input');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Wait for Conditions
|
||||||
|
|
||||||
|
Wait for specific conditions rather than arbitrary timeouts:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.getByText('Success')).toBeVisible();
|
||||||
|
|
||||||
|
// Avoid
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Optional Elements
|
||||||
|
|
||||||
|
Use conditional logic for elements that may not exist:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const deleteButton = page.getByRole('button', { name: 'Delete' });
|
||||||
|
if (await deleteButton.isVisible()) {
|
||||||
|
await deleteButton.click();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Mobile Testing
|
||||||
|
|
||||||
|
Include mobile viewport testing:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('should work on mobile', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
// Test implementation
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Adding New Tests
|
||||||
|
|
||||||
|
1. Create tests in the appropriate spec file
|
||||||
|
2. Use descriptive test names
|
||||||
|
3. Follow the existing patterns
|
||||||
|
4. Update this README if adding new test files
|
||||||
|
|
||||||
|
### Updating Selectors
|
||||||
|
|
||||||
|
When the application UI changes:
|
||||||
|
1. Update selectors in helper functions first
|
||||||
|
2. Run tests to identify breaking changes
|
||||||
|
3. Update individual test files as needed
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
|
||||||
|
- Tests include performance checks for critical paths
|
||||||
|
- Map loading times are monitored
|
||||||
|
- Navigation speed is tested
|
||||||
|
- Large dataset handling is verified
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Server not ready** - Ensure Dawarich is running on the correct port
|
||||||
|
2. **Element not found** - Check if UI has changed or element is conditionally rendered
|
||||||
|
3. **Timeouts** - Verify network conditions and increase timeouts if needed
|
||||||
|
4. **Map not loading** - Ensure map dependencies are available
|
||||||
|
|
||||||
|
### Debug Tips
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with debug flag
|
||||||
|
npx playwright test --debug
|
||||||
|
|
||||||
|
# Run specific test with trace
|
||||||
|
npx playwright test auth.spec.ts --trace on
|
||||||
|
|
||||||
|
# Record video on failure
|
||||||
|
npx playwright test --video retain-on-failure
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
The test suite is configured for CI/CD with:
|
||||||
|
- Automatic retry on failure
|
||||||
|
- Parallel execution control
|
||||||
|
- Artifact collection (screenshots, videos, traces)
|
||||||
|
- HTML report generation
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding new tests:
|
||||||
|
1. Follow the existing patterns
|
||||||
|
2. Add appropriate test coverage
|
||||||
|
3. Update documentation
|
||||||
|
4. Ensure tests pass in all browsers
|
||||||
|
5. Consider mobile and accessibility aspects
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues with the test suite:
|
||||||
|
1. Check the test logs and reports
|
||||||
|
2. Verify application state
|
||||||
|
3. Review recent changes
|
||||||
|
4. Check browser compatibility
|
||||||
509
e2e/auth.spec.ts
Normal file
509
e2e/auth.spec.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { TestHelpers, TEST_USERS } from './fixtures/test-helpers';
|
||||||
|
|
||||||
|
test.describe('Authentication', () => {
|
||||||
|
let helpers: TestHelpers;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
helpers = new TestHelpers(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Login and Logout', () => {
|
||||||
|
test('should display login page correctly', async ({ page }) => {
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
|
||||||
|
// Check page elements based on actual Devise view
|
||||||
|
await expect(page).toHaveTitle(/Dawarich/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Login now' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Email')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Password')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Forgot your password?' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show demo credentials in demo environment', async ({ page }) => {
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
|
||||||
|
// Check if demo credentials are shown (they may not be in test environment)
|
||||||
|
const demoCredentials = page.getByText('demo@dawarich.app');
|
||||||
|
if (await demoCredentials.isVisible()) {
|
||||||
|
await expect(demoCredentials).toBeVisible();
|
||||||
|
await expect(page.getByText('password').nth(1)).toBeVisible(); // Second "password" text
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should login with valid credentials', async ({ page }) => {
|
||||||
|
await helpers.loginAsDemo();
|
||||||
|
|
||||||
|
// Verify successful login - should redirect to map
|
||||||
|
await expect(page).toHaveURL(/\/map/);
|
||||||
|
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject invalid credentials', async ({ page }) => {
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill('invalid@email.com');
|
||||||
|
await page.getByLabel('Password').fill('wrongpassword');
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
|
||||||
|
// Should stay on login page and show error
|
||||||
|
await expect(page).toHaveURL(/\/users\/sign_in/);
|
||||||
|
// Devise shows error messages - look for error indication
|
||||||
|
const errorMessage = page.locator('#error_explanation, .alert, .flash').filter({ hasText: /invalid/i });
|
||||||
|
if (await errorMessage.isVisible()) {
|
||||||
|
await expect(errorMessage).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remember user when "Remember me" is checked', async ({ page }) => {
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||||
|
|
||||||
|
// Look for remember me checkbox - use getByRole to target the actual checkbox
|
||||||
|
const rememberCheckbox = page.getByRole('checkbox', { name: 'Remember me' });
|
||||||
|
|
||||||
|
if (await rememberCheckbox.isVisible()) {
|
||||||
|
await rememberCheckbox.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
|
||||||
|
// Wait for redirect with longer timeout
|
||||||
|
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Check for remember token cookie
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const hasPersistentCookie = cookies.some(cookie =>
|
||||||
|
cookie.name.includes('remember') || cookie.name.includes('session')
|
||||||
|
);
|
||||||
|
expect(hasPersistentCookie).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should logout successfully', async ({ page }) => {
|
||||||
|
await helpers.loginAsDemo();
|
||||||
|
|
||||||
|
// Open user dropdown using the actual navigation structure
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
|
||||||
|
// Use evaluate to trigger the logout form submission properly
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const logoutLink = document.querySelector('a[href="/users/sign_out"]');
|
||||||
|
if (logoutLink) {
|
||||||
|
// Create a form and submit it with DELETE method (Rails UJS style)
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.action = '/users/sign_out';
|
||||||
|
form.method = 'post';
|
||||||
|
form.style.display = 'none';
|
||||||
|
|
||||||
|
// Add method override for DELETE
|
||||||
|
const methodInput = document.createElement('input');
|
||||||
|
methodInput.type = 'hidden';
|
||||||
|
methodInput.name = '_method';
|
||||||
|
methodInput.value = 'delete';
|
||||||
|
form.appendChild(methodInput);
|
||||||
|
|
||||||
|
// Add CSRF token
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (csrfToken) {
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'authenticity_token';
|
||||||
|
const tokenValue = csrfToken.getAttribute('content');
|
||||||
|
if (tokenValue) {
|
||||||
|
csrfInput.value = tokenValue;
|
||||||
|
}
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for redirect and navigate to home to verify logout
|
||||||
|
await page.waitForURL('/', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify user is logged out - should see login options
|
||||||
|
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should redirect to login when accessing protected pages while logged out', async ({ page }) => {
|
||||||
|
await page.goto('/map');
|
||||||
|
|
||||||
|
// Should redirect to login
|
||||||
|
await expect(page).toHaveURL(/\/users\/sign_in/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE: Update TEST_USERS in fixtures/test-helpers.ts with correct credentials
|
||||||
|
// that match your localhost:3000 server setup
|
||||||
|
test.describe('Password Management', () => {
|
||||||
|
test('should display forgot password form', async ({ page }) => {
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/users\/password\/new/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Forgot your password?' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Email')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Send me reset password instructions' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle password reset request', async ({ page }) => {
|
||||||
|
await page.goto('/users/password/new');
|
||||||
|
|
||||||
|
// Fill the email but don't submit to avoid sending actual reset emails
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
|
||||||
|
// Verify the form elements exist and are functional
|
||||||
|
await expect(page.getByRole('button', { name: 'Send me reset password instructions' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Email')).toHaveValue(TEST_USERS.DEMO.email);
|
||||||
|
|
||||||
|
// Test form validation by clearing email and checking if button is still clickable
|
||||||
|
await page.getByLabel('Email').fill('');
|
||||||
|
await expect(page.getByRole('button', { name: 'Send me reset password instructions' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should change password when logged in', async ({ page }) => {
|
||||||
|
// Manual login for this test
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Navigate to account settings through user dropdown
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await page.getByRole('link', { name: 'Account' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/users\/edit/);
|
||||||
|
|
||||||
|
// Check password change form is available - be more specific with selectors
|
||||||
|
await expect(page.locator('input[id="user_password"]')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Current password')).toBeVisible();
|
||||||
|
|
||||||
|
// Test filling the form but don't submit to avoid changing the password
|
||||||
|
await page.locator('input[id="user_password"]').fill('newpassword123');
|
||||||
|
await page.getByLabel('Current password').fill(TEST_USERS.DEMO.password);
|
||||||
|
|
||||||
|
// Verify the form can be filled and update button is present
|
||||||
|
await expect(page.getByRole('button', { name: 'Update' })).toBeVisible();
|
||||||
|
|
||||||
|
// Clear the password fields to avoid changing credentials
|
||||||
|
await page.locator('input[id="user_password"]').fill('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Account Settings', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Fresh login for each test in this describe block
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display account settings page', async ({ page }) => {
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await page.getByRole('link', { name: 'Account' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/users\/edit/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Edit your account!' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Email')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update email address with current password', async ({ page }) => {
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await page.getByRole('link', { name: 'Account' }).click();
|
||||||
|
|
||||||
|
// Test that we can fill the form, but don't actually submit to avoid changing credentials
|
||||||
|
await page.getByLabel('Email').fill('newemail@test.com');
|
||||||
|
await page.getByLabel('Current password').fill(TEST_USERS.DEMO.password);
|
||||||
|
|
||||||
|
// Verify the form elements are present and fillable, but don't submit
|
||||||
|
await expect(page.getByRole('button', { name: 'Update' })).toBeVisible();
|
||||||
|
|
||||||
|
// Reset the email field to avoid confusion
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should view API key in settings', async ({ page }) => {
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await page.getByRole('link', { name: 'Account' }).click();
|
||||||
|
|
||||||
|
// API key should be visible in the account section
|
||||||
|
await expect(page.getByText('Use this API key')).toBeVisible();
|
||||||
|
await expect(page.locator('code').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should generate new API key', async ({ page }) => {
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await page.getByRole('link', { name: 'Account' }).click();
|
||||||
|
|
||||||
|
// Get current API key
|
||||||
|
const currentApiKey = await page.locator('code').first().textContent();
|
||||||
|
|
||||||
|
// Verify the generate new API key link exists but don't click it to avoid changing the key
|
||||||
|
const generateKeyLink = page.getByRole('link', { name: 'Generate new API key' });
|
||||||
|
await expect(generateKeyLink).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the API key is displayed
|
||||||
|
await expect(page.locator('code').first()).toBeVisible();
|
||||||
|
expect(currentApiKey).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should change theme', async ({ page }) => {
|
||||||
|
// Theme toggle is in the navbar
|
||||||
|
const themeButton = page.locator('svg').locator('..').filter({ hasText: /path/ });
|
||||||
|
|
||||||
|
if (await themeButton.isVisible()) {
|
||||||
|
// Get current theme
|
||||||
|
const htmlElement = page.locator('html');
|
||||||
|
const currentTheme = await htmlElement.getAttribute('data-theme');
|
||||||
|
|
||||||
|
await themeButton.click();
|
||||||
|
|
||||||
|
// Wait for theme change
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Theme should have changed
|
||||||
|
const newTheme = await htmlElement.getAttribute('data-theme');
|
||||||
|
expect(newTheme).not.toBe(currentTheme);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Registration (Non-Self-Hosted)', () => {
|
||||||
|
test('should show registration link when not self-hosted', async ({ page }) => {
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
|
||||||
|
// Registration link may or may not be visible depending on SELF_HOSTED setting
|
||||||
|
const registerLink = page.getByRole('link', { name: 'Register' }).first(); // Use first to avoid strict mode
|
||||||
|
const selfHosted = await page.getAttribute('html', 'data-self-hosted');
|
||||||
|
|
||||||
|
if (selfHosted === 'false') {
|
||||||
|
await expect(registerLink).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(registerLink).not.toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display registration form when available', async ({ page }) => {
|
||||||
|
await page.goto('/users/sign_up');
|
||||||
|
|
||||||
|
// May redirect if self-hosted, so check current URL
|
||||||
|
if (page.url().includes('/users/sign_up')) {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Register now!' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Email')).toBeVisible();
|
||||||
|
await expect(page.locator('input[id="user_password"]')).toBeVisible(); // Be specific for main password field
|
||||||
|
await expect(page.locator('input[id="user_password_confirmation"]')).toBeVisible(); // Use ID for confirmation field
|
||||||
|
await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Mobile Authentication', () => {
|
||||||
|
test('should work on mobile viewport', async ({ page }) => {
|
||||||
|
// Set mobile viewport
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
|
||||||
|
// Check mobile-responsive login form
|
||||||
|
await expect(page.getByLabel('Email')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Password')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
|
||||||
|
|
||||||
|
// Test login on mobile
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mobile navigation after login', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
// Manual login
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Open mobile navigation using hamburger menu
|
||||||
|
const mobileMenuButton = page.locator('label[tabindex="0"]').or(
|
||||||
|
page.locator('button').filter({ hasText: /menu/i })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (await mobileMenuButton.isVisible()) {
|
||||||
|
await mobileMenuButton.click();
|
||||||
|
|
||||||
|
// Should see user email in mobile menu structure
|
||||||
|
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mobile logout', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
// Manual login
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// In mobile view, user dropdown should still work
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
|
||||||
|
// Use evaluate to trigger the logout form submission properly
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const logoutLink = document.querySelector('a[href="/users/sign_out"]');
|
||||||
|
if (logoutLink) {
|
||||||
|
// Create a form and submit it with DELETE method (Rails UJS style)
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.action = '/users/sign_out';
|
||||||
|
form.method = 'post';
|
||||||
|
form.style.display = 'none';
|
||||||
|
|
||||||
|
// Add method override for DELETE
|
||||||
|
const methodInput = document.createElement('input');
|
||||||
|
methodInput.type = 'hidden';
|
||||||
|
methodInput.name = '_method';
|
||||||
|
methodInput.value = 'delete';
|
||||||
|
form.appendChild(methodInput);
|
||||||
|
|
||||||
|
// Add CSRF token
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (csrfToken) {
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'authenticity_token';
|
||||||
|
const tokenValue = csrfToken.getAttribute('content');
|
||||||
|
if (tokenValue) {
|
||||||
|
csrfInput.value = tokenValue;
|
||||||
|
}
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for redirect and navigate to home to verify logout
|
||||||
|
await page.waitForURL('/', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify user is logged out - should see login options
|
||||||
|
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Navigation Integration', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Manual login for each test in this describe block
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show user email in navigation', async ({ page }) => {
|
||||||
|
// User email should be visible in the navbar dropdown
|
||||||
|
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show admin indicator for admin users', async ({ page }) => {
|
||||||
|
// Look for admin star indicator if user is admin
|
||||||
|
const adminStar = page.getByText('⭐️');
|
||||||
|
// Admin indicator may not be visible for demo user
|
||||||
|
const isVisible = await adminStar.isVisible();
|
||||||
|
// Just verify the page doesn't crash
|
||||||
|
expect(typeof isVisible).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should access settings through navigation', async ({ page }) => {
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: TEST_USERS.DEMO.email });
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await page.getByRole('link', { name: 'Settings' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/settings/);
|
||||||
|
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show version badge in navigation', async ({ page }) => {
|
||||||
|
// Version badge should be visible
|
||||||
|
const versionBadge = page.locator('.badge').filter({ hasText: /\d+\.\d+/ });
|
||||||
|
await expect(versionBadge).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show notifications dropdown', async ({ page }) => {
|
||||||
|
// Notifications dropdown should be present - look for the notification bell icon more directly
|
||||||
|
const notificationDropdown = page.locator('[data-controller="notifications"]');
|
||||||
|
|
||||||
|
if (await notificationDropdown.isVisible()) {
|
||||||
|
await expect(notificationDropdown).toBeVisible();
|
||||||
|
} else {
|
||||||
|
// Alternative: Look for notification button/bell icon
|
||||||
|
const notificationButton = page.locator('svg').filter({ hasText: /path.*stroke.*d=/ });
|
||||||
|
if (await notificationButton.first().isVisible()) {
|
||||||
|
await expect(notificationButton.first()).toBeVisible();
|
||||||
|
} else {
|
||||||
|
// If notifications aren't available, just check that the navbar exists
|
||||||
|
const navbar = page.locator('.navbar');
|
||||||
|
await expect(navbar).toBeVisible();
|
||||||
|
console.log('Notifications dropdown not found, but navbar is present');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Session Management', () => {
|
||||||
|
test('should maintain session across page reloads', async ({ page }) => {
|
||||||
|
// Manual login
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Reload page
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Should still be logged in
|
||||||
|
await expect(page.getByText(TEST_USERS.DEMO.email)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/map/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle session timeout gracefully', async ({ page }) => {
|
||||||
|
// Manual login
|
||||||
|
await page.goto('/users/sign_in');
|
||||||
|
await page.getByLabel('Email').fill(TEST_USERS.DEMO.email);
|
||||||
|
await page.getByLabel('Password').fill(TEST_USERS.DEMO.password);
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Clear all cookies to simulate session timeout
|
||||||
|
await page.context().clearCookies();
|
||||||
|
|
||||||
|
// Try to access protected page
|
||||||
|
await page.goto('/settings');
|
||||||
|
|
||||||
|
// Should redirect to login
|
||||||
|
await expect(page).toHaveURL(/\/users\/sign_in/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
366
e2e/fixtures/test-helpers.ts
Normal file
366
e2e/fixtures/test-helpers.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export interface TestUser {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestHelpers {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the home page
|
||||||
|
*/
|
||||||
|
async goToHomePage() {
|
||||||
|
await this.page.goto('/');
|
||||||
|
await expect(this.page).toHaveTitle(/Dawarich/);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with provided credentials
|
||||||
|
*/
|
||||||
|
async login(user: TestUser) {
|
||||||
|
await this.page.goto('/users/sign_in');
|
||||||
|
|
||||||
|
// Fill in login form using actual Devise structure
|
||||||
|
await this.page.getByLabel('Email').fill(user.email);
|
||||||
|
await this.page.getByLabel('Password').fill(user.password);
|
||||||
|
|
||||||
|
// Submit login
|
||||||
|
await this.page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
|
||||||
|
// Wait for navigation to complete - use the same approach as working tests
|
||||||
|
await this.page.waitForURL(/\/map/, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify user is logged in by checking for email in navbar
|
||||||
|
await expect(this.page.getByText(user.email)).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with demo credentials
|
||||||
|
*/
|
||||||
|
async loginAsDemo() {
|
||||||
|
await this.login({ email: 'demo@dawarich.app', password: 'password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout current user using actual navigation structure
|
||||||
|
*/
|
||||||
|
async logout() {
|
||||||
|
// Open user dropdown using the actual navigation structure - use first() to avoid strict mode
|
||||||
|
const userDropdown = this.page.locator('details').filter({ hasText: /@/ }).first();
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
|
||||||
|
// Use evaluate to trigger the logout form submission properly
|
||||||
|
await this.page.evaluate(() => {
|
||||||
|
const logoutLink = document.querySelector('a[href="/users/sign_out"]');
|
||||||
|
if (logoutLink) {
|
||||||
|
// Create a form and submit it with DELETE method (Rails UJS style)
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.action = '/users/sign_out';
|
||||||
|
form.method = 'post';
|
||||||
|
form.style.display = 'none';
|
||||||
|
|
||||||
|
// Add method override for DELETE
|
||||||
|
const methodInput = document.createElement('input');
|
||||||
|
methodInput.type = 'hidden';
|
||||||
|
methodInput.name = '_method';
|
||||||
|
methodInput.value = 'delete';
|
||||||
|
form.appendChild(methodInput);
|
||||||
|
|
||||||
|
// Add CSRF token
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (csrfToken) {
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'authenticity_token';
|
||||||
|
const tokenValue = csrfToken.getAttribute('content');
|
||||||
|
if (tokenValue) {
|
||||||
|
csrfInput.value = tokenValue;
|
||||||
|
}
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for redirect and navigate to home to verify logout
|
||||||
|
await this.page.waitForURL('/', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify user is logged out - should see login options
|
||||||
|
await expect(this.page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to specific section using actual navigation structure
|
||||||
|
*/
|
||||||
|
async navigateTo(section: 'Map' | 'Trips' | 'Stats' | 'Points' | 'Visits' | 'Imports' | 'Exports' | 'Settings') {
|
||||||
|
// Check if already on the target page
|
||||||
|
const currentUrl = this.page.url();
|
||||||
|
const targetPath = section.toLowerCase();
|
||||||
|
|
||||||
|
if (section === 'Map' && (currentUrl.includes('/map') || currentUrl.endsWith('/'))) {
|
||||||
|
// Already on map page, just navigate directly
|
||||||
|
await this.page.goto('/map');
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle nested menu items that are in "My data" dropdown
|
||||||
|
if (['Points', 'Visits', 'Imports', 'Exports'].includes(section)) {
|
||||||
|
// Open "My data" dropdown - select the visible one (not the hidden mobile version)
|
||||||
|
const myDataDropdown = this.page.locator('details').filter({ hasText: 'My data' }).and(this.page.locator(':visible'));
|
||||||
|
await myDataDropdown.locator('summary').click();
|
||||||
|
|
||||||
|
// Handle special cases for visit links
|
||||||
|
if (section === 'Visits') {
|
||||||
|
await this.page.getByRole('link', { name: 'Visits & Places' }).click();
|
||||||
|
} else {
|
||||||
|
await this.page.getByRole('link', { name: section }).click();
|
||||||
|
}
|
||||||
|
} else if (section === 'Settings') {
|
||||||
|
// Settings is accessed through user dropdown - use first() to avoid strict mode
|
||||||
|
const userDropdown = this.page.locator('details').filter({ hasText: /@/ }).first();
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await this.page.getByRole('link', { name: 'Settings' }).click();
|
||||||
|
} else {
|
||||||
|
// Direct navigation items (Map, Trips, Stats)
|
||||||
|
// Try to find the link, if not found, navigate directly
|
||||||
|
const navLink = this.page.getByRole('link', { name: section });
|
||||||
|
try {
|
||||||
|
await navLink.click({ timeout: 2000 });
|
||||||
|
} catch (error) {
|
||||||
|
// If link not found, navigate directly to the page
|
||||||
|
await this.page.goto(`/${targetPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for map to be loaded and interactive
|
||||||
|
*/
|
||||||
|
async waitForMap() {
|
||||||
|
// Wait for map container to be visible - the #map element is always present
|
||||||
|
await expect(this.page.locator('#map')).toBeVisible();
|
||||||
|
|
||||||
|
// Wait for map controls to be available (indicates map is functional)
|
||||||
|
await expect(this.page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||||
|
|
||||||
|
// Wait a bit more for any async loading
|
||||||
|
await this.page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if notification with specific text is visible
|
||||||
|
*/
|
||||||
|
async expectNotification(text: string, type: 'success' | 'error' | 'info' = 'success') {
|
||||||
|
// Use actual flash message structure from Dawarich
|
||||||
|
const notification = this.page.locator('#flash-messages .alert, #flash-messages div').filter({ hasText: text });
|
||||||
|
await expect(notification.first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file using the file input
|
||||||
|
*/
|
||||||
|
async uploadFile(inputSelector: string, filePath: string) {
|
||||||
|
const fileInput = this.page.locator(inputSelector);
|
||||||
|
await fileInput.setInputFiles(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for background job to complete (polling approach)
|
||||||
|
*/
|
||||||
|
async waitForJobCompletion(jobName: string, timeout = 30000) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeout) {
|
||||||
|
// Check if there's a completion notification in flash messages
|
||||||
|
const completionNotification = this.page.locator('#flash-messages').filter({
|
||||||
|
hasText: new RegExp(jobName + '.*(completed|finished|done)', 'i')
|
||||||
|
});
|
||||||
|
|
||||||
|
if (await completionNotification.isVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before checking again
|
||||||
|
await this.page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Job "${jobName}" did not complete within ${timeout}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate test file content for imports
|
||||||
|
*/
|
||||||
|
createTestGeoJSON(pointCount = 10): string {
|
||||||
|
const features: any[] = [];
|
||||||
|
const baseTime = Date.now() - (pointCount * 60 * 1000); // Points every minute
|
||||||
|
|
||||||
|
for (let i = 0; i < pointCount; i++) {
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [-74.0060 + (i * 0.001), 40.7128 + (i * 0.001)]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
timestamp: Math.floor((baseTime + (i * 60 * 1000)) / 1000)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if element is visible on mobile viewports
|
||||||
|
*/
|
||||||
|
async isMobileViewport(): Promise<boolean> {
|
||||||
|
const viewport = this.page.viewportSize();
|
||||||
|
return viewport ? viewport.width < 768 : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mobile navigation (hamburger menu) using actual structure
|
||||||
|
*/
|
||||||
|
async openMobileNavigation() {
|
||||||
|
if (await this.isMobileViewport()) {
|
||||||
|
// Use actual mobile menu button structure from navbar
|
||||||
|
const mobileMenuButton = this.page.locator('label[tabindex="0"]').or(
|
||||||
|
this.page.locator('button').filter({ hasText: /menu/i })
|
||||||
|
);
|
||||||
|
|
||||||
|
if (await mobileMenuButton.isVisible()) {
|
||||||
|
await mobileMenuButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access account settings through user dropdown
|
||||||
|
*/
|
||||||
|
async goToAccountSettings() {
|
||||||
|
const userDropdown = this.page.locator('details').filter({ hasText: /@/ }).first();
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await this.page.getByRole('link', { name: 'Account' }).click();
|
||||||
|
|
||||||
|
await expect(this.page).toHaveURL(/\/users\/edit/);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is admin by looking for admin indicator
|
||||||
|
*/
|
||||||
|
async isUserAdmin(): Promise<boolean> {
|
||||||
|
const adminStar = this.page.getByText('⭐️');
|
||||||
|
return await adminStar.isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current theme from HTML data attribute
|
||||||
|
*/
|
||||||
|
async getCurrentTheme(): Promise<string | null> {
|
||||||
|
return await this.page.getAttribute('html', 'data-theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if app is in self-hosted mode
|
||||||
|
*/
|
||||||
|
async isSelfHosted(): Promise<boolean> {
|
||||||
|
const selfHosted = await this.page.getAttribute('html', 'data-self-hosted');
|
||||||
|
return selfHosted === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle theme using navbar theme button
|
||||||
|
*/
|
||||||
|
async toggleTheme() {
|
||||||
|
// Theme button is an SVG inside a link
|
||||||
|
const themeButton = this.page.locator('svg').locator('..').filter({ hasText: /path/ });
|
||||||
|
|
||||||
|
if (await themeButton.isVisible()) {
|
||||||
|
await themeButton.click();
|
||||||
|
// Wait for theme change to take effect
|
||||||
|
await this.page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if notifications dropdown is available
|
||||||
|
*/
|
||||||
|
async hasNotifications(): Promise<boolean> {
|
||||||
|
const notificationButton = this.page.locator('svg').locator('..').filter({ hasText: /path.*stroke/ });
|
||||||
|
return await notificationButton.first().isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open notifications dropdown
|
||||||
|
*/
|
||||||
|
async openNotifications() {
|
||||||
|
if (await this.hasNotifications()) {
|
||||||
|
const notificationButton = this.page.locator('svg').locator('..').filter({ hasText: /path.*stroke/ }).first();
|
||||||
|
await notificationButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate new API key from account settings
|
||||||
|
*/
|
||||||
|
async generateNewApiKey() {
|
||||||
|
await this.goToAccountSettings();
|
||||||
|
|
||||||
|
// Get current API key
|
||||||
|
const currentApiKey = await this.page.locator('code').first().textContent();
|
||||||
|
|
||||||
|
// Click generate new API key button
|
||||||
|
await this.page.getByRole('link', { name: 'Generate new API key' }).click();
|
||||||
|
|
||||||
|
// Wait for page to reload with new key
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Return new API key
|
||||||
|
const newApiKey = await this.page.locator('code').first().textContent();
|
||||||
|
return { currentApiKey, newApiKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access specific settings section
|
||||||
|
*/
|
||||||
|
async goToSettings(section?: 'Maps' | 'Background Jobs' | 'Users') {
|
||||||
|
await this.navigateTo('Settings');
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
// Click on the specific settings tab
|
||||||
|
await this.page.getByRole('tab', { name: section }).click();
|
||||||
|
await this.page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test data constants
|
||||||
|
export const TEST_USERS = {
|
||||||
|
DEMO: {
|
||||||
|
email: 'demo@dawarich.app',
|
||||||
|
password: 'password'
|
||||||
|
},
|
||||||
|
ADMIN: {
|
||||||
|
email: 'admin@dawarich.app',
|
||||||
|
password: 'password',
|
||||||
|
isAdmin: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEST_COORDINATES = {
|
||||||
|
NYC: { lat: 40.7128, lon: -74.0060, name: 'New York City' },
|
||||||
|
LONDON: { lat: 51.5074, lon: -0.1278, name: 'London' },
|
||||||
|
TOKYO: { lat: 35.6762, lon: 139.6503, name: 'Tokyo' }
|
||||||
|
};
|
||||||
39
e2e/global-setup.ts
Normal file
39
e2e/global-setup.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { chromium, FullConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
async function globalSetup(config: FullConfig) {
|
||||||
|
const { baseURL } = config.projects[0].use;
|
||||||
|
|
||||||
|
// Launch browser for setup operations
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for the server to be ready
|
||||||
|
console.log('Checking if Dawarich server is available...');
|
||||||
|
|
||||||
|
// Try to connect to the health endpoint
|
||||||
|
try {
|
||||||
|
await page.goto(baseURL + '/api/v1/health', { waitUntil: 'networkidle', timeout: 10000 });
|
||||||
|
console.log('Health endpoint is accessible');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Health endpoint not available, trying main page...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we can access the main app
|
||||||
|
const response = await page.goto(baseURL + '/', { timeout: 15000 });
|
||||||
|
if (!response?.ok()) {
|
||||||
|
throw new Error(`Server not available. Status: ${response?.status()}. Make sure Dawarich is running on ${baseURL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Dawarich server is ready for testing');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to Dawarich server:', error);
|
||||||
|
console.error(`Please make sure Dawarich is running on ${baseURL}`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalSetup;
|
||||||
427
e2e/map.spec.ts
Normal file
427
e2e/map.spec.ts
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { TestHelpers } from './fixtures/test-helpers';
|
||||||
|
|
||||||
|
test.describe('Map Functionality', () => {
|
||||||
|
let helpers: TestHelpers;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
helpers = new TestHelpers(page);
|
||||||
|
await helpers.loginAsDemo();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Main Map Interface', () => {
|
||||||
|
test('should display map page correctly', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Check page title and basic elements
|
||||||
|
await expect(page).toHaveTitle(/Map.*Dawarich/);
|
||||||
|
// Check for map controls instead of specific #map element
|
||||||
|
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||||
|
|
||||||
|
// Wait for map to be fully loaded
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
// Check for time range controls
|
||||||
|
await expect(page.getByLabel('Start at')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('End at')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Search' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load Leaflet map correctly', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
// Check that map functionality is available - either Leaflet or other map implementation
|
||||||
|
const mapInitialized = await page.evaluate(() => {
|
||||||
|
const mapElement = document.querySelector('#map');
|
||||||
|
return mapElement && (mapElement as any)._leaflet_id;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If Leaflet is not found, check for basic map functionality
|
||||||
|
if (!mapInitialized) {
|
||||||
|
// Verify map controls are working
|
||||||
|
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Zoom out' })).toBeVisible();
|
||||||
|
} else {
|
||||||
|
expect(mapInitialized).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display time range controls', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Check time controls
|
||||||
|
await expect(page.getByLabel('Start at')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('End at')).toBeVisible();
|
||||||
|
|
||||||
|
// Check quick time range buttons
|
||||||
|
await expect(page.getByRole('link', { name: 'Today' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Last 7 days' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Last month' })).toBeVisible();
|
||||||
|
|
||||||
|
// Check navigation arrows
|
||||||
|
await expect(page.getByRole('link', { name: '◀️' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: '▶️' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate between dates using arrows', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Wait for initial page load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Verify navigation arrows exist and are functional
|
||||||
|
const prevArrow = page.getByRole('link', { name: '◀️' });
|
||||||
|
const nextArrow = page.getByRole('link', { name: '▶️' });
|
||||||
|
|
||||||
|
await expect(prevArrow).toBeVisible();
|
||||||
|
await expect(nextArrow).toBeVisible();
|
||||||
|
|
||||||
|
// Check that arrows have proper href attributes with date parameters
|
||||||
|
const prevHref = await prevArrow.getAttribute('href');
|
||||||
|
const nextHref = await nextArrow.getAttribute('href');
|
||||||
|
|
||||||
|
expect(prevHref).toContain('start_at');
|
||||||
|
expect(nextHref).toContain('start_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use quick time range buttons', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Verify quick time range buttons exist and have proper hrefs
|
||||||
|
const todayButton = page.getByRole('link', { name: 'Today' });
|
||||||
|
const lastWeekButton = page.getByRole('link', { name: 'Last 7 days' });
|
||||||
|
const lastMonthButton = page.getByRole('link', { name: 'Last month' });
|
||||||
|
|
||||||
|
await expect(todayButton).toBeVisible();
|
||||||
|
await expect(lastWeekButton).toBeVisible();
|
||||||
|
await expect(lastMonthButton).toBeVisible();
|
||||||
|
|
||||||
|
// Check that buttons have proper href attributes with date parameters
|
||||||
|
const todayHref = await todayButton.getAttribute('href');
|
||||||
|
const lastWeekHref = await lastWeekButton.getAttribute('href');
|
||||||
|
const lastMonthHref = await lastMonthButton.getAttribute('href');
|
||||||
|
|
||||||
|
expect(todayHref).toContain('start_at');
|
||||||
|
expect(lastWeekHref).toContain('start_at');
|
||||||
|
expect(lastMonthHref).toContain('start_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should search custom date range', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Verify custom date range form exists
|
||||||
|
const startInput = page.getByLabel('Start at');
|
||||||
|
const endInput = page.getByLabel('End at');
|
||||||
|
const searchButton = page.getByRole('button', { name: 'Search' });
|
||||||
|
|
||||||
|
await expect(startInput).toBeVisible();
|
||||||
|
await expect(endInput).toBeVisible();
|
||||||
|
await expect(searchButton).toBeVisible();
|
||||||
|
|
||||||
|
// Test that we can interact with the form
|
||||||
|
await startInput.fill('2024-01-01T00:00');
|
||||||
|
await endInput.fill('2024-01-02T23:59');
|
||||||
|
|
||||||
|
// Verify form inputs work
|
||||||
|
await expect(startInput).toHaveValue('2024-01-01T00:00');
|
||||||
|
await expect(endInput).toHaveValue('2024-01-02T23:59');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Map Layers and Controls', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.waitForMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display layer control', async ({ page }) => {
|
||||||
|
// Look for layer control (Leaflet control)
|
||||||
|
const layerControl = page.locator('.leaflet-control-layers');
|
||||||
|
await expect(layerControl).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should toggle layer control', async ({ page }) => {
|
||||||
|
const layerControl = page.locator('.leaflet-control-layers');
|
||||||
|
|
||||||
|
if (await layerControl.isVisible()) {
|
||||||
|
// Click to expand if collapsed
|
||||||
|
await layerControl.click();
|
||||||
|
|
||||||
|
// Should show layer options
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
// Layer control should be expanded (check for typical layer control elements)
|
||||||
|
const expanded = await page.locator('.leaflet-control-layers-expanded').isVisible();
|
||||||
|
if (!expanded) {
|
||||||
|
// Try clicking on the control toggle
|
||||||
|
const toggle = layerControl.locator('.leaflet-control-layers-toggle');
|
||||||
|
if (await toggle.isVisible()) {
|
||||||
|
await toggle.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should switch between base layers', async ({ page }) => {
|
||||||
|
// This test depends on having multiple base layers available
|
||||||
|
// We'll check if base layer options exist and try to switch
|
||||||
|
|
||||||
|
const layerControl = page.locator('.leaflet-control-layers');
|
||||||
|
await layerControl.click();
|
||||||
|
|
||||||
|
// Look for base layer radio buttons (OpenStreetMap, OpenTopo, etc.)
|
||||||
|
const baseLayerRadios = page.locator('input[type="radio"][name="leaflet-base-layers"]');
|
||||||
|
const radioCount = await baseLayerRadios.count();
|
||||||
|
|
||||||
|
if (radioCount > 1) {
|
||||||
|
// Switch to different base layer
|
||||||
|
await baseLayerRadios.nth(1).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify the layer switched (tiles should reload)
|
||||||
|
await expect(page.locator('.leaflet-tile-loaded')).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should toggle overlay layers', async ({ page }) => {
|
||||||
|
const layerControl = page.locator('.leaflet-control-layers');
|
||||||
|
await layerControl.click();
|
||||||
|
|
||||||
|
// Wait for the layer control to expand
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Look for overlay checkboxes (Points, Routes, Heatmap, etc.)
|
||||||
|
const overlayCheckboxes = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]');
|
||||||
|
const checkboxCount = await overlayCheckboxes.count();
|
||||||
|
|
||||||
|
if (checkboxCount > 0) {
|
||||||
|
// Toggle first overlay - check if it's visible first
|
||||||
|
const firstCheckbox = overlayCheckboxes.first();
|
||||||
|
|
||||||
|
// Wait for checkbox to be visible, especially on mobile
|
||||||
|
await expect(firstCheckbox).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
const wasChecked = await firstCheckbox.isChecked();
|
||||||
|
|
||||||
|
// If on mobile, the checkbox might be hidden behind other elements
|
||||||
|
// Use JavaScript click as fallback
|
||||||
|
try {
|
||||||
|
await firstCheckbox.click({ force: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to JavaScript click if element is not interactable
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const checkbox = document.querySelector('.leaflet-control-layers-overlays input[type="checkbox"]') as HTMLInputElement;
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify state changed
|
||||||
|
const isNowChecked = await firstCheckbox.isChecked();
|
||||||
|
expect(isNowChecked).toBe(!wasChecked);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Map Data Display', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.waitForMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display distance and points statistics', async ({ page }) => {
|
||||||
|
// Check for distance and points statistics - they appear as "0 km | 1 points"
|
||||||
|
const statsDisplay = page.getByText(/\d+\s*km.*\d+\s*points/i);
|
||||||
|
await expect(statsDisplay.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display map attribution', async ({ page }) => {
|
||||||
|
// Check for Leaflet attribution
|
||||||
|
const attribution = page.locator('.leaflet-control-attribution');
|
||||||
|
await expect(attribution).toBeVisible();
|
||||||
|
|
||||||
|
// Should contain some attribution text
|
||||||
|
const attributionText = await attribution.textContent();
|
||||||
|
expect(attributionText).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display map scale control', async ({ page }) => {
|
||||||
|
// Check for scale control
|
||||||
|
const scaleControl = page.locator('.leaflet-control-scale');
|
||||||
|
await expect(scaleControl).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should zoom in and out', async ({ page }) => {
|
||||||
|
// Find zoom controls
|
||||||
|
const zoomIn = page.locator('.leaflet-control-zoom-in');
|
||||||
|
const zoomOut = page.locator('.leaflet-control-zoom-out');
|
||||||
|
|
||||||
|
await expect(zoomIn).toBeVisible();
|
||||||
|
await expect(zoomOut).toBeVisible();
|
||||||
|
|
||||||
|
// Test zoom in
|
||||||
|
await zoomIn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Test zoom out
|
||||||
|
await zoomOut.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Map should still be visible and functional
|
||||||
|
await expect(page.locator('#map')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle map dragging', async ({ page }) => {
|
||||||
|
// Get map container
|
||||||
|
const mapContainer = page.locator('#map .leaflet-container');
|
||||||
|
await expect(mapContainer).toBeVisible();
|
||||||
|
|
||||||
|
// Get initial map center (if available)
|
||||||
|
const initialBounds = await page.evaluate(() => {
|
||||||
|
const mapElement = document.querySelector('#map');
|
||||||
|
if (mapElement && (mapElement as any)._leaflet_id) {
|
||||||
|
const map = (window as any).L.map((mapElement as any)._leaflet_id);
|
||||||
|
return map.getBounds();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate drag
|
||||||
|
await mapContainer.hover();
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(100, 100);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Map should still be functional
|
||||||
|
await expect(mapContainer).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Points Interaction', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.waitForMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should click on points to show details', async ({ page }) => {
|
||||||
|
// Look for point markers on the map
|
||||||
|
const pointMarkers = page.locator('.leaflet-marker-icon, .leaflet-interactive[fill]');
|
||||||
|
const markerCount = await pointMarkers.count();
|
||||||
|
|
||||||
|
if (markerCount > 0) {
|
||||||
|
// Click on first point
|
||||||
|
await pointMarkers.first().click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Should show popup with point details
|
||||||
|
const popup = page.locator('.leaflet-popup, .popup');
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
|
||||||
|
// Popup should contain some data
|
||||||
|
const popupContent = await popup.textContent();
|
||||||
|
expect(popupContent).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show point deletion option in popup', async ({ page }) => {
|
||||||
|
// This test assumes there are points to click on
|
||||||
|
const pointMarkers = page.locator('.leaflet-marker-icon, .leaflet-interactive[fill]');
|
||||||
|
const markerCount = await pointMarkers.count();
|
||||||
|
|
||||||
|
if (markerCount > 0) {
|
||||||
|
await pointMarkers.first().click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Look for delete option in popup
|
||||||
|
const deleteLink = page.getByRole('link', { name: /delete/i });
|
||||||
|
if (await deleteLink.isVisible()) {
|
||||||
|
await expect(deleteLink).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Mobile Map Experience', () => {
|
||||||
|
test('should work on mobile viewport', async ({ page }) => {
|
||||||
|
// Set mobile viewport
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
// Map should be visible and functional on mobile
|
||||||
|
await expect(page.locator('#map')).toBeVisible();
|
||||||
|
|
||||||
|
// Time controls should be responsive
|
||||||
|
await expect(page.getByLabel('Start at')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('End at')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mobile touch interactions', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map');
|
||||||
|
|
||||||
|
// Simulate touch interactions using click (more compatible than tap)
|
||||||
|
await mapContainer.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Map should remain functional
|
||||||
|
await expect(mapContainer).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display mobile-optimized controls', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Check that controls stack properly on mobile
|
||||||
|
const timeControls = page.locator('.flex').filter({ hasText: /Start at|End at/ });
|
||||||
|
await expect(timeControls.first()).toBeVisible();
|
||||||
|
|
||||||
|
// Quick action buttons should be visible
|
||||||
|
await expect(page.getByRole('link', { name: 'Today' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Map Performance', () => {
|
||||||
|
test('should load map within reasonable time', async ({ page }) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
const loadTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Check if we're on mobile and adjust timeout accordingly
|
||||||
|
const isMobile = await helpers.isMobileViewport();
|
||||||
|
const maxLoadTime = isMobile ? 25000 : 15000; // 25s for mobile, 15s for desktop
|
||||||
|
|
||||||
|
expect(loadTime).toBeLessThan(maxLoadTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle large datasets efficiently', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Set a longer date range that might have more data
|
||||||
|
await page.getByLabel('Start at').fill('2024-01-01T00:00');
|
||||||
|
await page.getByLabel('End at').fill('2024-12-31T23:59');
|
||||||
|
await page.getByRole('button', { name: 'Search' }).click();
|
||||||
|
|
||||||
|
// Should load without timing out
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
// Map should still be interactive
|
||||||
|
const zoomIn = page.locator('.leaflet-control-zoom-in');
|
||||||
|
await zoomIn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
472
e2e/navigation.spec.ts
Normal file
472
e2e/navigation.spec.ts
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { TestHelpers } from './fixtures/test-helpers';
|
||||||
|
|
||||||
|
test.describe('Navigation', () => {
|
||||||
|
let helpers: TestHelpers;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
helpers = new TestHelpers(page);
|
||||||
|
await helpers.loginAsDemo();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Main Navigation', () => {
|
||||||
|
test('should display main navigation elements', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Check for main navigation items - note Trips has α symbol, Settings is in user dropdown
|
||||||
|
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /Trips/ })).toBeVisible(); // Match with α symbol
|
||||||
|
await expect(page.getByRole('link', { name: 'Stats' })).toBeVisible();
|
||||||
|
|
||||||
|
// Settings is in user dropdown, not main nav - check user dropdown instead
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||||
|
await expect(userDropdown).toBeVisible();
|
||||||
|
|
||||||
|
// Check for "My data" dropdown - select the visible one (not hidden mobile version)
|
||||||
|
await expect(page.getByText('My data').and(page.locator(':visible'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to Map section', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/map/);
|
||||||
|
// No h1 heading on map page - check for map interface instead
|
||||||
|
await expect(page.locator('#map')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to Trips section', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/trips/);
|
||||||
|
// No h1 heading on trips page - check for trips interface instead (visible elements only)
|
||||||
|
await expect(page.getByText(/trip|distance|duration/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to Stats section', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Stats');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/stats/);
|
||||||
|
// No h1 heading on stats page - check for stats interface instead (visible elements only)
|
||||||
|
await expect(page.getByText(/total.*distance|points.*tracked/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to Settings section', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Settings');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/settings/);
|
||||||
|
// No h1 heading on settings page - check for settings interface instead
|
||||||
|
await expect(page.getByText(/integration|map.*configuration/i).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('My Data Dropdown', () => {
|
||||||
|
test('should expand My data dropdown', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Click on "My data" dropdown - select the visible one (not hidden mobile version)
|
||||||
|
await page.getByText('My data').and(page.locator(':visible')).click();
|
||||||
|
|
||||||
|
// Should show dropdown items
|
||||||
|
await expect(page.getByRole('link', { name: 'Points' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Visits' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Imports' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Exports' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to Points', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Points');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/points/);
|
||||||
|
// No h1 heading on points page - check for points interface instead (visible elements only)
|
||||||
|
await expect(page.getByText(/point|location|coordinate/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to Visits', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Visits');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/visits/);
|
||||||
|
// No h1 heading on visits page - check for visits interface instead (visible elements only)
|
||||||
|
await expect(page.getByText(/visit|place|duration/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to Imports', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Imports');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/imports/);
|
||||||
|
// No h1 heading on imports page - check for imports interface instead (visible elements only)
|
||||||
|
await expect(page.getByText(/import|file|source/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to Exports', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Exports');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/exports/);
|
||||||
|
// No h1 heading on exports page - check for exports interface instead (visible elements only)
|
||||||
|
await expect(page.getByText(/export|download|format/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('User Navigation', () => {
|
||||||
|
test('should display user menu', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Click on user dropdown using the details/summary structure
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
|
||||||
|
// Should show user menu items
|
||||||
|
await expect(page.getByRole('link', { name: 'Account' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Logout' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to Account settings', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await page.getByRole('link', { name: 'Account' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/users\/edit/);
|
||||||
|
await expect(page.getByLabel('Email')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show logout functionality', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
const userDropdown = page.locator('details').filter({ hasText: /@/ }).first();
|
||||||
|
await userDropdown.locator('summary').click();
|
||||||
|
await page.getByRole('link', { name: 'Logout' }).click();
|
||||||
|
|
||||||
|
// Should redirect to home/login
|
||||||
|
await expect(page).toHaveURL('/');
|
||||||
|
await expect(page.getByRole('link', { name: 'Sign in' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Breadcrumb Navigation', () => {
|
||||||
|
test('should show breadcrumbs on detail pages', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
// Look for trip links
|
||||||
|
const tripLinks = page.getByRole('link').filter({ hasText: /trip|km|miles/i });
|
||||||
|
const linkCount = await tripLinks.count();
|
||||||
|
|
||||||
|
if (linkCount > 0) {
|
||||||
|
// Click on first trip
|
||||||
|
await tripLinks.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Should show breadcrumb navigation
|
||||||
|
const breadcrumbs = page.locator('.breadcrumb, .breadcrumbs, nav').filter({ hasText: /trip/i });
|
||||||
|
if (await breadcrumbs.isVisible()) {
|
||||||
|
await expect(breadcrumbs).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate back using breadcrumbs', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Imports');
|
||||||
|
|
||||||
|
// Look for import detail links
|
||||||
|
const importLinks = page.getByRole('link').filter({ hasText: /\.json|\.gpx|\.rec/i });
|
||||||
|
const linkCount = await importLinks.count();
|
||||||
|
|
||||||
|
if (linkCount > 0) {
|
||||||
|
await importLinks.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Look for back navigation
|
||||||
|
const backLink = page.getByRole('link', { name: /back|imports/i });
|
||||||
|
if (await backLink.isVisible()) {
|
||||||
|
await backLink.click();
|
||||||
|
await expect(page).toHaveURL(/\/imports/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('URL Navigation', () => {
|
||||||
|
test('should handle direct URL navigation', async ({ page }) => {
|
||||||
|
// Navigate directly to different sections - no h1 headings on pages
|
||||||
|
await page.goto('/map');
|
||||||
|
await expect(page.locator('#map')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/trips');
|
||||||
|
await expect(page.getByText(/trip|distance|duration/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/stats');
|
||||||
|
await expect(page.getByText(/total.*distance|points.*tracked/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/settings');
|
||||||
|
await expect(page.getByText(/integration|map.*configuration/i).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle browser back/forward navigation', async ({ page }) => {
|
||||||
|
// Navigate to different pages
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
await helpers.navigateTo('Stats');
|
||||||
|
|
||||||
|
// Use browser back
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL(/\/trips/);
|
||||||
|
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL(/\/map/);
|
||||||
|
|
||||||
|
// Use browser forward
|
||||||
|
await page.goForward();
|
||||||
|
await expect(page).toHaveURL(/\/trips/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle URL parameters', async ({ page }) => {
|
||||||
|
// Navigate to map with date parameters
|
||||||
|
await page.goto('/map?start_at=2024-01-01T00:00&end_at=2024-01-02T23:59');
|
||||||
|
|
||||||
|
// Should preserve URL parameters
|
||||||
|
await expect(page).toHaveURL(/start_at=2024-01-01/);
|
||||||
|
await expect(page).toHaveURL(/end_at=2024-01-02/);
|
||||||
|
|
||||||
|
// Form should be populated with URL parameters - use display labels
|
||||||
|
await expect(page.getByLabel('Start at')).toHaveValue(/2024-01-01/);
|
||||||
|
await expect(page.getByLabel('End at')).toHaveValue(/2024-01-02/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Mobile Navigation', () => {
|
||||||
|
test('should show mobile navigation menu', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Look for mobile menu button (hamburger)
|
||||||
|
const mobileMenuButton = page.locator('button').filter({ hasText: /menu|☰|≡/ }).first();
|
||||||
|
|
||||||
|
if (await mobileMenuButton.isVisible()) {
|
||||||
|
await mobileMenuButton.click();
|
||||||
|
|
||||||
|
// Should show mobile navigation
|
||||||
|
await expect(page.getByRole('link', { name: 'Map' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Trips' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Stats' })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mobile navigation interactions', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Open mobile navigation
|
||||||
|
await helpers.openMobileNavigation();
|
||||||
|
|
||||||
|
// Navigate to different section
|
||||||
|
await page.getByRole('link', { name: 'Stats' }).click();
|
||||||
|
|
||||||
|
// Should navigate successfully - no h1 heading on stats page
|
||||||
|
await expect(page).toHaveURL(/\/stats/);
|
||||||
|
await expect(page.getByText(/total.*distance|points.*tracked/i).and(page.locator(':visible')).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mobile dropdown menus', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Open mobile navigation
|
||||||
|
await helpers.openMobileNavigation();
|
||||||
|
|
||||||
|
// Look for "My data" in mobile menu - select the visible one
|
||||||
|
const myDataMobile = page.getByText('My data').and(page.locator(':visible'));
|
||||||
|
if (await myDataMobile.isVisible()) {
|
||||||
|
await myDataMobile.click();
|
||||||
|
|
||||||
|
// Should show mobile dropdown
|
||||||
|
await expect(page.getByRole('link', { name: 'Points' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Imports' })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Active Navigation State', () => {
|
||||||
|
test('should highlight active navigation item', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Map should be active - use exact match to avoid attribution links
|
||||||
|
const mapLink = page.getByRole('link', { name: 'Map', exact: true });
|
||||||
|
await expect(mapLink).toHaveClass(/active|current/);
|
||||||
|
|
||||||
|
// Navigate to different section
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
// Trips should now be active
|
||||||
|
const tripsLink = page.getByRole('link', { name: 'Trips' });
|
||||||
|
await expect(tripsLink).toHaveClass(/active|current/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update active state on URL change', async ({ page }) => {
|
||||||
|
// Navigate directly via URL
|
||||||
|
await page.goto('/stats');
|
||||||
|
|
||||||
|
// Stats should be active - use exact match to avoid "Update stats" button
|
||||||
|
const statsLink = page.getByRole('link', { name: 'Stats', exact: true });
|
||||||
|
await expect(statsLink).toHaveClass(/active|current/);
|
||||||
|
|
||||||
|
// Navigate via URL again
|
||||||
|
await page.goto('/settings');
|
||||||
|
|
||||||
|
// Settings link is in user dropdown, not main nav - check URL instead
|
||||||
|
await expect(page).toHaveURL(/\/settings/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Navigation Performance', () => {
|
||||||
|
test('should navigate between sections quickly', async ({ page }) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Navigate through multiple sections (Settings uses different navigation)
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
await helpers.navigateTo('Stats');
|
||||||
|
await helpers.navigateTo('Points'); // Navigate to Points instead of Settings
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const totalTime = endTime - startTime;
|
||||||
|
|
||||||
|
// Should complete navigation within reasonable time
|
||||||
|
expect(totalTime).toBeLessThan(10000); // 10 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle rapid navigation clicks', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Rapidly click different navigation items (Settings is not in main nav)
|
||||||
|
await page.getByRole('link', { name: /Trips/ }).click(); // Match with α symbol
|
||||||
|
await page.getByRole('link', { name: 'Stats' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Map', exact: true }).click();
|
||||||
|
|
||||||
|
// Should end up on the last clicked item
|
||||||
|
await expect(page).toHaveURL(/\/map/);
|
||||||
|
await expect(page.locator('#map')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Error Handling', () => {
|
||||||
|
test('should handle non-existent routes', async ({ page }) => {
|
||||||
|
// Navigate to a non-existent route
|
||||||
|
await page.goto('/non-existent-page');
|
||||||
|
|
||||||
|
// Should show 404 or redirect to valid page
|
||||||
|
const currentUrl = page.url();
|
||||||
|
|
||||||
|
// Either shows 404 page or redirects to valid page
|
||||||
|
if (currentUrl.includes('non-existent-page')) {
|
||||||
|
// Should show 404 page
|
||||||
|
await expect(page.getByText(/404|not found/i)).toBeVisible();
|
||||||
|
} else {
|
||||||
|
// Should redirect to valid page
|
||||||
|
expect(currentUrl).toMatch(/\/(map|home|$)/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle network errors gracefully', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Mock network error for navigation
|
||||||
|
await page.route('**/trips', route => route.abort());
|
||||||
|
|
||||||
|
// Try to navigate
|
||||||
|
await page.getByRole('link', { name: 'Trips' }).click();
|
||||||
|
|
||||||
|
// Should handle gracefully (stay on current page or show error)
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Should not crash - page should still be functional - use exact match
|
||||||
|
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Keyboard Navigation', () => {
|
||||||
|
test('should support keyboard navigation', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Press Tab to navigate to links
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
// Should focus on navigation elements
|
||||||
|
const focusedElement = page.locator(':focus');
|
||||||
|
await expect(focusedElement).toBeVisible();
|
||||||
|
|
||||||
|
// Should be able to navigate with keyboard
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Should navigate to focused element - use exact match to avoid attribution links
|
||||||
|
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle keyboard shortcuts', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Test common keyboard shortcuts if they exist
|
||||||
|
// This depends on the application implementing keyboard shortcuts
|
||||||
|
|
||||||
|
// For example, if there's a keyboard shortcut for settings
|
||||||
|
await page.keyboard.press('Alt+S');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// May or may not navigate (depends on implementation)
|
||||||
|
const currentUrl = page.url();
|
||||||
|
|
||||||
|
// Just verify the page is still functional - use exact match
|
||||||
|
await expect(page.getByRole('link', { name: 'Map', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Accessibility', () => {
|
||||||
|
test('should have proper ARIA labels', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// Check for main navigation landmark
|
||||||
|
const mainNav = page.locator('nav[role="navigation"]').or(page.locator('nav'));
|
||||||
|
await expect(mainNav.first()).toBeVisible();
|
||||||
|
|
||||||
|
// Check for accessible navigation items
|
||||||
|
const navItems = page.getByRole('link');
|
||||||
|
const navCount = await navItems.count();
|
||||||
|
|
||||||
|
expect(navCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Navigation items should have proper text content
|
||||||
|
for (let i = 0; i < Math.min(navCount, 5); i++) {
|
||||||
|
const navItem = navItems.nth(i);
|
||||||
|
const text = await navItem.textContent();
|
||||||
|
expect(text).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support screen reader navigation', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Map');
|
||||||
|
|
||||||
|
// No h1 headings exist - check for navigation landmark instead
|
||||||
|
const nav = page.locator('nav').first();
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
|
||||||
|
// Check for proper link labels
|
||||||
|
const links = page.getByRole('link');
|
||||||
|
const linkCount = await links.count();
|
||||||
|
|
||||||
|
// Most links should have text content (skip icon-only links)
|
||||||
|
let linksWithText = 0;
|
||||||
|
for (let i = 0; i < Math.min(linkCount, 10); i++) {
|
||||||
|
const link = links.nth(i);
|
||||||
|
const text = await link.textContent();
|
||||||
|
if (text?.trim()) {
|
||||||
|
linksWithText++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// At least half of the links should have text content
|
||||||
|
expect(linksWithText).toBeGreaterThan(Math.min(linkCount, 10) / 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
418
e2e/trips.spec.ts
Normal file
418
e2e/trips.spec.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { TestHelpers } from './fixtures/test-helpers';
|
||||||
|
|
||||||
|
test.describe('Trips', () => {
|
||||||
|
let helpers: TestHelpers;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
helpers = new TestHelpers(page);
|
||||||
|
await helpers.loginAsDemo();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Trips List', () => {
|
||||||
|
test('should display trips page correctly', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
// Check page title and elements
|
||||||
|
await expect(page).toHaveTitle(/Trips.*Dawarich/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Trips' })).toBeVisible();
|
||||||
|
|
||||||
|
// Should show "New trip" button
|
||||||
|
await expect(page.getByRole('link', { name: 'New trip' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show trips list or empty state', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
// Check for either trips grid or empty state
|
||||||
|
const tripsGrid = page.locator('.grid');
|
||||||
|
const emptyState = page.getByText('Hello there!');
|
||||||
|
|
||||||
|
if (await tripsGrid.isVisible()) {
|
||||||
|
await expect(tripsGrid).toBeVisible();
|
||||||
|
} else {
|
||||||
|
// Should show empty state with create link
|
||||||
|
await expect(emptyState).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'create one' })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display trip statistics if trips exist', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
// Look for trip cards
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
// Should show distance info in first trip card
|
||||||
|
const firstCard = tripCards.first();
|
||||||
|
await expect(firstCard.getByText(/\d+\s*(km|miles)/)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to new trip page', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
// Click "New trip" button
|
||||||
|
await page.getByRole('link', { name: 'New trip' }).click();
|
||||||
|
|
||||||
|
// Should navigate to new trip page
|
||||||
|
await expect(page).toHaveURL(/\/trips\/new/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'New trip' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Trip Creation', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
await page.getByRole('link', { name: 'New trip' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show trip creation form', async ({ page }) => {
|
||||||
|
// Should have form fields
|
||||||
|
await expect(page.getByLabel('Name')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Started at')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Ended at')).toBeVisible();
|
||||||
|
|
||||||
|
// Should have submit button
|
||||||
|
await expect(page.getByRole('button', { name: 'Create trip' })).toBeVisible();
|
||||||
|
|
||||||
|
// Should have map container
|
||||||
|
await expect(page.locator('#map')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create trip with valid data', async ({ page }) => {
|
||||||
|
// Fill form fields
|
||||||
|
await page.getByLabel('Name').fill('Test Trip');
|
||||||
|
await page.getByLabel('Started at').fill('2024-01-01T10:00');
|
||||||
|
await page.getByLabel('Ended at').fill('2024-01-01T18:00');
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await page.getByRole('button', { name: 'Create trip' }).click();
|
||||||
|
|
||||||
|
// Should redirect to trip show page
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page).toHaveURL(/\/trips\/\d+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate required fields', async ({ page }) => {
|
||||||
|
// Try to submit empty form
|
||||||
|
await page.getByRole('button', { name: 'Create trip' }).click();
|
||||||
|
|
||||||
|
// Should show validation errors
|
||||||
|
await expect(page.getByText(/can't be blank|is required/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate date range', async ({ page }) => {
|
||||||
|
// Fill with invalid date range (end before start)
|
||||||
|
await page.getByLabel('Name').fill('Invalid Trip');
|
||||||
|
await page.getByLabel('Started at').fill('2024-01-02T10:00');
|
||||||
|
await page.getByLabel('Ended at').fill('2024-01-01T18:00');
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await page.getByRole('button', { name: 'Create trip' }).click();
|
||||||
|
|
||||||
|
// Should show validation error (if backend validates this)
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
// Note: This test assumes backend validation exists
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Trip Details', () => {
|
||||||
|
test('should display trip details when clicked', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
// Look for trip cards
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
// Click on first trip card
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Should show trip name as heading
|
||||||
|
await expect(page.locator('h1, h2, h3').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Should show distance info
|
||||||
|
const distanceText = page.getByText(/\d+\s*(km|miles)/);
|
||||||
|
if (await distanceText.count() > 0) {
|
||||||
|
await expect(distanceText.first()).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show trip map', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Should show map container
|
||||||
|
const mapContainer = page.locator('#map');
|
||||||
|
if (await mapContainer.isVisible()) {
|
||||||
|
await expect(mapContainer).toBeVisible();
|
||||||
|
await helpers.waitForMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show trip timeline info', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Should show date/time information
|
||||||
|
const dateInfo = page.getByText(/\d{1,2}\s+(January|February|March|April|May|June|July|August|September|October|November|December)/);
|
||||||
|
if (await dateInfo.count() > 0) {
|
||||||
|
await expect(dateInfo.first()).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow trip editing', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Look for edit link/button
|
||||||
|
const editLink = page.getByRole('link', { name: /edit/i });
|
||||||
|
if (await editLink.isVisible()) {
|
||||||
|
await editLink.click();
|
||||||
|
|
||||||
|
// Should show edit form
|
||||||
|
await expect(page.getByLabel('Name')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Started at')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Ended at')).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Trip Visualization', () => {
|
||||||
|
test('should show trip on map', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Check if map is present
|
||||||
|
const mapContainer = page.locator('#map');
|
||||||
|
if (await mapContainer.isVisible()) {
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
// Should have map controls
|
||||||
|
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Zoom out' })).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display trip route', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map');
|
||||||
|
if (await mapContainer.isVisible()) {
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
// Look for route polylines
|
||||||
|
const routeElements = page.locator('.leaflet-interactive[stroke]');
|
||||||
|
if (await routeElements.count() > 0) {
|
||||||
|
await expect(routeElements.first()).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show trip points', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map');
|
||||||
|
if (await mapContainer.isVisible()) {
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
// Look for point markers
|
||||||
|
const pointMarkers = page.locator('.leaflet-marker-icon');
|
||||||
|
if (await pointMarkers.count() > 0) {
|
||||||
|
await expect(pointMarkers.first()).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow map interaction', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map');
|
||||||
|
if (await mapContainer.isVisible()) {
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
// Test zoom controls
|
||||||
|
const zoomIn = page.getByRole('button', { name: 'Zoom in' });
|
||||||
|
const zoomOut = page.getByRole('button', { name: 'Zoom out' });
|
||||||
|
|
||||||
|
await zoomIn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await zoomOut.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Map should still be functional
|
||||||
|
await expect(mapContainer).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Trip Management', () => {
|
||||||
|
test('should show trip actions', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Look for edit/delete/export options
|
||||||
|
const editLink = page.getByRole('link', { name: /edit/i });
|
||||||
|
const deleteButton = page.getByRole('button', { name: /delete/i }).or(page.getByRole('link', { name: /delete/i }));
|
||||||
|
|
||||||
|
// At least edit should be available
|
||||||
|
if (await editLink.isVisible()) {
|
||||||
|
await expect(editLink).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Mobile Trips Experience', () => {
|
||||||
|
test('should work on mobile viewport', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
// Page should load correctly on mobile
|
||||||
|
await expect(page.getByRole('heading', { name: 'Trips' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'New trip' })).toBeVisible();
|
||||||
|
|
||||||
|
// Grid should adapt to mobile
|
||||||
|
const tripsGrid = page.locator('.grid');
|
||||||
|
if (await tripsGrid.isVisible()) {
|
||||||
|
await expect(tripsGrid).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mobile trip details', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Should show trip info on mobile
|
||||||
|
await expect(page.locator('h1, h2, h3').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Map should be responsive if present
|
||||||
|
const mapContainer = page.locator('#map');
|
||||||
|
if (await mapContainer.isVisible()) {
|
||||||
|
await expect(mapContainer).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mobile map interactions', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const tripCards = page.locator('.card[data-trip-id]');
|
||||||
|
const cardCount = await tripCards.count();
|
||||||
|
|
||||||
|
if (cardCount > 0) {
|
||||||
|
await tripCards.first().click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const mapContainer = page.locator('#map');
|
||||||
|
if (await mapContainer.isVisible()) {
|
||||||
|
await helpers.waitForMap();
|
||||||
|
|
||||||
|
// Test touch interaction
|
||||||
|
await mapContainer.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Map should remain functional
|
||||||
|
await expect(mapContainer).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Trip Performance', () => {
|
||||||
|
test('should load trips page within reasonable time', async ({ page }) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
const loadTime = Date.now() - startTime;
|
||||||
|
const maxLoadTime = await helpers.isMobileViewport() ? 15000 : 10000;
|
||||||
|
|
||||||
|
expect(loadTime).toBeLessThan(maxLoadTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle large numbers of trips', async ({ page }) => {
|
||||||
|
await helpers.navigateTo('Trips');
|
||||||
|
|
||||||
|
// Page should load without timing out
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 30000 });
|
||||||
|
|
||||||
|
// Should show either trips or empty state
|
||||||
|
const tripsGrid = page.locator('.grid');
|
||||||
|
const emptyState = page.getByText('Hello there!');
|
||||||
|
|
||||||
|
expect(await tripsGrid.isVisible() || await emptyState.isVisible()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
69
playwright.config.ts
Normal file
69
playwright.config.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
|
/* Take screenshot on failure */
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
|
||||||
|
/* Record video on failure */
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
|
||||||
|
/* Set timeout for actions */
|
||||||
|
actionTimeout: 10000,
|
||||||
|
|
||||||
|
/* Set timeout for page navigation */
|
||||||
|
navigationTimeout: 30000,
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Global setup for checking server availability */
|
||||||
|
globalSetup: require.resolve('./e2e/global-setup.ts'),
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
{
|
||||||
|
name: 'Mobile Chrome',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mobile Safari',
|
||||||
|
use: { ...devices['iPhone 12'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
27
playwright.yml
Normal file
27
playwright.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: Playwright Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: npx playwright test
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Tracks::BulkCreatingJob, type: :job do
|
|
||||||
describe '#perform' do
|
|
||||||
let(:service) { instance_double(Tracks::BulkTrackCreator) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(Tracks::BulkTrackCreator).to receive(:new).with(start_at: 'foo', end_at: 'bar', user_ids: [1, 2]).and_return(service)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'calls Tracks::BulkTrackCreator with the correct arguments' do
|
|
||||||
expect(service).to receive(:call)
|
|
||||||
|
|
||||||
described_class.new.perform(start_at: 'foo', end_at: 'bar', user_ids: [1, 2])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
88
spec/jobs/tracks/cleanup_job_spec.rb
Normal file
88
spec/jobs/tracks/cleanup_job_spec.rb
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Tracks::CleanupJob, type: :job do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'with old untracked points' do
|
||||||
|
let!(:old_points) do
|
||||||
|
create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 2.days.ago.to_i)
|
||||||
|
create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 1.day.ago.to_i)
|
||||||
|
end
|
||||||
|
let!(:recent_points) do
|
||||||
|
create_points_around(user: user, count: 2, base_lat: 20.0, timestamp: 1.hour.ago.to_i)
|
||||||
|
end
|
||||||
|
let(:generator) { instance_double(Tracks::Generator) }
|
||||||
|
|
||||||
|
it 'processes only old untracked points' do
|
||||||
|
expect(Tracks::Generator).to receive(:new)
|
||||||
|
.and_return(generator)
|
||||||
|
|
||||||
|
expect(generator).to receive(:call)
|
||||||
|
|
||||||
|
described_class.new.perform(older_than: 1.day.ago)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs processing information' do
|
||||||
|
allow(Tracks::Generator).to receive(:new).and_return(double(call: nil))
|
||||||
|
|
||||||
|
expect(Rails.logger).to receive(:info).with(/Processing missed tracks for user #{user.id}/)
|
||||||
|
|
||||||
|
described_class.new.perform(older_than: 1.day.ago)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with users having insufficient points' do
|
||||||
|
let!(:single_point) do
|
||||||
|
create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 2.days.ago.to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips users with less than 2 points' do
|
||||||
|
expect(Tracks::Generator).not_to receive(:new)
|
||||||
|
|
||||||
|
described_class.new.perform(older_than: 1.day.ago)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with no old untracked points' do
|
||||||
|
let(:track) { create(:track, user: user) }
|
||||||
|
let!(:tracked_points) do
|
||||||
|
create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 2.days.ago.to_i, track: track)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not process any users' do
|
||||||
|
expect(Tracks::Generator).not_to receive(:new)
|
||||||
|
|
||||||
|
described_class.new.perform(older_than: 1.day.ago)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with custom older_than parameter' do
|
||||||
|
let!(:points) do
|
||||||
|
create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 3.days.ago.to_i)
|
||||||
|
end
|
||||||
|
let(:generator) { instance_double(Tracks::Generator) }
|
||||||
|
|
||||||
|
it 'uses custom threshold' do
|
||||||
|
expect(Tracks::Generator).to receive(:new)
|
||||||
|
.and_return(generator)
|
||||||
|
|
||||||
|
expect(generator).to receive(:call)
|
||||||
|
|
||||||
|
described_class.new.perform(older_than: 2.days.ago)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'job configuration' do
|
||||||
|
it 'uses tracks queue' do
|
||||||
|
expect(described_class.queue_name).to eq('tracks')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not retry on failure' do
|
||||||
|
expect(described_class.sidekiq_options_hash['retry']).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -6,26 +6,34 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
||||||
let(:user) { create(:user) }
|
let(:user) { create(:user) }
|
||||||
|
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
let(:service_instance) { instance_double(Tracks::CreateFromPoints) }
|
let(:generator_instance) { instance_double(Tracks::Generator) }
|
||||||
let(:notification_service) { instance_double(Notifications::Create) }
|
let(:notification_service) { instance_double(Notifications::Create) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(Tracks::CreateFromPoints).to receive(:new).with(user, start_at: nil, end_at: nil, cleaning_strategy: :replace).and_return(service_instance)
|
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
|
||||||
allow(service_instance).to receive(:call).and_return(3)
|
allow(generator_instance).to receive(:call)
|
||||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||||
allow(notification_service).to receive(:call)
|
allow(notification_service).to receive(:call)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'calls the service and creates a notification' do
|
it 'calls the generator and creates a notification' do
|
||||||
|
# Mock the generator to return the count of tracks created
|
||||||
|
allow(generator_instance).to receive(:call).and_return(2)
|
||||||
|
|
||||||
described_class.new.perform(user.id)
|
described_class.new.perform(user.id)
|
||||||
|
|
||||||
expect(Tracks::CreateFromPoints).to have_received(:new).with(user, start_at: nil, end_at: nil, cleaning_strategy: :replace)
|
expect(Tracks::Generator).to have_received(:new).with(
|
||||||
expect(service_instance).to have_received(:call)
|
user,
|
||||||
|
start_at: nil,
|
||||||
|
end_at: nil,
|
||||||
|
mode: :daily
|
||||||
|
)
|
||||||
|
expect(generator_instance).to have_received(:call)
|
||||||
expect(Notifications::Create).to have_received(:new).with(
|
expect(Notifications::Create).to have_received(:new).with(
|
||||||
user: user,
|
user: user,
|
||||||
kind: :info,
|
kind: :info,
|
||||||
title: 'Tracks Generated',
|
title: 'Tracks Generated',
|
||||||
content: 'Created 3 tracks from your location data. Check your tracks section to view them.'
|
content: 'Created 2 tracks from your location data. Check your tracks section to view them.'
|
||||||
)
|
)
|
||||||
expect(notification_service).to have_received(:call)
|
expect(notification_service).to have_received(:call)
|
||||||
end
|
end
|
||||||
|
|
@ -33,38 +41,111 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
||||||
context 'with custom parameters' do
|
context 'with custom parameters' do
|
||||||
let(:start_at) { 1.day.ago.beginning_of_day.to_i }
|
let(:start_at) { 1.day.ago.beginning_of_day.to_i }
|
||||||
let(:end_at) { 1.day.ago.end_of_day.to_i }
|
let(:end_at) { 1.day.ago.end_of_day.to_i }
|
||||||
let(:cleaning_strategy) { :daily }
|
let(:mode) { :daily }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(Tracks::CreateFromPoints).to receive(:new).with(user, start_at: start_at, end_at: end_at, cleaning_strategy: cleaning_strategy).and_return(service_instance)
|
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
|
||||||
allow(service_instance).to receive(:call).and_return(2)
|
allow(generator_instance).to receive(:call)
|
||||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||||
allow(notification_service).to receive(:call)
|
allow(notification_service).to receive(:call)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'passes custom parameters to the service' do
|
it 'passes custom parameters to the generator' do
|
||||||
described_class.new.perform(user.id, start_at: start_at, end_at: end_at, cleaning_strategy: cleaning_strategy)
|
# Mock generator to return the count of tracks created
|
||||||
|
allow(generator_instance).to receive(:call).and_return(1)
|
||||||
|
|
||||||
expect(Tracks::CreateFromPoints).to have_received(:new).with(user, start_at: start_at, end_at: end_at, cleaning_strategy: cleaning_strategy)
|
described_class.new.perform(user.id, start_at: start_at, end_at: end_at, mode: mode)
|
||||||
expect(service_instance).to have_received(:call)
|
|
||||||
|
expect(Tracks::Generator).to have_received(:new).with(
|
||||||
|
user,
|
||||||
|
start_at: start_at,
|
||||||
|
end_at: end_at,
|
||||||
|
mode: :daily
|
||||||
|
)
|
||||||
|
expect(generator_instance).to have_received(:call)
|
||||||
expect(Notifications::Create).to have_received(:new).with(
|
expect(Notifications::Create).to have_received(:new).with(
|
||||||
user: user,
|
user: user,
|
||||||
kind: :info,
|
kind: :info,
|
||||||
title: 'Tracks Generated',
|
title: 'Tracks Generated',
|
||||||
content: 'Created 2 tracks from your location data. Check your tracks section to view them.'
|
content: 'Created 1 tracks from your location data. Check your tracks section to view them.'
|
||||||
)
|
)
|
||||||
expect(notification_service).to have_received(:call)
|
expect(notification_service).to have_received(:call)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when service raises an error' do
|
context 'with mode translation' do
|
||||||
|
before do
|
||||||
|
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
|
||||||
|
allow(generator_instance).to receive(:call) # No tracks created for mode tests
|
||||||
|
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||||
|
allow(notification_service).to receive(:call)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'translates :none to :incremental' do
|
||||||
|
allow(generator_instance).to receive(:call).and_return(0)
|
||||||
|
|
||||||
|
described_class.new.perform(user.id, mode: :none)
|
||||||
|
|
||||||
|
expect(Tracks::Generator).to have_received(:new).with(
|
||||||
|
user,
|
||||||
|
start_at: nil,
|
||||||
|
end_at: nil,
|
||||||
|
mode: :incremental
|
||||||
|
)
|
||||||
|
expect(Notifications::Create).to have_received(:new).with(
|
||||||
|
user: user,
|
||||||
|
kind: :info,
|
||||||
|
title: 'Tracks Generated',
|
||||||
|
content: 'Created 0 tracks from your location data. Check your tracks section to view them.'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'translates :daily to :daily' do
|
||||||
|
allow(generator_instance).to receive(:call).and_return(0)
|
||||||
|
|
||||||
|
described_class.new.perform(user.id, mode: :daily)
|
||||||
|
|
||||||
|
expect(Tracks::Generator).to have_received(:new).with(
|
||||||
|
user,
|
||||||
|
start_at: nil,
|
||||||
|
end_at: nil,
|
||||||
|
mode: :daily
|
||||||
|
)
|
||||||
|
expect(Notifications::Create).to have_received(:new).with(
|
||||||
|
user: user,
|
||||||
|
kind: :info,
|
||||||
|
title: 'Tracks Generated',
|
||||||
|
content: 'Created 0 tracks from your location data. Check your tracks section to view them.'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'translates other modes to :bulk' do
|
||||||
|
allow(generator_instance).to receive(:call).and_return(0)
|
||||||
|
|
||||||
|
described_class.new.perform(user.id, mode: :replace)
|
||||||
|
|
||||||
|
expect(Tracks::Generator).to have_received(:new).with(
|
||||||
|
user,
|
||||||
|
start_at: nil,
|
||||||
|
end_at: nil,
|
||||||
|
mode: :bulk
|
||||||
|
)
|
||||||
|
expect(Notifications::Create).to have_received(:new).with(
|
||||||
|
user: user,
|
||||||
|
kind: :info,
|
||||||
|
title: 'Tracks Generated',
|
||||||
|
content: 'Created 0 tracks from your location data. Check your tracks section to view them.'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when generator raises an error' do
|
||||||
let(:error_message) { 'Something went wrong' }
|
let(:error_message) { 'Something went wrong' }
|
||||||
let(:service_instance) { instance_double(Tracks::CreateFromPoints) }
|
|
||||||
let(:notification_service) { instance_double(Notifications::Create) }
|
let(:notification_service) { instance_double(Notifications::Create) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(Tracks::CreateFromPoints).to receive(:new).with(user, start_at: nil, end_at: nil, cleaning_strategy: :replace).and_return(service_instance)
|
allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
|
||||||
allow(service_instance).to receive(:call).and_raise(StandardError, error_message)
|
allow(generator_instance).to receive(:call).and_raise(StandardError, error_message)
|
||||||
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
allow(Notifications::Create).to receive(:new).and_return(notification_service)
|
||||||
allow(notification_service).to receive(:call)
|
allow(notification_service).to receive(:call)
|
||||||
end
|
end
|
||||||
|
|
@ -105,11 +186,39 @@ RSpec.describe Tracks::CreateJob, type: :job do
|
||||||
expect(ExceptionReporter).to have_received(:call)
|
expect(ExceptionReporter).to have_received(:call)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when tracks are deleted and recreated' do
|
||||||
|
it 'returns the correct count of newly created tracks' do
|
||||||
|
# Create some existing tracks first
|
||||||
|
create_list(:track, 3, user: user)
|
||||||
|
|
||||||
|
# Mock the generator to simulate deleting existing tracks and creating new ones
|
||||||
|
# This should return the count of newly created tracks, not the difference
|
||||||
|
allow(generator_instance).to receive(:call).and_return(2)
|
||||||
|
|
||||||
|
described_class.new.perform(user.id, mode: :bulk)
|
||||||
|
|
||||||
|
expect(Tracks::Generator).to have_received(:new).with(
|
||||||
|
user,
|
||||||
|
start_at: nil,
|
||||||
|
end_at: nil,
|
||||||
|
mode: :bulk
|
||||||
|
)
|
||||||
|
expect(generator_instance).to have_received(:call)
|
||||||
|
expect(Notifications::Create).to have_received(:new).with(
|
||||||
|
user: user,
|
||||||
|
kind: :info,
|
||||||
|
title: 'Tracks Generated',
|
||||||
|
content: 'Created 2 tracks from your location data. Check your tracks section to view them.'
|
||||||
|
)
|
||||||
|
expect(notification_service).to have_received(:call)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'queue' do
|
describe 'queue' do
|
||||||
it 'is queued on default queue' do
|
it 'is queued on tracks queue' do
|
||||||
expect(described_class.new.queue_name).to eq('default')
|
expect(described_class.new.queue_name).to eq('tracks')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
39
spec/jobs/tracks/incremental_check_job_spec.rb
Normal file
39
spec/jobs/tracks/incremental_check_job_spec.rb
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Tracks::IncrementalCheckJob, type: :job do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:point) { create(:point, user: user) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'with valid parameters' do
|
||||||
|
let(:processor) { instance_double(Tracks::IncrementalProcessor) }
|
||||||
|
|
||||||
|
it 'calls the incremental processor' do
|
||||||
|
expect(Tracks::IncrementalProcessor).to receive(:new)
|
||||||
|
.with(user, point)
|
||||||
|
.and_return(processor)
|
||||||
|
|
||||||
|
expect(processor).to receive(:call)
|
||||||
|
|
||||||
|
described_class.new.perform(user.id, point.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'job configuration' do
|
||||||
|
it 'uses tracks queue' do
|
||||||
|
expect(described_class.queue_name).to eq('tracks')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'integration with ActiveJob' do
|
||||||
|
it 'enqueues the job' do
|
||||||
|
expect do
|
||||||
|
described_class.perform_later(user.id, point.id)
|
||||||
|
end.to have_enqueued_job(described_class)
|
||||||
|
.with(user.id, point.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -127,8 +127,8 @@ RSpec.describe Point, type: :model do
|
||||||
end
|
end
|
||||||
let(:track) { create(:track) }
|
let(:track) { create(:track) }
|
||||||
|
|
||||||
it 'enqueues Tracks::IncrementalGeneratorJob' do
|
it 'enqueues Tracks::IncrementalCheckJob' do
|
||||||
expect { point.send(:trigger_incremental_track_generation) }.to have_enqueued_job(Tracks::IncrementalGeneratorJob).with(point.user_id, point.recorded_at.to_date.to_s, 5)
|
expect { point.send(:trigger_incremental_track_generation) }.to have_enqueued_job(Tracks::IncrementalCheckJob).with(point.user_id, point.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ RSpec.describe PointsLimitExceeded do
|
||||||
|
|
||||||
context 'when user points count is equal to the limit' do
|
context 'when user points count is equal to the limit' do
|
||||||
before do
|
before do
|
||||||
allow(user.points).to receive(:count).and_return(10)
|
allow(user.tracked_points).to receive(:count).and_return(10)
|
||||||
end
|
end
|
||||||
|
|
||||||
it { is_expected.to be true }
|
it { is_expected.to be true }
|
||||||
|
|
@ -32,7 +32,7 @@ RSpec.describe PointsLimitExceeded do
|
||||||
|
|
||||||
context 'when user points count exceeds the limit' do
|
context 'when user points count exceeds the limit' do
|
||||||
before do
|
before do
|
||||||
allow(user.points).to receive(:count).and_return(11)
|
allow(user.tracked_points).to receive(:count).and_return(11)
|
||||||
end
|
end
|
||||||
|
|
||||||
it { is_expected.to be true }
|
it { is_expected.to be true }
|
||||||
|
|
@ -40,7 +40,7 @@ RSpec.describe PointsLimitExceeded do
|
||||||
|
|
||||||
context 'when user points count is below the limit' do
|
context 'when user points count is below the limit' do
|
||||||
before do
|
before do
|
||||||
allow(user.points).to receive(:count).and_return(9)
|
allow(user.tracked_points).to receive(:count).and_return(9)
|
||||||
end
|
end
|
||||||
|
|
||||||
it { is_expected.to be false }
|
it { is_expected.to be false }
|
||||||
|
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Tracks::BulkTrackCreator do
|
|
||||||
describe '#call' do
|
|
||||||
let!(:active_user) { create(:user) }
|
|
||||||
let!(:inactive_user) { create(:user, :inactive) }
|
|
||||||
let!(:user_without_points) { create(:user) }
|
|
||||||
|
|
||||||
let(:start_at) { 1.day.ago.beginning_of_day }
|
|
||||||
let(:end_at) { 1.day.ago.end_of_day }
|
|
||||||
|
|
||||||
before do
|
|
||||||
# Create points for active user in the target timeframe
|
|
||||||
create(:point, user: active_user, timestamp: start_at.to_i + 1.hour.to_i)
|
|
||||||
create(:point, user: active_user, timestamp: start_at.to_i + 2.hours.to_i)
|
|
||||||
|
|
||||||
# Create points for inactive user in the target timeframe
|
|
||||||
create(:point, user: inactive_user, timestamp: start_at.to_i + 1.hour.to_i)
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when explicit start_at is provided' do
|
|
||||||
it 'schedules tracks creation jobs for active users with points in the timeframe' do
|
|
||||||
expect {
|
|
||||||
described_class.new(start_at:, end_at:).call
|
|
||||||
}.to have_enqueued_job(Tracks::CreateJob).with(active_user.id, start_at:, end_at:, cleaning_strategy: :daily)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not schedule jobs for users without tracked points' do
|
|
||||||
expect {
|
|
||||||
described_class.new(start_at:, end_at:).call
|
|
||||||
}.not_to have_enqueued_job(Tracks::CreateJob).with(user_without_points.id, start_at:, end_at:, cleaning_strategy: :daily)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not schedule jobs for users without points in the specified timeframe' do
|
|
||||||
# Create a user with points outside the timeframe
|
|
||||||
user_with_old_points = create(:user)
|
|
||||||
create(:point, user: user_with_old_points, timestamp: 2.days.ago.to_i)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.new(start_at:, end_at:).call
|
|
||||||
}.not_to have_enqueued_job(Tracks::CreateJob).with(user_with_old_points.id, start_at:, end_at:, cleaning_strategy: :daily)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when specific user_ids are provided' do
|
|
||||||
it 'only processes the specified users' do
|
|
||||||
expect {
|
|
||||||
described_class.new(start_at:, end_at:, user_ids: [active_user.id]).call
|
|
||||||
}.to have_enqueued_job(Tracks::CreateJob).with(active_user.id, start_at:, end_at:, cleaning_strategy: :daily)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not process users not in the user_ids list' do
|
|
||||||
expect {
|
|
||||||
described_class.new(start_at:, end_at:, user_ids: [active_user.id]).call
|
|
||||||
}.not_to have_enqueued_job(Tracks::CreateJob).with(inactive_user.id, start_at:, end_at:, cleaning_strategy: :daily)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with automatic start time determination' do
|
|
||||||
let(:user_with_tracks) { create(:user) }
|
|
||||||
let(:user_without_tracks) { create(:user) }
|
|
||||||
let(:current_time) { Time.current }
|
|
||||||
|
|
||||||
before do
|
|
||||||
# Create some historical points and tracks for user_with_tracks
|
|
||||||
create(:point, user: user_with_tracks, timestamp: 3.days.ago.to_i)
|
|
||||||
create(:point, user: user_with_tracks, timestamp: 2.days.ago.to_i)
|
|
||||||
|
|
||||||
# Create a track ending 1 day ago
|
|
||||||
create(:track, user: user_with_tracks, end_at: 1.day.ago)
|
|
||||||
|
|
||||||
# Create newer points after the last track
|
|
||||||
create(:point, user: user_with_tracks, timestamp: 12.hours.ago.to_i)
|
|
||||||
create(:point, user: user_with_tracks, timestamp: 6.hours.ago.to_i)
|
|
||||||
|
|
||||||
# Create points for user without tracks
|
|
||||||
create(:point, user: user_without_tracks, timestamp: 2.days.ago.to_i)
|
|
||||||
create(:point, user: user_without_tracks, timestamp: 1.day.ago.to_i)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'starts from the end of the last track for users with existing tracks' do
|
|
||||||
track_end_time = user_with_tracks.tracks.order(end_at: :desc).first.end_at
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.new(end_at: current_time, user_ids: [user_with_tracks.id]).call
|
|
||||||
}.to have_enqueued_job(Tracks::CreateJob).with(
|
|
||||||
user_with_tracks.id,
|
|
||||||
start_at: track_end_time,
|
|
||||||
end_at: current_time.to_datetime,
|
|
||||||
cleaning_strategy: :daily
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'starts from the oldest point for users without tracks' do
|
|
||||||
oldest_point_time = Time.zone.at(user_without_tracks.tracked_points.order(:timestamp).first.timestamp)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.new(end_at: current_time, user_ids: [user_without_tracks.id]).call
|
|
||||||
}.to have_enqueued_job(Tracks::CreateJob).with(
|
|
||||||
user_without_tracks.id,
|
|
||||||
start_at: oldest_point_time,
|
|
||||||
end_at: current_time.to_datetime,
|
|
||||||
cleaning_strategy: :daily
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'falls back to 1 day ago for users with no points' do
|
|
||||||
expect {
|
|
||||||
described_class.new(end_at: current_time, user_ids: [user_without_points.id]).call
|
|
||||||
}.not_to have_enqueued_job(Tracks::CreateJob).with(
|
|
||||||
user_without_points.id,
|
|
||||||
start_at: anything,
|
|
||||||
end_at: anything,
|
|
||||||
cleaning_strategy: :daily
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with default parameters' do
|
|
||||||
let(:user_with_recent_points) { create(:user) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
# Create points within yesterday's timeframe
|
|
||||||
create(:point, user: user_with_recent_points, timestamp: 1.day.ago.beginning_of_day.to_i + 2.hours.to_i)
|
|
||||||
create(:point, user: user_with_recent_points, timestamp: 1.day.ago.beginning_of_day.to_i + 6.hours.to_i)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'uses automatic start time determination with yesterday as end_at' do
|
|
||||||
oldest_point_time = Time.zone.at(user_with_recent_points.tracked_points.order(:timestamp).first.timestamp)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.new(user_ids: [user_with_recent_points.id]).call
|
|
||||||
}.to have_enqueued_job(Tracks::CreateJob).with(
|
|
||||||
user_with_recent_points.id,
|
|
||||||
start_at: oldest_point_time,
|
|
||||||
end_at: 1.day.ago.end_of_day.to_datetime,
|
|
||||||
cleaning_strategy: :daily
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#start_time' do
|
|
||||||
let(:user) { create(:user) }
|
|
||||||
let(:service) { described_class.new }
|
|
||||||
|
|
||||||
context 'when user has tracks' do
|
|
||||||
let!(:old_track) { create(:track, user: user, end_at: 3.days.ago) }
|
|
||||||
let!(:recent_track) { create(:track, user: user, end_at: 1.day.ago) }
|
|
||||||
|
|
||||||
it 'returns the end time of the most recent track' do
|
|
||||||
result = service.send(:start_time, user)
|
|
||||||
expect(result).to eq(recent_track.end_at)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when user has no tracks but has points' do
|
|
||||||
let!(:old_point) { create(:point, user: user, timestamp: 5.days.ago.to_i) }
|
|
||||||
let!(:recent_point) { create(:point, user: user, timestamp: 2.days.ago.to_i) }
|
|
||||||
|
|
||||||
it 'returns the timestamp of the oldest point' do
|
|
||||||
result = service.send(:start_time, user)
|
|
||||||
expect(result).to eq(Time.zone.at(old_point.timestamp))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when user has no tracks and no points' do
|
|
||||||
it 'returns 1 day ago beginning of day' do
|
|
||||||
result = service.send(:start_time, user)
|
|
||||||
expect(result).to eq(1.day.ago.beginning_of_day)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Tracks::Cleaners::DailyCleaner do
|
|
||||||
let(:user) { create(:user) }
|
|
||||||
let(:start_at) { 1.day.ago.beginning_of_day }
|
|
||||||
let(:end_at) { 1.day.ago.end_of_day }
|
|
||||||
let(:cleaner) { described_class.new(user, start_at: start_at.to_i, end_at: end_at.to_i) }
|
|
||||||
|
|
||||||
describe '#cleanup' do
|
|
||||||
context 'when there are no overlapping tracks' do
|
|
||||||
before do
|
|
||||||
# Create a track that ends before our window
|
|
||||||
track = create(:track, user: user, start_at: 2.days.ago, end_at: 2.days.ago + 1.hour)
|
|
||||||
create(:point, user: user, track: track, timestamp: 2.days.ago.to_i)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not remove any tracks' do
|
|
||||||
expect { cleaner.cleanup }.not_to change { user.tracks.count }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a track is completely within the time window' do
|
|
||||||
let!(:track) { create(:track, user: user, start_at: start_at + 1.hour, end_at: end_at - 1.hour) }
|
|
||||||
let!(:point1) { create(:point, user: user, track: track, timestamp: (start_at + 1.hour).to_i) }
|
|
||||||
let!(:point2) { create(:point, user: user, track: track, timestamp: (start_at + 2.hours).to_i) }
|
|
||||||
|
|
||||||
it 'removes all points from the track and deletes it' do
|
|
||||||
expect { cleaner.cleanup }.to change { user.tracks.count }.by(-1)
|
|
||||||
expect(point1.reload.track_id).to be_nil
|
|
||||||
expect(point2.reload.track_id).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a track spans across the time window' do
|
|
||||||
let!(:track) { create(:track, user: user, start_at: start_at - 1.hour, end_at: end_at + 1.hour) }
|
|
||||||
let!(:point_before) { create(:point, user: user, track: track, timestamp: (start_at - 30.minutes).to_i) }
|
|
||||||
let!(:point_during1) { create(:point, user: user, track: track, timestamp: (start_at + 1.hour).to_i) }
|
|
||||||
let!(:point_during2) { create(:point, user: user, track: track, timestamp: (start_at + 2.hours).to_i) }
|
|
||||||
let!(:point_after) { create(:point, user: user, track: track, timestamp: (end_at + 30.minutes).to_i) }
|
|
||||||
|
|
||||||
it 'removes only points within the window and updates track boundaries' do
|
|
||||||
expect { cleaner.cleanup }.not_to change { user.tracks.count }
|
|
||||||
|
|
||||||
# Points outside window should remain attached
|
|
||||||
expect(point_before.reload.track_id).to eq(track.id)
|
|
||||||
expect(point_after.reload.track_id).to eq(track.id)
|
|
||||||
|
|
||||||
# Points inside window should be detached
|
|
||||||
expect(point_during1.reload.track_id).to be_nil
|
|
||||||
expect(point_during2.reload.track_id).to be_nil
|
|
||||||
|
|
||||||
# Track boundaries should be updated
|
|
||||||
track.reload
|
|
||||||
expect(track.start_at).to be_within(1.second).of(Time.zone.at(point_before.timestamp))
|
|
||||||
expect(track.end_at).to be_within(1.second).of(Time.zone.at(point_after.timestamp))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a track overlaps but has insufficient remaining points' do
|
|
||||||
let!(:track) { create(:track, user: user, start_at: start_at - 1.hour, end_at: end_at + 1.hour) }
|
|
||||||
let!(:point_before) { create(:point, user: user, track: track, timestamp: (start_at - 30.minutes).to_i) }
|
|
||||||
let!(:point_during) { create(:point, user: user, track: track, timestamp: (start_at + 1.hour).to_i) }
|
|
||||||
|
|
||||||
it 'removes the track entirely and orphans remaining points' do
|
|
||||||
expect { cleaner.cleanup }.to change { user.tracks.count }.by(-1)
|
|
||||||
|
|
||||||
expect(point_before.reload.track_id).to be_nil
|
|
||||||
expect(point_during.reload.track_id).to be_nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when track has no points in the time window' do
|
|
||||||
let!(:track) { create(:track, user: user, start_at: start_at - 2.hours, end_at: end_at + 2.hours) }
|
|
||||||
let!(:point_before) { create(:point, user: user, track: track, timestamp: (start_at - 30.minutes).to_i) }
|
|
||||||
let!(:point_after) { create(:point, user: user, track: track, timestamp: (end_at + 30.minutes).to_i) }
|
|
||||||
|
|
||||||
it 'does not modify the track' do
|
|
||||||
expect { cleaner.cleanup }.not_to change { user.tracks.count }
|
|
||||||
expect(track.reload.start_at).to be_within(1.second).of(track.start_at)
|
|
||||||
expect(track.reload.end_at).to be_within(1.second).of(track.end_at)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'without start_at and end_at' do
|
|
||||||
let(:cleaner) { described_class.new(user) }
|
|
||||||
|
|
||||||
it 'does not perform any cleanup' do
|
|
||||||
create(:track, user: user)
|
|
||||||
expect { cleaner.cleanup }.not_to change { user.tracks.count }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,357 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Tracks::CreateFromPoints do
|
|
||||||
let(:user) { create(:user) }
|
|
||||||
let(:service) { described_class.new(user) }
|
|
||||||
|
|
||||||
describe '#initialize' do
|
|
||||||
it 'sets user and thresholds from user settings' do
|
|
||||||
expect(service.user).to eq(user)
|
|
||||||
expect(service.distance_threshold_meters).to eq(user.safe_settings.meters_between_routes.to_i)
|
|
||||||
expect(service.time_threshold_minutes).to eq(user.safe_settings.minutes_between_routes.to_i)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'defaults to replace cleaning strategy' do
|
|
||||||
expect(service.cleaning_strategy).to eq(:replace)
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with custom user settings' do
|
|
||||||
before do
|
|
||||||
user.update!(settings: user.settings.merge({
|
|
||||||
'meters_between_routes' => 1000,
|
|
||||||
'minutes_between_routes' => 60
|
|
||||||
}))
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'uses custom settings' do
|
|
||||||
service = described_class.new(user)
|
|
||||||
expect(service.distance_threshold_meters).to eq(1000)
|
|
||||||
expect(service.time_threshold_minutes).to eq(60)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with custom cleaning strategy' do
|
|
||||||
it 'accepts daily cleaning strategy' do
|
|
||||||
service = described_class.new(user, cleaning_strategy: :daily)
|
|
||||||
expect(service.cleaning_strategy).to eq(:daily)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'accepts none cleaning strategy' do
|
|
||||||
service = described_class.new(user, cleaning_strategy: :none)
|
|
||||||
expect(service.cleaning_strategy).to eq(:none)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'accepts custom date range with cleaning strategy' do
|
|
||||||
start_time = 1.day.ago.beginning_of_day.to_i
|
|
||||||
end_time = 1.day.ago.end_of_day.to_i
|
|
||||||
service = described_class.new(user, start_at: start_time, end_at: end_time, cleaning_strategy: :daily)
|
|
||||||
|
|
||||||
expect(service.start_at).to eq(start_time)
|
|
||||||
expect(service.end_at).to eq(end_time)
|
|
||||||
expect(service.cleaning_strategy).to eq(:daily)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#call' do
|
|
||||||
context 'with no points' do
|
|
||||||
it 'returns 0 tracks created' do
|
|
||||||
expect(service.call).to eq(0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with insufficient points' do
|
|
||||||
let!(:single_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
|
|
||||||
|
|
||||||
it 'returns 0 tracks created' do
|
|
||||||
expect(service.call).to eq(0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with points that form a single track' do
|
|
||||||
let(:base_time) { 1.hour.ago }
|
|
||||||
let!(:points) do
|
|
||||||
[
|
|
||||||
create(:point, user: user, timestamp: base_time.to_i,
|
|
||||||
lonlat: 'POINT(-74.0060 40.7128)', altitude: 10),
|
|
||||||
create(:point, user: user, timestamp: (base_time + 5.minutes).to_i,
|
|
||||||
lonlat: 'POINT(-74.0070 40.7130)', altitude: 15),
|
|
||||||
create(:point, user: user, timestamp: (base_time + 10.minutes).to_i,
|
|
||||||
lonlat: 'POINT(-74.0080 40.7132)', altitude: 20)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates one track' do
|
|
||||||
expect { service.call }.to change(Track, :count).by(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 1 track created' do
|
|
||||||
expect(service.call).to eq(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'sets track attributes correctly' do
|
|
||||||
service.call
|
|
||||||
track = Track.last
|
|
||||||
|
|
||||||
expect(track.user).to eq(user)
|
|
||||||
expect(track.start_at).to be_within(1.second).of(base_time)
|
|
||||||
expect(track.end_at).to be_within(1.second).of(base_time + 10.minutes)
|
|
||||||
expect(track.duration).to eq(600) # 10 minutes in seconds
|
|
||||||
expect(track.original_path).to be_present
|
|
||||||
expect(track.distance).to be > 0
|
|
||||||
expect(track.avg_speed).to be > 0
|
|
||||||
expect(track.elevation_gain).to eq(10) # 20 - 10
|
|
||||||
expect(track.elevation_loss).to eq(0)
|
|
||||||
expect(track.elevation_max).to eq(20)
|
|
||||||
expect(track.elevation_min).to eq(10)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'associates points with the track' do
|
|
||||||
service.call
|
|
||||||
track = Track.last
|
|
||||||
expect(points.map(&:reload).map(&:track)).to all(eq(track))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with points that should be split by time' do
|
|
||||||
let(:base_time) { 2.hours.ago }
|
|
||||||
let!(:points) do
|
|
||||||
[
|
|
||||||
# First track
|
|
||||||
create(:point, user: user, timestamp: base_time.to_i,
|
|
||||||
lonlat: 'POINT(-74.0060 40.7128)'),
|
|
||||||
create(:point, user: user, timestamp: (base_time + 5.minutes).to_i,
|
|
||||||
lonlat: 'POINT(-74.0070 40.7130)'),
|
|
||||||
|
|
||||||
# Gap > time threshold (default 30 minutes)
|
|
||||||
create(:point, user: user, timestamp: (base_time + 45.minutes).to_i,
|
|
||||||
lonlat: 'POINT(-74.0080 40.7132)'),
|
|
||||||
create(:point, user: user, timestamp: (base_time + 50.minutes).to_i,
|
|
||||||
lonlat: 'POINT(-74.0090 40.7134)')
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates two tracks' do
|
|
||||||
expect { service.call }.to change(Track, :count).by(2)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 2 tracks created' do
|
|
||||||
expect(service.call).to eq(2)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with points that should be split by distance' do
|
|
||||||
let(:base_time) { 1.hour.ago }
|
|
||||||
let!(:points) do
|
|
||||||
[
|
|
||||||
# First track - close points
|
|
||||||
create(:point, user: user, timestamp: base_time.to_i,
|
|
||||||
lonlat: 'POINT(-74.0060 40.7128)'),
|
|
||||||
create(:point, user: user, timestamp: (base_time + 1.minute).to_i,
|
|
||||||
lonlat: 'POINT(-74.0061 40.7129)'),
|
|
||||||
|
|
||||||
# Far point (> distance threshold, but within time threshold)
|
|
||||||
create(:point, user: user, timestamp: (base_time + 2.minutes).to_i,
|
|
||||||
lonlat: 'POINT(-74.0500 40.7500)'), # ~5km away
|
|
||||||
create(:point, user: user, timestamp: (base_time + 3.minutes).to_i,
|
|
||||||
lonlat: 'POINT(-74.0501 40.7501)')
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates two tracks' do
|
|
||||||
expect { service.call }.to change(Track, :count).by(2)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with existing tracks' do
|
|
||||||
let!(:existing_track) { create(:track, user: user) }
|
|
||||||
let!(:points) do
|
|
||||||
[
|
|
||||||
create(:point, user: user, timestamp: 1.hour.ago.to_i,
|
|
||||||
lonlat: 'POINT(-74.0060 40.7128)'),
|
|
||||||
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
|
|
||||||
lonlat: 'POINT(-74.0070 40.7130)')
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'destroys existing tracks and creates new ones' do
|
|
||||||
expect { service.call }.to change(Track, :count).by(0) # -1 + 1
|
|
||||||
expect(Track.exists?(existing_track.id)).to be false
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with none cleaning strategy' do
|
|
||||||
let(:service) { described_class.new(user, cleaning_strategy: :none) }
|
|
||||||
|
|
||||||
it 'preserves existing tracks and creates new ones' do
|
|
||||||
expect { service.call }.to change(Track, :count).by(1) # +1, existing preserved
|
|
||||||
expect(Track.exists?(existing_track.id)).to be true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with different cleaning strategies' do
|
|
||||||
let!(:points) do
|
|
||||||
[
|
|
||||||
create(:point, user: user, timestamp: 1.hour.ago.to_i,
|
|
||||||
lonlat: 'POINT(-74.0060 40.7128)'),
|
|
||||||
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
|
|
||||||
lonlat: 'POINT(-74.0070 40.7130)')
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'works with replace strategy (default)' do
|
|
||||||
service = described_class.new(user, cleaning_strategy: :replace)
|
|
||||||
expect { service.call }.to change(Track, :count).by(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'works with daily strategy' do
|
|
||||||
# Create points within the daily range we're testing
|
|
||||||
start_time = 1.day.ago.beginning_of_day.to_i
|
|
||||||
end_time = 1.day.ago.end_of_day.to_i
|
|
||||||
|
|
||||||
# Create test points within the daily range
|
|
||||||
create(:point, user: user, timestamp: start_time + 1.hour.to_i,
|
|
||||||
lonlat: 'POINT(-74.0060 40.7128)')
|
|
||||||
create(:point, user: user, timestamp: start_time + 2.hours.to_i,
|
|
||||||
lonlat: 'POINT(-74.0070 40.7130)')
|
|
||||||
|
|
||||||
# Create an existing track that overlaps with our time window
|
|
||||||
existing_track = create(:track, user: user,
|
|
||||||
start_at: Time.zone.at(start_time - 1.hour),
|
|
||||||
end_at: Time.zone.at(start_time + 30.minutes))
|
|
||||||
|
|
||||||
service = described_class.new(user, start_at: start_time, end_at: end_time, cleaning_strategy: :daily)
|
|
||||||
|
|
||||||
# Daily cleaning should handle existing tracks properly and create new ones
|
|
||||||
expect { service.call }.to change(Track, :count).by(0) # existing cleaned and new created
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'works with none strategy' do
|
|
||||||
service = described_class.new(user, cleaning_strategy: :none)
|
|
||||||
expect { service.call }.to change(Track, :count).by(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with mixed elevation data' do
|
|
||||||
let!(:points) do
|
|
||||||
[
|
|
||||||
create(:point, user: user, timestamp: 1.hour.ago.to_i,
|
|
||||||
lonlat: 'POINT(-74.0060 40.7128)', altitude: 100),
|
|
||||||
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
|
|
||||||
lonlat: 'POINT(-74.0070 40.7130)', altitude: 150),
|
|
||||||
create(:point, user: user, timestamp: 40.minutes.ago.to_i,
|
|
||||||
lonlat: 'POINT(-74.0080 40.7132)', altitude: 120)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'calculates elevation correctly' do
|
|
||||||
service.call
|
|
||||||
track = Track.last
|
|
||||||
|
|
||||||
expect(track.elevation_gain).to eq(50) # 150 - 100
|
|
||||||
expect(track.elevation_loss).to eq(30) # 150 - 120
|
|
||||||
expect(track.elevation_max).to eq(150)
|
|
||||||
expect(track.elevation_min).to eq(100)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with points missing altitude data' do
|
|
||||||
let!(:points) do
|
|
||||||
[
|
|
||||||
create(:point, user: user, timestamp: 1.hour.ago.to_i,
|
|
||||||
lonlat: 'POINT(-74.0060 40.7128)', altitude: nil),
|
|
||||||
create(:point, user: user, timestamp: 50.minutes.ago.to_i,
|
|
||||||
lonlat: 'POINT(-74.0070 40.7130)', altitude: nil)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'uses default elevation values' do
|
|
||||||
service.call
|
|
||||||
track = Track.last
|
|
||||||
|
|
||||||
expect(track.elevation_gain).to eq(0)
|
|
||||||
expect(track.elevation_loss).to eq(0)
|
|
||||||
expect(track.elevation_max).to eq(0)
|
|
||||||
expect(track.elevation_min).to eq(0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'private methods' do
|
|
||||||
describe '#should_start_new_track?' do
|
|
||||||
let(:point1) { build(:point, timestamp: 1.hour.ago.to_i, lonlat: 'POINT(-74.0060 40.7128)') }
|
|
||||||
let(:point2) { build(:point, timestamp: 50.minutes.ago.to_i, lonlat: 'POINT(-74.0070 40.7130)') }
|
|
||||||
|
|
||||||
it 'returns false when previous point is nil' do
|
|
||||||
result = service.send(:should_start_new_track?, point1, nil)
|
|
||||||
expect(result).to be false
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns true when time threshold is exceeded' do
|
|
||||||
# Create a point > 30 minutes later (default threshold)
|
|
||||||
later_point = build(:point, timestamp: 29.minutes.ago.to_i, lonlat: 'POINT(-74.0070 40.7130)')
|
|
||||||
|
|
||||||
result = service.send(:should_start_new_track?, later_point, point1)
|
|
||||||
expect(result).to be true
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns true when distance threshold is exceeded' do
|
|
||||||
# Create a point far away (> 500m default threshold)
|
|
||||||
far_point = build(:point, timestamp: 59.minutes.ago.to_i, lonlat: 'POINT(-74.0500 40.7500)')
|
|
||||||
|
|
||||||
result = service.send(:should_start_new_track?, far_point, point1)
|
|
||||||
expect(result).to be true
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns false when both thresholds are not exceeded' do
|
|
||||||
result = service.send(:should_start_new_track?, point2, point1)
|
|
||||||
expect(result).to be false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#calculate_distance_kilometers' do
|
|
||||||
let(:point1) { build(:point, lonlat: 'POINT(-74.0060 40.7128)') }
|
|
||||||
let(:point2) { build(:point, lonlat: 'POINT(-74.0070 40.7130)') }
|
|
||||||
|
|
||||||
it 'calculates distance between two points in kilometers' do
|
|
||||||
distance = service.send(:calculate_distance_kilometers, point1, point2)
|
|
||||||
expect(distance).to be > 0
|
|
||||||
expect(distance).to be < 0.2 # Should be small distance for close points (in km)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#calculate_average_speed' do
|
|
||||||
it 'calculates speed correctly' do
|
|
||||||
# 1000 meters in 100 seconds = 10 m/s = 36 km/h
|
|
||||||
speed = service.send(:calculate_average_speed, 1000, 100)
|
|
||||||
expect(speed).to eq(36.0)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 0 for zero duration' do
|
|
||||||
speed = service.send(:calculate_average_speed, 1000, 0)
|
|
||||||
expect(speed).to eq(0.0)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 0 for zero distance' do
|
|
||||||
speed = service.send(:calculate_average_speed, 0, 100)
|
|
||||||
expect(speed).to eq(0.0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#calculate_track_distance' do
|
|
||||||
let(:points) do
|
|
||||||
[
|
|
||||||
build(:point, lonlat: 'POINT(-74.0060 40.7128)'),
|
|
||||||
build(:point, lonlat: 'POINT(-74.0070 40.7130)')
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'stores distance in meters by default' do
|
|
||||||
distance = service.send(:calculate_track_distance, points)
|
|
||||||
expect(distance).to eq(87)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -4,253 +4,256 @@ require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Tracks::Generator do
|
RSpec.describe Tracks::Generator do
|
||||||
let(:user) { create(:user) }
|
let(:user) { create(:user) }
|
||||||
let(:point_loader) { double('PointLoader') }
|
let(:safe_settings) { user.safe_settings }
|
||||||
let(:incomplete_segment_handler) { double('IncompleteSegmentHandler') }
|
|
||||||
let(:track_cleaner) { double('Cleaner') }
|
|
||||||
|
|
||||||
let(:generator) do
|
|
||||||
described_class.new(
|
|
||||||
user,
|
|
||||||
point_loader: point_loader,
|
|
||||||
incomplete_segment_handler: incomplete_segment_handler,
|
|
||||||
track_cleaner: track_cleaner
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow_any_instance_of(Users::SafeSettings).to receive(:meters_between_routes).and_return(500)
|
allow(user).to receive(:safe_settings).and_return(safe_settings)
|
||||||
allow_any_instance_of(Users::SafeSettings).to receive(:minutes_between_routes).and_return(60)
|
|
||||||
allow_any_instance_of(Users::SafeSettings).to receive(:distance_unit).and_return('km')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#call' do
|
describe '#call' do
|
||||||
context 'with no points to process' do
|
context 'with bulk mode' do
|
||||||
before do
|
let(:generator) { described_class.new(user, mode: :bulk) }
|
||||||
allow(track_cleaner).to receive(:cleanup)
|
|
||||||
allow(point_loader).to receive(:load_points).and_return([])
|
context 'with sufficient points' do
|
||||||
|
let!(:points) { create_points_around(user: user, count: 5, base_lat: 20.0) }
|
||||||
|
|
||||||
|
it 'generates tracks from all points' do
|
||||||
|
expect { generator.call }.to change(Track, :count).by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'cleans existing tracks' do
|
||||||
|
existing_track = create(:track, user: user)
|
||||||
|
generator.call
|
||||||
|
expect(Track.exists?(existing_track.id)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'associates points with created tracks' do
|
||||||
|
generator.call
|
||||||
|
expect(points.map(&:reload).map(&:track)).to all(be_present)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'properly handles point associations when cleaning existing tracks' do
|
||||||
|
# Create existing tracks with associated points
|
||||||
|
existing_track = create(:track, user: user)
|
||||||
|
existing_points = create_list(:point, 3, user: user, track: existing_track)
|
||||||
|
|
||||||
|
# Verify points are associated
|
||||||
|
expect(existing_points.map(&:reload).map(&:track_id)).to all(eq(existing_track.id))
|
||||||
|
|
||||||
|
# Run generator which should clean existing tracks and create new ones
|
||||||
|
generator.call
|
||||||
|
|
||||||
|
# Verify the old track is deleted
|
||||||
|
expect(Track.exists?(existing_track.id)).to be false
|
||||||
|
|
||||||
|
# Verify the points are no longer associated with the deleted track
|
||||||
|
expect(existing_points.map(&:reload).map(&:track_id)).to all(be_nil)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 0 tracks created' do
|
context 'with insufficient points' do
|
||||||
result = generator.call
|
let!(:points) { create_points_around(user: user, count: 1, base_lat: 20.0) }
|
||||||
expect(result).to eq(0)
|
|
||||||
|
it 'does not create tracks' do
|
||||||
|
expect { generator.call }.not_to change(Track, :count)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not call incomplete segment handler' do
|
context 'with time range' do
|
||||||
expect(incomplete_segment_handler).not_to receive(:should_finalize_segment?)
|
let!(:old_points) { create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 2.days.ago.to_i) }
|
||||||
expect(incomplete_segment_handler).not_to receive(:handle_incomplete_segment)
|
let!(:new_points) { create_points_around(user: user, count: 3, base_lat: 21.0, timestamp: 1.day.ago.to_i) }
|
||||||
expect(incomplete_segment_handler).not_to receive(:cleanup_processed_data)
|
|
||||||
|
|
||||||
generator.call
|
it 'only processes points within range' do
|
||||||
|
generator = described_class.new(
|
||||||
|
user,
|
||||||
|
start_at: 1.day.ago.beginning_of_day,
|
||||||
|
end_at: 1.day.ago.end_of_day,
|
||||||
|
mode: :bulk
|
||||||
|
)
|
||||||
|
|
||||||
|
generator.call
|
||||||
|
track = Track.last
|
||||||
|
expect(track.points.count).to eq(3)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with points that create tracks' do
|
context 'with incremental mode' do
|
||||||
let!(:points) do
|
let(:generator) { described_class.new(user, mode: :incremental) }
|
||||||
[
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i, latitude: 40.7128, longitude: -74.0060),
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0050 40.7138)', timestamp: 30.minutes.ago.to_i, latitude: 40.7138, longitude: -74.0050),
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0040 40.7148)', timestamp: 10.minutes.ago.to_i, latitude: 40.7148, longitude: -74.0040)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
context 'with untracked points' do
|
||||||
allow(track_cleaner).to receive(:cleanup)
|
let!(:points) { create_points_around(user: user, count: 3, base_lat: 22.0, track_id: nil) }
|
||||||
allow(point_loader).to receive(:load_points).and_return(points)
|
|
||||||
allow(incomplete_segment_handler).to receive(:should_finalize_segment?).and_return(true)
|
it 'processes untracked points' do
|
||||||
allow(incomplete_segment_handler).to receive(:cleanup_processed_data)
|
expect { generator.call }.to change(Track, :count).by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'associates points with created tracks' do
|
||||||
|
generator.call
|
||||||
|
expect(points.map(&:reload).map(&:track)).to all(be_present)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates tracks from segments' do
|
context 'with end_at specified' do
|
||||||
expect { generator.call }.to change { Track.count }.by(1)
|
let!(:early_points) { create_points_around(user: user, count: 2, base_lat: 23.0, timestamp: 2.hours.ago.to_i) }
|
||||||
|
let!(:late_points) { create_points_around(user: user, count: 2, base_lat: 24.0, timestamp: 1.hour.ago.to_i) }
|
||||||
|
|
||||||
|
it 'only processes points up to end_at' do
|
||||||
|
generator = described_class.new(user, end_at: 1.5.hours.ago, mode: :incremental)
|
||||||
|
generator.call
|
||||||
|
|
||||||
|
expect(Track.count).to eq(1)
|
||||||
|
expect(Track.first.points.count).to eq(2)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns the number of tracks created' do
|
context 'without existing tracks' do
|
||||||
result = generator.call
|
let!(:points) { create_points_around(user: user, count: 3, base_lat: 25.0) }
|
||||||
expect(result).to eq(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'calls cleanup on processed data' do
|
it 'does not clean existing tracks' do
|
||||||
expect(incomplete_segment_handler).to receive(:cleanup_processed_data)
|
existing_track = create(:track, user: user)
|
||||||
generator.call
|
generator.call
|
||||||
|
expect(Track.exists?(existing_track.id)).to be true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'assigns points to the created track' do
|
|
||||||
generator.call
|
|
||||||
points.each(&:reload)
|
|
||||||
track_ids = points.map(&:track_id).uniq.compact
|
|
||||||
expect(track_ids.size).to eq(1)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with incomplete segments' do
|
context 'with daily mode' do
|
||||||
let!(:points) do
|
let(:today) { Date.current }
|
||||||
[
|
let(:generator) { described_class.new(user, start_at: today, mode: :daily) }
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 5.minutes.ago.to_i, latitude: 40.7128, longitude: -74.0060),
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0050 40.7138)', timestamp: 4.minutes.ago.to_i, latitude: 40.7138, longitude: -74.0050)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
let!(:today_points) { create_points_around(user: user, count: 3, base_lat: 26.0, timestamp: today.beginning_of_day.to_i) }
|
||||||
allow(track_cleaner).to receive(:cleanup)
|
let!(:yesterday_points) { create_points_around(user: user, count: 3, base_lat: 27.0, timestamp: 1.day.ago.to_i) }
|
||||||
allow(point_loader).to receive(:load_points).and_return(points)
|
|
||||||
allow(incomplete_segment_handler).to receive(:should_finalize_segment?).and_return(false)
|
it 'only processes points from specified day' do
|
||||||
allow(incomplete_segment_handler).to receive(:handle_incomplete_segment)
|
generator.call
|
||||||
allow(incomplete_segment_handler).to receive(:cleanup_processed_data)
|
track = Track.last
|
||||||
|
expect(track.points.count).to eq(3)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'cleans existing tracks for the day' do
|
||||||
|
existing_track = create(:track, user: user, start_at: today.beginning_of_day)
|
||||||
|
generator.call
|
||||||
|
expect(Track.exists?(existing_track.id)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'properly handles point associations when cleaning daily tracks' do
|
||||||
|
# Create existing tracks with associated points for today
|
||||||
|
existing_track = create(:track, user: user, start_at: today.beginning_of_day)
|
||||||
|
existing_points = create_list(:point, 3, user: user, track: existing_track)
|
||||||
|
|
||||||
|
# Verify points are associated
|
||||||
|
expect(existing_points.map(&:reload).map(&:track_id)).to all(eq(existing_track.id))
|
||||||
|
|
||||||
|
# Run generator which should clean existing tracks for the day and create new ones
|
||||||
|
generator.call
|
||||||
|
|
||||||
|
# Verify the old track is deleted
|
||||||
|
expect(Track.exists?(existing_track.id)).to be false
|
||||||
|
|
||||||
|
# Verify the points are no longer associated with the deleted track
|
||||||
|
expect(existing_points.map(&:reload).map(&:track_id)).to all(be_nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty points' do
|
||||||
|
let(:generator) { described_class.new(user, mode: :bulk) }
|
||||||
|
|
||||||
it 'does not create tracks' do
|
it 'does not create tracks' do
|
||||||
expect { generator.call }.not_to change { Track.count }
|
expect { generator.call }.not_to change(Track, :count)
|
||||||
end
|
|
||||||
|
|
||||||
it 'handles incomplete segments' do
|
|
||||||
expect(incomplete_segment_handler).to receive(:handle_incomplete_segment).with(points)
|
|
||||||
generator.call
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 0 tracks created' do
|
|
||||||
result = generator.call
|
|
||||||
expect(result).to eq(0)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with mixed complete and incomplete segments' do
|
context 'with threshold configuration' do
|
||||||
let!(:old_points) do
|
let(:generator) { described_class.new(user, mode: :bulk) }
|
||||||
[
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 2.hours.ago.to_i, latitude: 40.7128, longitude: -74.0060),
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0050 40.7138)', timestamp: 1.hour.ago.to_i, latitude: 40.7138, longitude: -74.0050)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:recent_points) do
|
|
||||||
[
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0040 40.7148)', timestamp: 3.minutes.ago.to_i, latitude: 40.7148, longitude: -74.0040),
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0030 40.7158)', timestamp: 2.minutes.ago.to_i, latitude: 40.7158, longitude: -74.0030)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(track_cleaner).to receive(:cleanup)
|
|
||||||
allow(point_loader).to receive(:load_points).and_return(old_points + recent_points)
|
|
||||||
|
|
||||||
# First segment (old points) should be finalized
|
|
||||||
# Second segment (recent points) should be incomplete
|
|
||||||
call_count = 0
|
|
||||||
allow(incomplete_segment_handler).to receive(:should_finalize_segment?) do |segment_points|
|
|
||||||
call_count += 1
|
|
||||||
call_count == 1 # Only finalize first segment
|
|
||||||
end
|
|
||||||
|
|
||||||
allow(incomplete_segment_handler).to receive(:handle_incomplete_segment)
|
|
||||||
allow(incomplete_segment_handler).to receive(:cleanup_processed_data)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates tracks for complete segments only' do
|
|
||||||
expect { generator.call }.to change { Track.count }.by(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'handles incomplete segments' do
|
|
||||||
# Note: The exact behavior depends on segmentation logic
|
|
||||||
# The important thing is that the method can be called without errors
|
|
||||||
generator.call
|
|
||||||
# Test passes if no exceptions are raised
|
|
||||||
expect(true).to be_truthy
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns the correct number of tracks created' do
|
|
||||||
result = generator.call
|
|
||||||
expect(result).to eq(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with insufficient points for track creation' do
|
|
||||||
let!(:single_point) do
|
|
||||||
[create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i, latitude: 40.7128, longitude: -74.0060)]
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(track_cleaner).to receive(:cleanup)
|
allow(safe_settings).to receive(:meters_between_routes).and_return(1000)
|
||||||
allow(point_loader).to receive(:load_points).and_return(single_point)
|
allow(safe_settings).to receive(:minutes_between_routes).and_return(90)
|
||||||
allow(incomplete_segment_handler).to receive(:should_finalize_segment?).and_return(true)
|
|
||||||
allow(incomplete_segment_handler).to receive(:cleanup_processed_data)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create tracks with less than 2 points' do
|
it 'uses configured thresholds' do
|
||||||
expect { generator.call }.not_to change { Track.count }
|
expect(generator.send(:distance_threshold_meters)).to eq(1000)
|
||||||
end
|
expect(generator.send(:time_threshold_minutes)).to eq(90)
|
||||||
|
|
||||||
it 'returns 0 tracks created' do
|
|
||||||
result = generator.call
|
|
||||||
expect(result).to eq(0)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'error handling' do
|
context 'with invalid mode' do
|
||||||
before do
|
it 'raises argument error' do
|
||||||
allow(track_cleaner).to receive(:cleanup)
|
expect do
|
||||||
allow(point_loader).to receive(:load_points).and_raise(StandardError, 'Point loading failed')
|
described_class.new(user, mode: :invalid).call
|
||||||
end
|
end.to raise_error(ArgumentError, /Unknown mode/)
|
||||||
|
|
||||||
it 'propagates errors from point loading' do
|
|
||||||
expect { generator.call }.to raise_error(StandardError, 'Point loading failed')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'strategy pattern integration' do
|
describe 'segmentation behavior' do
|
||||||
context 'with bulk processing strategies' do
|
let(:generator) { described_class.new(user, mode: :bulk) }
|
||||||
let(:bulk_loader) { Tracks::PointLoaders::BulkLoader.new(user) }
|
|
||||||
let(:ignore_handler) { Tracks::IncompleteSegmentHandlers::IgnoreHandler.new(user) }
|
|
||||||
let(:replace_cleaner) { Tracks::Cleaners::ReplaceCleaner.new(user) }
|
|
||||||
|
|
||||||
let(:bulk_generator) do
|
context 'with points exceeding time threshold' do
|
||||||
described_class.new(
|
let!(:points) do
|
||||||
user,
|
[
|
||||||
point_loader: bulk_loader,
|
create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: 90.minutes.ago.to_i),
|
||||||
incomplete_segment_handler: ignore_handler,
|
create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: 60.minutes.ago.to_i),
|
||||||
track_cleaner: replace_cleaner
|
# Gap exceeds threshold 👇👇👇
|
||||||
)
|
create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: 10.minutes.ago.to_i),
|
||||||
|
create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: Time.current.to_i)
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:existing_track) { create(:track, user: user) }
|
|
||||||
let!(:points) do
|
|
||||||
[
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i, latitude: 40.7128, longitude: -74.0060),
|
|
||||||
create(:point, user: user, lonlat: 'POINT(-74.0050 40.7138)', timestamp: 30.minutes.ago.to_i, latitude: 40.7138, longitude: -74.0050)
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'behaves like bulk processing' do
|
|
||||||
initial_count = Track.count
|
|
||||||
bulk_generator.call
|
|
||||||
# Bulk processing replaces existing tracks with new ones
|
|
||||||
# The final count depends on how many valid tracks can be created from the points
|
|
||||||
expect(Track.count).to be >= 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with incremental processing strategies' do
|
|
||||||
let(:incremental_loader) { Tracks::PointLoaders::IncrementalLoader.new(user) }
|
|
||||||
let(:buffer_handler) { Tracks::IncompleteSegmentHandlers::BufferHandler.new(user, Date.current, 5) }
|
|
||||||
let(:noop_cleaner) { Tracks::Cleaners::NoOpCleaner.new(user) }
|
|
||||||
|
|
||||||
let(:incremental_generator) do
|
|
||||||
described_class.new(
|
|
||||||
user,
|
|
||||||
point_loader: incremental_loader,
|
|
||||||
incomplete_segment_handler: buffer_handler,
|
|
||||||
track_cleaner: noop_cleaner
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:existing_track) { create(:track, user: user) }
|
|
||||||
|
|
||||||
before do
|
before do
|
||||||
# Mock the incremental loader to return some points
|
allow(safe_settings).to receive(:minutes_between_routes).and_return(45)
|
||||||
allow(incremental_loader).to receive(:load_points).and_return([])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'behaves like incremental processing' do
|
it 'creates separate tracks for segments' do
|
||||||
expect { incremental_generator.call }.not_to change { Track.count }
|
expect { generator.call }.to change(Track, :count).by(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with points exceeding distance threshold' do
|
||||||
|
let!(:points) do
|
||||||
|
[
|
||||||
|
create_points_around(user: user, count: 2, base_lat: 29.0, timestamp: 20.minutes.ago.to_i),
|
||||||
|
create_points_around(user: user, count: 2, base_lat: 29.0, timestamp: 15.minutes.ago.to_i),
|
||||||
|
# Large distance jump 👇👇👇
|
||||||
|
create_points_around(user: user, count: 2, base_lat: 28.0, timestamp: 10.minutes.ago.to_i),
|
||||||
|
create_points_around(user: user, count: 1, base_lat: 28.0, timestamp: Time.current.to_i)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(safe_settings).to receive(:meters_between_routes).and_return(200)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates separate tracks for segments' do
|
||||||
|
expect { generator.call }.to change(Track, :count).by(2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'deterministic behavior' do
|
||||||
|
let!(:points) { create_points_around(user: user, count: 10, base_lat: 28.0) }
|
||||||
|
|
||||||
|
it 'produces same results for bulk and incremental modes' do
|
||||||
|
# Generate tracks in bulk mode
|
||||||
|
bulk_generator = described_class.new(user, mode: :bulk)
|
||||||
|
bulk_generator.call
|
||||||
|
bulk_tracks = user.tracks.order(:start_at).to_a
|
||||||
|
|
||||||
|
# Clear tracks and generate incrementally
|
||||||
|
user.tracks.destroy_all
|
||||||
|
incremental_generator = described_class.new(user, mode: :incremental)
|
||||||
|
incremental_generator.call
|
||||||
|
incremental_tracks = user.tracks.order(:start_at).to_a
|
||||||
|
|
||||||
|
# Should have same number of tracks
|
||||||
|
expect(incremental_tracks.size).to eq(bulk_tracks.size)
|
||||||
|
|
||||||
|
# Should have same track boundaries (allowing for small timing differences)
|
||||||
|
bulk_tracks.zip(incremental_tracks).each do |bulk_track, incremental_track|
|
||||||
|
expect(incremental_track.start_at).to be_within(1.second).of(bulk_track.start_at)
|
||||||
|
expect(incremental_track.end_at).to be_within(1.second).of(bulk_track.end_at)
|
||||||
|
expect(incremental_track.distance).to be_within(10).of(bulk_track.distance)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
249
spec/services/tracks/incremental_processor_spec.rb
Normal file
249
spec/services/tracks/incremental_processor_spec.rb
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Tracks::IncrementalProcessor do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:safe_settings) { user.safe_settings }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(user).to receive(:safe_settings).and_return(safe_settings)
|
||||||
|
allow(safe_settings).to receive(:minutes_between_routes).and_return(30)
|
||||||
|
allow(safe_settings).to receive(:meters_between_routes).and_return(500)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#call' do
|
||||||
|
context 'with imported points' do
|
||||||
|
let(:imported_point) { create(:point, user: user, import: create(:import)) }
|
||||||
|
let(:processor) { described_class.new(user, imported_point) }
|
||||||
|
|
||||||
|
it 'does not process imported points' do
|
||||||
|
expect(Tracks::CreateJob).not_to receive(:perform_later)
|
||||||
|
|
||||||
|
processor.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with first point for user' do
|
||||||
|
let(:new_point) { create(:point, user: user) }
|
||||||
|
let(:processor) { described_class.new(user, new_point) }
|
||||||
|
|
||||||
|
it 'processes first point' do
|
||||||
|
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||||
|
.with(user.id, start_at: nil, end_at: nil, mode: :none)
|
||||||
|
processor.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with thresholds exceeded' do
|
||||||
|
let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
|
||||||
|
let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
|
||||||
|
let(:processor) { described_class.new(user, new_point) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create previous point first
|
||||||
|
previous_point
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'processes when time threshold exceeded' do
|
||||||
|
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||||
|
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :none)
|
||||||
|
processor.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with existing tracks' do
|
||||||
|
let(:existing_track) { create(:track, user: user, end_at: 2.hours.ago) }
|
||||||
|
let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
|
||||||
|
let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
|
||||||
|
let(:processor) { described_class.new(user, new_point) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
existing_track
|
||||||
|
previous_point
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses existing track end time as start_at' do
|
||||||
|
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||||
|
.with(user.id, start_at: existing_track.end_at, end_at: Time.zone.at(previous_point.timestamp), mode: :none)
|
||||||
|
processor.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with distance threshold exceeded' do
|
||||||
|
let(:previous_point) do
|
||||||
|
create(:point, user: user, timestamp: 10.minutes.ago.to_i, lonlat: 'POINT(0 0)')
|
||||||
|
end
|
||||||
|
let(:new_point) do
|
||||||
|
create(:point, user: user, timestamp: Time.current.to_i, lonlat: 'POINT(1 1)')
|
||||||
|
end
|
||||||
|
let(:processor) { described_class.new(user, new_point) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create previous point first
|
||||||
|
previous_point
|
||||||
|
# Mock distance calculation to exceed threshold
|
||||||
|
allow_any_instance_of(Point).to receive(:distance_to).and_return(1.0) # 1 km = 1000m
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'processes when distance threshold exceeded' do
|
||||||
|
expect(Tracks::CreateJob).to receive(:perform_later)
|
||||||
|
.with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :none)
|
||||||
|
processor.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with thresholds not exceeded' do
|
||||||
|
let(:previous_point) { create(:point, user: user, timestamp: 10.minutes.ago.to_i) }
|
||||||
|
let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
|
||||||
|
let(:processor) { described_class.new(user, new_point) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Create previous point first
|
||||||
|
previous_point
|
||||||
|
# Mock distance to be within threshold
|
||||||
|
allow_any_instance_of(Point).to receive(:distance_to).and_return(0.1) # 100m
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not process when thresholds not exceeded' do
|
||||||
|
expect(Tracks::CreateJob).not_to receive(:perform_later)
|
||||||
|
processor.call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#should_process?' do
|
||||||
|
let(:processor) { described_class.new(user, new_point) }
|
||||||
|
|
||||||
|
context 'with imported point' do
|
||||||
|
let(:new_point) { create(:point, user: user, import: create(:import)) }
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
expect(processor.send(:should_process?)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with first point for user' do
|
||||||
|
let(:new_point) { create(:point, user: user) }
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
expect(processor.send(:should_process?)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with thresholds exceeded' do
|
||||||
|
let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
|
||||||
|
let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
previous_point # Create previous point
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true when time threshold exceeded' do
|
||||||
|
expect(processor.send(:should_process?)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with thresholds not exceeded' do
|
||||||
|
let(:previous_point) { create(:point, user: user, timestamp: 10.minutes.ago.to_i) }
|
||||||
|
let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
previous_point # Create previous point
|
||||||
|
allow_any_instance_of(Point).to receive(:distance_to).and_return(0.1) # 100m
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when thresholds not exceeded' do
|
||||||
|
expect(processor.send(:should_process?)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#exceeds_thresholds?' do
|
||||||
|
let(:processor) { described_class.new(user, new_point) }
|
||||||
|
let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
|
||||||
|
let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
|
||||||
|
|
||||||
|
context 'with time threshold exceeded' do
|
||||||
|
before do
|
||||||
|
allow(safe_settings).to receive(:minutes_between_routes).and_return(30)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
result = processor.send(:exceeds_thresholds?, previous_point, new_point)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with distance threshold exceeded' do
|
||||||
|
before do
|
||||||
|
allow(safe_settings).to receive(:minutes_between_routes).and_return(120) # 2 hours
|
||||||
|
allow(safe_settings).to receive(:meters_between_routes).and_return(400)
|
||||||
|
allow_any_instance_of(Point).to receive(:distance_to).and_return(0.5) # 500m
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true' do
|
||||||
|
result = processor.send(:exceeds_thresholds?, previous_point, new_point)
|
||||||
|
expect(result).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with neither threshold exceeded' do
|
||||||
|
before do
|
||||||
|
allow(safe_settings).to receive(:minutes_between_routes).and_return(120) # 2 hours
|
||||||
|
allow(safe_settings).to receive(:meters_between_routes).and_return(600)
|
||||||
|
allow_any_instance_of(Point).to receive(:distance_to).and_return(0.1) # 100m
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false' do
|
||||||
|
result = processor.send(:exceeds_thresholds?, previous_point, new_point)
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#time_difference_minutes' do
|
||||||
|
let(:processor) { described_class.new(user, new_point) }
|
||||||
|
let(:point1) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
|
||||||
|
let(:point2) { create(:point, user: user, timestamp: Time.current.to_i) }
|
||||||
|
let(:new_point) { point2 }
|
||||||
|
|
||||||
|
it 'calculates time difference in minutes' do
|
||||||
|
result = processor.send(:time_difference_minutes, point1, point2)
|
||||||
|
expect(result).to be_within(1).of(60) # Approximately 60 minutes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#distance_difference_meters' do
|
||||||
|
let(:processor) { described_class.new(user, new_point) }
|
||||||
|
let(:point1) { create(:point, user: user) }
|
||||||
|
let(:point2) { create(:point, user: user) }
|
||||||
|
let(:new_point) { point2 }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(point1).to receive(:distance_to).with(point2).and_return(1.5) # 1.5 km
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calculates distance difference in meters' do
|
||||||
|
result = processor.send(:distance_difference_meters, point1, point2)
|
||||||
|
expect(result).to eq(1500) # 1.5 km = 1500 m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'threshold configuration' do
|
||||||
|
let(:processor) { described_class.new(user, create(:point, user: user)) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(safe_settings).to receive(:minutes_between_routes).and_return(45)
|
||||||
|
allow(safe_settings).to receive(:meters_between_routes).and_return(750)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses configured time threshold' do
|
||||||
|
expect(processor.send(:time_threshold_minutes)).to eq(45)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses configured distance threshold' do
|
||||||
|
expect(processor.send(:distance_threshold_meters)).to eq(750)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,238 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -116,11 +116,11 @@ RSpec.describe Tracks::TrackBuilder do
|
||||||
|
|
||||||
it 'builds path using Tracks::BuildPath service' do
|
it 'builds path using Tracks::BuildPath service' do
|
||||||
expect(Tracks::BuildPath).to receive(:new).with(
|
expect(Tracks::BuildPath).to receive(:new).with(
|
||||||
points.map(&:lonlat)
|
points
|
||||||
).and_call_original
|
).and_call_original
|
||||||
|
|
||||||
result = builder.build_path(points)
|
result = builder.build_path(points)
|
||||||
expect(result).to respond_to(:as_text) # RGeo geometry object
|
expect(result).to respond_to(:as_text)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ RSpec.describe Users::ImportData, type: :service do
|
||||||
let(:import_directory) { Rails.root.join('tmp', "import_#{user.email.gsub(/[^0-9A-Za-z._-]/, '_')}_1234567890") }
|
let(:import_directory) { Rails.root.join('tmp', "import_#{user.email.gsub(/[^0-9A-Za-z._-]/, '_')}_1234567890") }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(Time).to receive(:current).and_return(Time.at(1234567890))
|
allow(Time).to receive(:current).and_return(Time.zone.at(1234567890))
|
||||||
allow(FileUtils).to receive(:mkdir_p)
|
allow(FileUtils).to receive(:mkdir_p)
|
||||||
allow(FileUtils).to receive(:rm_rf)
|
allow(FileUtils).to receive(:rm_rf)
|
||||||
allow(File).to receive(:directory?).and_return(true)
|
allow(File).to receive(:directory?).and_return(true)
|
||||||
|
|
|
||||||
20
spec/support/point_helpers.rb
Normal file
20
spec/support/point_helpers.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module PointHelpers
|
||||||
|
# Creates a list of points spaced ~100m apart northwards
|
||||||
|
def create_points_around(user:, count:, base_lat: 20.0, base_lon: 10.0, timestamp: nil, **attrs)
|
||||||
|
Array.new(count) do |i|
|
||||||
|
create(
|
||||||
|
:point,
|
||||||
|
user: user,
|
||||||
|
timestamp: (timestamp.respond_to?(:call) ? timestamp.call(i) : timestamp) || (Time.current - i.minutes).to_i,
|
||||||
|
lonlat: "POINT(#{base_lon} #{base_lat + i * 0.0009})",
|
||||||
|
**attrs
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
RSpec.configure do |config|
|
||||||
|
config.include PointHelpers
|
||||||
|
end
|
||||||
Loading…
Reference in a new issue