Confirmed via Garmin Connect against a real activity ("Auriol - W3-3-Double
Barrel"): the workout's step count didn't match its 18 recorded laps
because the athlete kept running 6:46 past the prescribed 5-minute
cool-down, logged as an 18th lap the workout never defined. The strict
equality check meant this one extra lap discarded every other lap's real
target too, not just its own.
alignWorkoutTargets now tolerates exactly one extra recorded lap beyond the
step count: the steps that do exist still zip to their laps normally, and
only the trailing extra lap is left without a target. Any larger mismatch
still falls back to nil for every lap, since that can't be trusted at all.
606 lines
22 KiB
Go
606 lines
22 KiB
Go
package sync
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"smartrun/backend/internal/classify"
|
|
"smartrun/backend/internal/garmin"
|
|
"smartrun/backend/internal/garmin/mock"
|
|
"smartrun/backend/internal/store"
|
|
)
|
|
|
|
func f(v float64) *float64 { return &v }
|
|
|
|
func openTestDB(t *testing.T) *store.DB {
|
|
t.Helper()
|
|
db, err := store.Open(filepath.Join(t.TempDir(), "smartrun_test.db"))
|
|
if err != nil {
|
|
t.Fatalf("store.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
return db
|
|
}
|
|
|
|
func fixedNow(t time.Time) func() time.Time {
|
|
return func() time.Time { return t }
|
|
}
|
|
|
|
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
|
|
// fresh on every call (it's no longer part of Config).
|
|
func setBackfillHorizon(t *testing.T, db *store.DB, days int) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
profile, err := db.GetProfile(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetProfile: %v", err)
|
|
}
|
|
profile.BackfillHorizonDays = days
|
|
if err := db.UpdateProfile(ctx, profile); err != nil {
|
|
t.Fatalf("UpdateProfile: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
m := &mock.Client{Activities: []garmin.Activity{
|
|
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
|
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
|
}}
|
|
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("Backfill: %v", err)
|
|
}
|
|
|
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
|
if err != nil {
|
|
t.Fatalf("ListActivities: %v", err)
|
|
}
|
|
if len(activities) != 1 {
|
|
t.Fatalf("expected 1 stored activity, got %d", len(activities))
|
|
}
|
|
if activities[0].GarminActivityID != 1 {
|
|
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
|
|
}
|
|
|
|
runs, err := db.ListSyncRuns(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("ListSyncRuns: %v", err)
|
|
}
|
|
if len(runs) != 1 || runs[0].Status != store.SyncStatusSuccess {
|
|
t.Fatalf("expected 1 successful sync run, got %+v", runs)
|
|
}
|
|
}
|
|
|
|
func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
|
|
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}}
|
|
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
|
{Steps: []garmin.WorkoutStep{
|
|
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
|
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}},
|
|
}},
|
|
}}
|
|
|
|
targets := alignWorkoutTargets(laps, workout)
|
|
if len(targets) != 2 {
|
|
t.Fatalf("expected 2 aligned targets, got %d", len(targets))
|
|
}
|
|
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
|
|
t.Errorf("targets[0] = %+v, want pace.zone step", targets[0])
|
|
}
|
|
if targets[1] == nil || targets[1].TargetType.TypeKey != "no.target" {
|
|
t.Errorf("targets[1] = %+v, want no.target step", targets[1])
|
|
}
|
|
}
|
|
|
|
func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T) {
|
|
// Confirmed via Garmin Connect against a real activity: recording
|
|
// sometimes continues one lap past the workout's last step (e.g. a
|
|
// 5-minute prescribed cool-down followed by another 6:46 the athlete
|
|
// just kept running). The extra lap should have no target, but every
|
|
// other lap's real target must still come through.
|
|
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
|
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
|
{Steps: []garmin.WorkoutStep{
|
|
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
|
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
|
|
}},
|
|
}}
|
|
|
|
targets := alignWorkoutTargets(laps, workout)
|
|
if len(targets) != 3 {
|
|
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
|
|
}
|
|
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
|
|
t.Errorf("targets[0] = %+v, want the first step's pace.zone target", targets[0])
|
|
}
|
|
if targets[1] == nil || targets[1].TargetType.TypeKey != "pace.zone" {
|
|
t.Errorf("targets[1] = %+v, want the second step's pace.zone target", targets[1])
|
|
}
|
|
if targets[2] != nil {
|
|
t.Errorf("targets[2] = %+v, want nil for the trailing unplanned continuation lap", targets[2])
|
|
}
|
|
}
|
|
|
|
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
|
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
|
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
|
{Steps: []garmin.WorkoutStep{
|
|
{Type: "ExecutableStepDTO"},
|
|
}},
|
|
}}
|
|
|
|
targets := alignWorkoutTargets(laps, workout)
|
|
if len(targets) != 3 {
|
|
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
|
|
}
|
|
for i, target := range targets {
|
|
if target != nil {
|
|
t.Errorf("targets[%d] = %+v, want nil on count mismatch", i, target)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) {
|
|
lo, hi := targetPaceRange(garmin.WorkoutStep{
|
|
TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
|
|
})
|
|
if lo == nil || hi == nil || *lo != 3 || *hi != 4 {
|
|
t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi)
|
|
}
|
|
|
|
lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
|
|
if lo != nil || hi != nil {
|
|
t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi)
|
|
}
|
|
}
|
|
|
|
func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
|
lo, hi := targetHRRange(garmin.WorkoutStep{
|
|
TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
|
|
}, store.Profile{})
|
|
if lo == nil || hi == nil || *lo != 150 || *hi != 160 {
|
|
t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi)
|
|
}
|
|
|
|
zone := 3
|
|
profile := store.Profile{
|
|
MaxHeartRate: f(190), RestingHeartRate: f(50),
|
|
HRZone3MinPct: 70, HRZone3MaxPct: 80,
|
|
}
|
|
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
|
|
// Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162
|
|
if lo == nil || hi == nil || *lo != 148 || *hi != 162 {
|
|
t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi)
|
|
}
|
|
|
|
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
|
|
if lo != nil || hi != nil {
|
|
t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi)
|
|
}
|
|
}
|
|
|
|
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
m := &mock.Client{Activities: []garmin.Activity{
|
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
|
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"},
|
|
StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400},
|
|
{ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"},
|
|
StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800},
|
|
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
|
|
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
|
|
}}
|
|
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("Backfill: %v", err)
|
|
}
|
|
|
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
|
if err != nil {
|
|
t.Fatalf("ListActivities: %v", err)
|
|
}
|
|
if len(activities) != 2 {
|
|
t.Fatalf("expected 2 stored (running-only) activities, got %d: %+v", len(activities), activities)
|
|
}
|
|
for _, a := range activities {
|
|
if a.GarminActivityID != 1 && a.GarminActivityID != 2 {
|
|
t.Errorf("unexpected non-running activity stored: %+v", a)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
const garminActivityID = 42
|
|
m := &mock.Client{
|
|
Activities: []garmin.Activity{
|
|
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
|
},
|
|
Splits: map[int64]garmin.ActivitySplits{
|
|
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
|
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"},
|
|
}},
|
|
},
|
|
Details: map[int64]garmin.ActivityDetails{
|
|
garminActivityID: {
|
|
ActivityID: garminActivityID,
|
|
MetricDescriptors: []garmin.MetricDescriptor{
|
|
{Key: "directHeartRate", MetricsIndex: 0},
|
|
{Key: "sumElapsedDuration", MetricsIndex: 1},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
svc := NewService(m, db, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("Backfill: %v", err)
|
|
}
|
|
|
|
// A workout kind that should cleanly match the seeded activity's pace.
|
|
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
|
|
if _, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
|
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
|
}
|
|
|
|
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
|
t.Fatalf("FillPendingDetails: %v", err)
|
|
}
|
|
|
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
|
if err != nil || len(activities) != 1 {
|
|
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
|
}
|
|
activityID := activities[0].ID
|
|
|
|
if activities[0].DetailsFetchedAt == nil {
|
|
t.Error("expected DetailsFetchedAt to be set after FillPendingDetails")
|
|
}
|
|
if activities[0].SplitsFetchedAt == nil {
|
|
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
|
|
}
|
|
|
|
laps, err := db.LapsForActivity(ctx, activityID)
|
|
if err != nil || len(laps) != 1 {
|
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
|
}
|
|
|
|
assignment, ok, err := db.CurrentAssignment(ctx, activityID)
|
|
if err != nil || !ok {
|
|
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
|
}
|
|
if assignment.Status != classify.StatusAssigned {
|
|
t.Fatalf("assignment.Status = %q, want %q (candidates: %s)", assignment.Status, classify.StatusAssigned, assignment.CandidateKindsJSON)
|
|
}
|
|
}
|
|
|
|
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
const garminActivityID = 55
|
|
const workoutID = 999
|
|
workoutIDPtr := int64(workoutID)
|
|
m := &mock.Client{
|
|
Activities: []garmin.Activity{
|
|
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
|
},
|
|
Splits: map[int64]garmin.ActivitySplits{
|
|
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
|
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
|
}},
|
|
},
|
|
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
|
Workouts: map[int64]garmin.Workout{
|
|
workoutID: {Segments: []garmin.WorkoutSegment{
|
|
{Steps: []garmin.WorkoutStep{
|
|
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("Backfill: %v", err)
|
|
}
|
|
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
|
t.Fatalf("FillPendingDetails: %v", err)
|
|
}
|
|
|
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
|
if err != nil || len(activities) != 1 {
|
|
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
|
}
|
|
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
|
if err != nil || len(laps) != 1 {
|
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
|
}
|
|
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
|
|
t.Errorf("TargetPaceLowMps = %v, want 3.0", laps[0].TargetPaceLowMps)
|
|
}
|
|
if laps[0].TargetPaceHighMps == nil || *laps[0].TargetPaceHighMps != 3.5 {
|
|
t.Errorf("TargetPaceHighMps = %v, want 3.5", laps[0].TargetPaceHighMps)
|
|
}
|
|
}
|
|
|
|
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
const garminActivityID = 56
|
|
const workoutID = 1000
|
|
workoutIDPtr := int64(workoutID)
|
|
m := &mock.Client{
|
|
Activities: []garmin.Activity{
|
|
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
|
},
|
|
Splits: map[int64]garmin.ActivitySplits{
|
|
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
|
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
|
{LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"},
|
|
}},
|
|
},
|
|
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
|
Workouts: map[int64]garmin.Workout{
|
|
// One step for two recorded laps -- the second lap is an
|
|
// unplanned continuation past the workout's end (confirmed via
|
|
// Garmin Connect against a real activity), not a genuine
|
|
// mismatch, so the first lap should still get a real target.
|
|
workoutID: {Segments: []garmin.WorkoutSegment{
|
|
{Steps: []garmin.WorkoutStep{
|
|
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("Backfill: %v", err)
|
|
}
|
|
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
|
t.Fatalf("FillPendingDetails: %v", err)
|
|
}
|
|
|
|
activities, _ := db.ListActivities(ctx, store.ActivityFilter{})
|
|
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
|
if err != nil || len(laps) != 2 {
|
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
|
}
|
|
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
|
|
t.Errorf("laps[0].TargetPaceLowMps = %v, want 3.0 (the one defined step's target)", laps[0].TargetPaceLowMps)
|
|
}
|
|
if laps[1].TargetPaceLowMps != nil || laps[1].TargetPaceHighMps != nil {
|
|
t.Errorf("laps[1] target should be nil (the trailing unplanned continuation lap), got low=%v high=%v", laps[1].TargetPaceLowMps, laps[1].TargetPaceHighMps)
|
|
}
|
|
}
|
|
|
|
func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) {
|
|
activity := store.Activity{
|
|
DurationSeconds: 1800,
|
|
DistanceMeters: 6000,
|
|
AvgSpeedMps: f(3.33),
|
|
AvgHR: f(152),
|
|
AerobicTrainingEffect: f(3.2),
|
|
}
|
|
laps := []store.Lap{
|
|
{IntensityType: "ACTIVE", AvgSpeedMps: f(3.33), HRDriftBpmPerMin: f(2.5)},
|
|
{IntensityType: "REST", AvgSpeedMps: f(1.5), HRRecoveryBpmPerMin: f(4.0)},
|
|
}
|
|
|
|
ctx := buildMetricContext(activity, laps, 190)
|
|
|
|
if got := ctx["avg_pace_sec_per_km"]; got < 300 || got > 301 {
|
|
t.Errorf("avg_pace_sec_per_km = %v, want ~300.3 (1000/3.33)", got)
|
|
}
|
|
if got, want := ctx["avg_hr_pct_max"], 152.0/190.0; got != want {
|
|
t.Errorf("avg_hr_pct_max = %v, want %v", got, want)
|
|
}
|
|
if got := ctx["lap_hr_drift_bpm_per_min"]; got != 2.5 {
|
|
t.Errorf("lap_hr_drift_bpm_per_min = %v, want 2.5", got)
|
|
}
|
|
if got := ctx["lap_hr_recovery_bpm_per_min"]; got != 4.0 {
|
|
t.Errorf("lap_hr_recovery_bpm_per_min = %v, want 4.0", got)
|
|
}
|
|
// Only one ACTIVE + one REST lap, not repeated -- should not look like a
|
|
// structured interval workout.
|
|
if got := ctx["lap_interval_pattern"]; got != 0 {
|
|
t.Errorf("lap_interval_pattern = %v, want 0", got)
|
|
}
|
|
if got := ctx["is_race"]; got != 0 {
|
|
t.Errorf("is_race = %v, want 0 (EventTypeKey not set)", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
|
|
ctx := buildMetricContext(store.Activity{EventTypeKey: "race"}, nil, 0)
|
|
if got := ctx["is_race"]; got != 1 {
|
|
t.Errorf("is_race = %v, want 1 when EventTypeKey is \"race\"", got)
|
|
}
|
|
|
|
ctx = buildMetricContext(store.Activity{EventTypeKey: "training"}, nil, 0)
|
|
if got := ctx["is_race"]; got != 0 {
|
|
t.Errorf("is_race = %v, want 0 when EventTypeKey is not \"race\"", got)
|
|
}
|
|
}
|
|
|
|
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
m := &mock.Client{Activities: []garmin.Activity{
|
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
|
}}
|
|
svc := NewService(m, db, Config{BackfillWindowDays: 10},
|
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
setBackfillHorizon(t, db, 10)
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("first Backfill: %v", err)
|
|
}
|
|
firstCallCount := m.GetActivitiesCalls
|
|
if firstCallCount == 0 {
|
|
t.Fatal("expected first backfill to call GetActivities at least once")
|
|
}
|
|
|
|
state, err := db.GetSyncState(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetSyncState: %v", err)
|
|
}
|
|
if !state.BackfillComplete {
|
|
t.Fatalf("expected backfill_complete=true after covering the full horizon, got %+v", state)
|
|
}
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("second Backfill: %v", err)
|
|
}
|
|
if m.GetActivitiesCalls != firstCallCount {
|
|
t.Errorf("second Backfill made %d more GetActivities call(s); want 0 (should be a no-op once horizon is covered)",
|
|
m.GetActivitiesCalls-firstCallCount)
|
|
}
|
|
}
|
|
|
|
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
m := &mock.Client{Activities: []garmin.Activity{
|
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
|
}}
|
|
svc := NewService(m, db, Config{BackfillWindowDays: 10},
|
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
setBackfillHorizon(t, db, 10)
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("first Backfill: %v", err)
|
|
}
|
|
firstCallCount := m.GetActivitiesCalls
|
|
|
|
if err := svc.ResetAll(ctx); err != nil {
|
|
t.Fatalf("ResetAll: %v", err)
|
|
}
|
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
|
if err != nil {
|
|
t.Fatalf("ListActivities: %v", err)
|
|
}
|
|
if len(activities) != 0 {
|
|
t.Fatalf("expected 0 activities after ResetAll, got %d", len(activities))
|
|
}
|
|
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("Backfill after reset: %v", err)
|
|
}
|
|
if m.GetActivitiesCalls <= firstCallCount {
|
|
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
|
|
}
|
|
activities, err = db.ListActivities(ctx, store.ActivityFilter{})
|
|
if err != nil {
|
|
t.Fatalf("ListActivities after re-backfill: %v", err)
|
|
}
|
|
if len(activities) != 1 {
|
|
t.Fatalf("expected 1 activity re-fetched after reset+backfill, got %d", len(activities))
|
|
}
|
|
}
|
|
|
|
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
m := &mock.Client{Activities: []garmin.Activity{
|
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
|
}}
|
|
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
|
|
|
|
svc := NewService(m, db, Config{BackfillWindowDays: 10}, now)
|
|
setBackfillHorizon(t, db, 10)
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("first Backfill: %v", err)
|
|
}
|
|
firstCallCount := m.GetActivitiesCalls
|
|
|
|
// Simulate the user widening the horizon later -- should resume from the
|
|
// watermark (not re-fetch the already-covered recent window) but still
|
|
// make progress toward the new, deeper horizon.
|
|
setBackfillHorizon(t, db, 30)
|
|
svc2 := NewService(m, db, Config{BackfillWindowDays: 10}, now)
|
|
if err := svc2.Backfill(ctx); err != nil {
|
|
t.Fatalf("second Backfill: %v", err)
|
|
}
|
|
if m.GetActivitiesCalls <= firstCallCount {
|
|
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
|
|
}
|
|
|
|
state, err := db.GetSyncState(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetSyncState: %v", err)
|
|
}
|
|
if !state.BackfillComplete {
|
|
t.Fatalf("expected backfill_complete=true after covering the new horizon, got %+v", state)
|
|
}
|
|
}
|
|
|
|
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
|
db := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
m := &mock.Client{
|
|
Activities: []garmin.Activity{},
|
|
Splits: map[int64]garmin.ActivitySplits{},
|
|
Details: map[int64]garmin.ActivityDetails{},
|
|
}
|
|
const n = 3
|
|
for i := int64(1); i <= n; i++ {
|
|
m.Activities = append(m.Activities, garmin.Activity{
|
|
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
|
StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500,
|
|
})
|
|
m.Splits[i] = garmin.ActivitySplits{ActivityID: i}
|
|
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
|
|
}
|
|
|
|
svc := NewService(m, db, Config{InterCallDelay: 150 * time.Millisecond},
|
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
|
if err := svc.Backfill(ctx); err != nil {
|
|
t.Fatalf("Backfill: %v", err)
|
|
}
|
|
|
|
if p := svc.Progress(); p.Total != 0 {
|
|
t.Fatalf("Progress before FillPendingDetails = %+v, want zero value", p)
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- svc.FillPendingDetails(ctx, n) }()
|
|
|
|
time.Sleep(200 * time.Millisecond) // into the delay before the 2nd or 3rd item
|
|
mid := svc.Progress()
|
|
if mid.Total != n {
|
|
t.Errorf("mid-flight Progress().Total = %d, want %d", mid.Total, n)
|
|
}
|
|
if mid.Done <= 0 || mid.Done >= n {
|
|
t.Errorf("mid-flight Progress().Done = %d, want strictly between 0 and %d (i.e. actually in progress)", mid.Done, n)
|
|
}
|
|
|
|
if err := <-done; err != nil {
|
|
t.Fatalf("FillPendingDetails: %v", err)
|
|
}
|
|
if final := svc.Progress(); final.Total != 0 || final.Done != 0 {
|
|
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
|
|
}
|
|
}
|