diff --git a/cmd/routines.go b/cmd/routines.go index 553d5bd..5938676 100644 --- a/cmd/routines.go +++ b/cmd/routines.go @@ -252,9 +252,17 @@ func renderOnePreset(w io.Writer, p Preset, headingMarker string) error { name = fmt.Sprintf("%s (%s)", name, *ex.ExerciseNotes) } fmt.Fprintln(w, name) + // An exercise added to a routine but never given target sets/reps + // arrives as one (or more) all-zero placeholder sets. Rendering them + // as `0@0` / `0@+0` / `0:00` looks like nonsense data; suppress the + // set lines but keep the exercise name so the LLM-agent reader still + // sees that the exercise is part of the routine. + if allSetsAreZero(ex.SetsData) { + continue + } var lines []string for _, s := range ex.SetsData { - lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s)) + lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s.InputOne, s.InputTwo)) } // Compress consecutive identical lines into Nx... notation. for i := 0; i < len(lines); { @@ -273,30 +281,66 @@ func renderOnePreset(w io.Writer, p Preset, headingMarker string) error { return nil } -// fitdownSetLine is split out of workouts.printFitdown so routines can render -// the same notation without duplicating the type switch. Keeping it here -// rather than in workouts.go keeps the workouts file untouched, at the cost -// of a tiny bit of duplication of the WR/BR/AB/WD/DD/ND mapping. -func fitdownSetLine(exTypes string, s PresetSetData) string { +// allSetsAreZero reports whether every set in the slice has both inputs == 0. +// Used to detect placeholder sets in routines (an exercise added but never +// given a target) and an empty `ND` time entry in either renderer. +func allSetsAreZero(sets []PresetSetData) bool { + if len(sets) == 0 { + return true + } + for _, s := range sets { + a, _ := s.InputOne.Float64() + b, _ := s.InputTwo.Float64() + if a != 0 || b != 0 { + return false + } + } + return true +} + +// fitdownSetLine renders one set line in fitdown notation. Shared by the +// workouts and routines renderers — both receive the same `exerciseTypes` +// tag from Liftoff and emit identical notation. The fitdown spec only +// documents `${reps}@${poundage}` and `Nx` compression; everything else +// (WR/BR/AB/WD/DD/ND mapping, the `+`/`-` weight prefixes, the bodyweight +// simplification below) is our extension. +// +// Simplification: when a WR/BR/AB set's weight component is 0 — i.e. a +// bodyweight rep with no extra load or assistance — we drop the `@…` +// suffix and emit just the rep count. "5" is clearer than "5@+0". +func fitdownSetLine(exTypes string, inputOne, inputTwo json.Number) string { + weightZero := func() bool { + v, _ := inputOne.Float64() + return v == 0 + } switch exTypes { case "WR": - return fmt.Sprintf("%s@%s", s.InputTwo, s.InputOne) + if weightZero() { + return fmt.Sprintf("%s", inputTwo) + } + return fmt.Sprintf("%s@%s", inputTwo, inputOne) case "AB": - return fmt.Sprintf("%s@-%s", s.InputTwo, s.InputOne) + if weightZero() { + return fmt.Sprintf("%s", inputTwo) + } + return fmt.Sprintf("%s@-%s", inputTwo, inputOne) case "BR": - return fmt.Sprintf("%s@+%s", s.InputTwo, s.InputOne) + if weightZero() { + return fmt.Sprintf("%s", inputTwo) + } + return fmt.Sprintf("%s@+%s", inputTwo, inputOne) case "WD": - km, _ := s.InputTwo.Float64() - return fmt.Sprintf("%slb %.3fmi", s.InputOne, km/1.60934) + km, _ := inputTwo.Float64() + return fmt.Sprintf("%slb %.3fmi", inputOne, km/1.60934) case "DD": - secs, _ := s.InputTwo.Int64() - km, _ := s.InputOne.Float64() + secs, _ := inputTwo.Int64() + km, _ := inputOne.Float64() return fmt.Sprintf("%.2fmi %d:%02d", km/1.60934, secs/60, secs%60) case "ND": - secs, _ := s.InputTwo.Int64() + secs, _ := inputTwo.Int64() return fmt.Sprintf("%d:%02d", secs/60, secs%60) default: - return fmt.Sprintf("[%s] %s %s", exTypes, s.InputOne, s.InputTwo) + return fmt.Sprintf("[%s] %s %s", exTypes, inputOne, inputTwo) } } diff --git a/cmd/routines_test.go b/cmd/routines_test.go index f10f852..310d84f 100644 --- a/cmd/routines_test.go +++ b/cmd/routines_test.go @@ -172,6 +172,49 @@ func TestRenderOnePreset_ShowUsesH1(t *testing.T) { } } +func TestRenderOnePreset_AllZeroSetsSuppressed(t *testing.T) { + // An exercise added to a routine but never given target sets/reps + // arrives as one or more zero/zero placeholder sets. The exercise + // name should still appear (the LLM-agent reader cares that it's + // part of the routine) but the bogus "0@0" / "0:00" set lines must + // not. + p := Preset{ + Name: "Free Weights", + ExerciseData: []PresetExerciseData{ + { + ExerciseName: "Bench Press", + ExerciseTypes: "WR", + SetsData: []PresetSetData{{InputOne: json.Number("100"), InputTwo: json.Number("5")}}, + }, + { + ExerciseName: "Placeholder", + ExerciseTypes: "WR", + SetsData: []PresetSetData{{InputOne: json.Number("0"), InputTwo: json.Number("0")}}, + }, + { + ExerciseName: "Sci-fit Upper Body", + ExerciseTypes: "ND", + SetsData: []PresetSetData{{InputOne: json.Number("0"), InputTwo: json.Number("0")}}, + }, + }, + } + var buf bytes.Buffer + renderOnePreset(&buf, p, "##") + out := buf.String() + if !strings.Contains(out, "Bench Press\n5@100") { + t.Errorf("real exercise should render normally; got:\n%s", out) + } + if !strings.Contains(out, "Placeholder") { + t.Errorf("placeholder exercise name should still appear; got:\n%s", out) + } + if strings.Contains(out, "0@0") { + t.Errorf("zero/zero WR set should be suppressed; got:\n%s", out) + } + if strings.Contains(out, "0:00") { + t.Errorf("zero ND duration set should be suppressed; got:\n%s", out) + } +} + func TestPickPreset(t *testing.T) { resp := &presetsResponse{ PresetsWithoutFolder: []Preset{ diff --git a/cmd/workouts.go b/cmd/workouts.go index c813015..c4422ac 100644 --- a/cmd/workouts.go +++ b/cmd/workouts.go @@ -308,28 +308,7 @@ func renderWorkoutsFitdown(w io.Writer, posts []Post) error { var lines []string for _, s := range ex.SetsData { - var line string - switch ex.ExerciseTypes { - case "WR": - line = fmt.Sprintf("%s@%s", s.InputTwo, s.InputOne) - case "AB": - line = fmt.Sprintf("%s@-%s", s.InputTwo, s.InputOne) - case "BR": - line = fmt.Sprintf("%s@+%s", s.InputTwo, s.InputOne) - case "WD": - km, _ := s.InputTwo.Float64() - line = fmt.Sprintf("%slb %.3fmi", s.InputOne, km/1.60934) - case "DD": - secs, _ := s.InputTwo.Int64() - km, _ := s.InputOne.Float64() - line = fmt.Sprintf("%.2fmi %d:%02d", km/1.60934, secs/60, secs%60) - case "ND": - secs, _ := s.InputTwo.Int64() - line = fmt.Sprintf("%d:%02d", secs/60, secs%60) - default: - line = fmt.Sprintf("[%s] %s %s", ex.ExerciseTypes, s.InputOne, s.InputTwo) - } - lines = append(lines, line) + lines = append(lines, fitdownSetLine(ex.ExerciseTypes, s.InputOne, s.InputTwo)) } // Compress consecutive identical lines into Nx... notation diff --git a/cmd/workouts_test.go b/cmd/workouts_test.go index 55a1d3b..cd670db 100644 --- a/cmd/workouts_test.go +++ b/cmd/workouts_test.go @@ -66,23 +66,28 @@ func TestRenderWorkoutsFitdown_ExerciseNoteInParens(t *testing.T) { } } -// Set-line notation hasn't changed in this PR but is the load-bearing -// piece routines share with workouts (via the fitdownSetLine duplicate). -// One representative case per ExerciseType so a regression in either -// renderer surfaces in CI. +// Representative cases per ExerciseType so a regression in the shared +// fitdownSetLine surfaces in CI. The bodyweight cases (BR/AB with zero +// weight component) verify the @+0/@-0 simplification — the spec is silent +// on these so the rule lives in code, not docs. func TestRenderWorkoutsFitdown_SetNotation(t *testing.T) { cases := []struct { + name string exType string one, two string want string + notWant string // optional negative assertion }{ - {"WR", "100", "5", "5@100"}, // weight/reps - {"BR", "0", "10", "10@+0"}, // bodyweight reps - {"AB", "20", "10", "10@-20"}, // assisted bodyweight (minus) - {"ND", "0", "495", "8:15"}, // no-data duration + {"WR weighted", "WR", "100", "5", "5@100", ""}, + {"WR zero weight simplifies", "WR", "0", "5", "\n5\n", "5@0"}, + {"BR with extra weight", "BR", "12", "5", "5@+12", ""}, + {"BR zero added simplifies", "BR", "0", "10", "\n10\n", "10@+0"}, + {"AB with assistance", "AB", "20", "10", "10@-20", ""}, + {"AB zero assistance simplifies", "AB", "0", "10", "\n10\n", "10@-0"}, + {"ND duration", "ND", "0", "495", "8:15", ""}, } for _, c := range cases { - t.Run(c.exType, func(t *testing.T) { + t.Run(c.name, func(t *testing.T) { w := bareWorkout() w.ExerciseData[0].ExerciseTypes = c.exType w.ExerciseData[0].SetsData[0] = SetData{ @@ -91,8 +96,12 @@ func TestRenderWorkoutsFitdown_SetNotation(t *testing.T) { } var buf bytes.Buffer renderWorkoutsFitdown(&buf, []Post{w}) - if !strings.Contains(buf.String(), c.want) { - t.Errorf("%s: expected %q in output; got:\n%s", c.exType, c.want, buf.String()) + out := buf.String() + if !strings.Contains(out, c.want) { + t.Errorf("expected %q in output; got:\n%s", c.want, out) + } + if c.notWant != "" && strings.Contains(out, c.notWant) { + t.Errorf("did NOT want %q in output (should have been simplified); got:\n%s", c.notWant, out) } }) }