Tolerate exactly one extra trailing lap when aligning workout targets
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.
This commit is contained in:
@@ -118,20 +118,31 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
|
||||
|
||||
// alignWorkoutTargets zips an activity's recorded laps against its
|
||||
// structured workout's flattened steps, returning one *garmin.WorkoutStep
|
||||
// per lap (nil where unavailable). Zipping only happens when the counts
|
||||
// match exactly -- a mismatch (extra manual laps, auto-lap-by-distance also
|
||||
// firing, etc.) means we can't trust the alignment, so every entry comes
|
||||
// back nil rather than risk showing a target against the wrong lap.
|
||||
// per lap (nil where unavailable).
|
||||
//
|
||||
// Confirmed against a real activity (via Garmin Connect's own workout view)
|
||||
// that recording sometimes continues one lap past the end of the workout's
|
||||
// last step -- e.g. a 5-minute prescribed cool-down followed by another
|
||||
// 6:46 the athlete just kept running, logged as a further lap Garmin never
|
||||
// defined a target for. That shows up here as exactly one more recorded lap
|
||||
// than the workout has steps, so that specific case zips the steps that do
|
||||
// exist and leaves the trailing extra lap unmapped, rather than discarding
|
||||
// every other lap's real target along with it.
|
||||
//
|
||||
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
|
||||
// etc.) can't be trusted at all, so every entry comes back nil rather than
|
||||
// risk showing a target against the wrong lap.
|
||||
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
|
||||
steps := workout.FlattenSteps()
|
||||
if len(steps) != len(laps) {
|
||||
return make([]*garmin.WorkoutStep, len(laps))
|
||||
}
|
||||
out := make([]*garmin.WorkoutStep, len(steps))
|
||||
out := make([]*garmin.WorkoutStep, len(laps))
|
||||
|
||||
switch len(laps) - len(steps) {
|
||||
case 0, 1:
|
||||
for i := range steps {
|
||||
s := steps[i]
|
||||
out[i] = &s
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,35 @@ func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -309,7 +338,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
|
||||
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -329,7 +358,10 @@ func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
// Only one step for two recorded laps -- counts don't match.
|
||||
// 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)},
|
||||
@@ -351,10 +383,11 @@ func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
|
||||
if err != nil || len(laps) != 2 {
|
||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||
}
|
||||
for i, l := range laps {
|
||||
if l.TargetPaceLowMps != nil || l.TargetPaceHighMps != nil {
|
||||
t.Errorf("laps[%d] target should be nil on lap/step count mismatch, got low=%v high=%v", i, l.TargetPaceLowMps, l.TargetPaceHighMps)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user