2026-07-17 18:33:06 +02:00
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 }
}
2026-07-19 12:57:18 +02:00
// 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 )
}
}
2026-07-17 18:33:06 +02:00
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 )
}
}
2026-07-19 11:55:13 +02:00
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 ] )
}
}
2026-07-19 18:01:06 +02:00
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 ] )
}
}
2026-07-19 11:55:13 +02:00
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 )
}
}
2026-07-19 10:52:09 +02:00
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 )
}
}
}
2026-07-17 18:33:06 +02:00
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 } ,
} ,
} ,
} ,
}
2026-07-17 19:13:24 +02:00
svc := NewService ( m , db , Config { MinConfidence : 0.5 } , fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-17 18:33:06 +02:00
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]}]} `
2026-07-17 18:50:21 +02:00
if _ , err := db . CreateWorkoutKind ( ctx , store . WorkoutKind { Name : "Test Classification Tempo" , RuleJSON : ruleJSON , IsActive : true } ) ; err != nil {
2026-07-17 18:33:06 +02:00
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 )
}
}
2026-07-19 11:55:13 +02:00
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 )
}
}
2026-07-19 18:01:06 +02:00
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets ( t * testing . T ) {
2026-07-19 11:55:13 +02:00
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 {
2026-07-19 18:01:06 +02:00
// 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.
2026-07-19 11:55:13 +02:00
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 )
}
2026-07-19 18:01:06 +02:00
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 )
2026-07-19 11:55:13 +02:00
}
}
2026-07-17 18:33:06 +02:00
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 )
}
2026-07-19 10:52:09 +02:00
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 )
}
2026-07-17 18:33:06 +02:00
}
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 } ,
} }
2026-07-19 12:57:18 +02:00
svc := NewService ( m , db , Config { BackfillWindowDays : 10 } ,
2026-07-17 18:33:06 +02:00
fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-19 12:57:18 +02:00
setBackfillHorizon ( t , db , 10 )
2026-07-17 18:33:06 +02:00
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 )
}
}
2026-07-19 12:41:38 +02:00
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 } ,
} }
2026-07-19 12:57:18 +02:00
svc := NewService ( m , db , Config { BackfillWindowDays : 10 } ,
2026-07-19 12:41:38 +02:00
fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-19 12:57:18 +02:00
setBackfillHorizon ( t , db , 10 )
2026-07-19 12:41:38 +02:00
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 ) )
}
}
2026-07-17 18:33:06 +02:00
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 ) )
2026-07-19 12:57:18 +02:00
svc := NewService ( m , db , Config { BackfillWindowDays : 10 } , now )
setBackfillHorizon ( t , db , 10 )
2026-07-17 18:33:06 +02:00
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.
2026-07-19 12:57:18 +02:00
setBackfillHorizon ( t , db , 30 )
svc2 := NewService ( m , db , Config { BackfillWindowDays : 10 } , now )
2026-07-17 18:33:06 +02:00
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 )
}
}
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
func TestFullSync_RecordsOneCombinedSyncRun ( 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 } ,
{ ActivityID : 2 , ActivityType : garmin . ActivityType { TypeKey : "running" } , StartTimeGMT : "2026-07-08 06:00:00" , Distance : 5000 , Duration : 1500 } ,
} ,
Splits : map [ int64 ] garmin . ActivitySplits {
1 : { ActivityID : 1 } , 2 : { ActivityID : 2 } ,
} ,
Details : map [ int64 ] garmin . ActivityDetails {
1 : { ActivityID : 1 } , 2 : { ActivityID : 2 } ,
} ,
}
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 . FullSync ( ctx , 10 ) ; err != nil {
t . Fatalf ( "FullSync: %v" , err )
}
runs , err := db . ListSyncRuns ( ctx , 10 )
if err != nil {
t . Fatalf ( "ListSyncRuns: %v" , err )
}
if len ( runs ) != 1 {
t . Fatalf ( "expected exactly 1 sync run recorded by FullSync (not one per stage), got %d: %+v" , len ( runs ) , runs )
}
run := runs [ 0 ]
if run . Kind != store . SyncKindFull {
t . Errorf ( "Kind = %q, want %q" , run . Kind , store . SyncKindFull )
}
if run . Status != store . SyncStatusSuccess {
t . Errorf ( "Status = %q, want success" , run . Status )
}
// Backfill (one window covering the whole horizon) stores both of the
// mock's activities as genuinely new (2). IncrementalSync then runs its
// own separate fetch and -- since the fake client ignores the date range
// it's called with -- sees the exact same 2 activities again, but they're
// already stored by then, so it contributes 0 new ones. The combined
// run's count (2) must reflect that dedup, not naively sum each stage's
// raw fetch count (which would double-count to 4) or report only
// whichever stage happened to run last (which would silently drop
// Backfill's count) -- both are bugs this test guards against.
if run . ActivitiesFetched != 2 {
t . Errorf ( "ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)" , run . ActivitiesFetched )
}
activities , err := db . ListActivities ( ctx , store . ActivityFilter { } )
if err != nil {
t . Fatalf ( "ListActivities: %v" , err )
}
if len ( activities ) != 2 {
t . Fatalf ( "expected 2 stored activities (upserted, not duplicated), got %d" , len ( activities ) )
}
}
2026-07-17 18:33:06 +02:00
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 )
}
}