feat: workout-suggester, weather-aware workouts from a local Gemma
Go + Templ + HTMX app that checks the weather (Open-Meteo, MET Norway fallback), scores run/ride/walk with plain rules, and has a local open-weight model (Gemma 4 E2B via any OpenAI-compatible server) write the plan. Routes are loops from your door (BRouter) plus signposted OpenStreetMap routes (Overpass), with GPX export.
This commit is contained in:
42 files changed
+7219
No files matched your search
@@ -0,0 +1,144 @@
|
||||
package coach
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// forecast builds a 24-hour forecast where every hour looks like h, then
|
||||
// lets the test tweak individual hours.
|
||||
func forecast(h weather.Hour, tweak func(i int, h *weather.Hour)) weather.Forecast {
|
||||
start := time.Date(2026, 10, 11, 14, 0, 0, 0, time.UTC)
|
||||
f := weather.Forecast{Sunset: start.Add(5 * time.Hour)}
|
||||
for i := range 24 {
|
||||
x := h
|
||||
x.Time = start.Add(time.Duration(i) * time.Hour)
|
||||
x.IsDay = i < 5
|
||||
if tweak != nil {
|
||||
tweak(i, &x)
|
||||
}
|
||||
f.Hours = append(f.Hours, x)
|
||||
}
|
||||
f.Now = f.Hours[0]
|
||||
return f
|
||||
}
|
||||
|
||||
var nice = weather.Hour{Temp: 15, Feels: 14, Wind: 10, Gusts: 20, Code: 1}
|
||||
|
||||
func TestAssess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
f weather.Forecast
|
||||
req Request
|
||||
pick Activity
|
||||
check func(t *testing.T, a Assessment)
|
||||
}{
|
||||
{
|
||||
name: "nice weather, anything goes",
|
||||
f: forecast(nice, nil),
|
||||
req: Request{Intensity: Moderate, Minutes: 60},
|
||||
pick: Run,
|
||||
},
|
||||
{
|
||||
name: "wanted activity is honoured when fine",
|
||||
f: forecast(nice, nil),
|
||||
req: Request{Activity: Ride, Intensity: Hard, Minutes: 90},
|
||||
pick: Ride,
|
||||
check: func(t *testing.T, a Assessment) {
|
||||
if a.TargetKm != 42 {
|
||||
t.Errorf("90 min hard ride target = %v km, want 42", a.TargetKm)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "windy: running beats cycling",
|
||||
f: forecast(weather.Hour{Temp: 12, Feels: 9, Wind: 30, Gusts: 50, Code: 3}, nil),
|
||||
req: Request{Intensity: Moderate, Minutes: 45},
|
||||
pick: Run,
|
||||
check: func(t *testing.T, a Assessment) {
|
||||
if ride, run := a.ScoreOf(Ride).Value, a.ScoreOf(Run).Value; ride >= run {
|
||||
t.Errorf("ride %d should score below run %d in strong gusts", ride, run)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "storm sends you inside",
|
||||
f: forecast(weather.Hour{Temp: 8, Feels: 3, Wind: 40, Gusts: 70, Precip: 3, PrecipProb: 95, Code: 63}, nil),
|
||||
req: Request{Activity: Run, Intensity: Moderate, Minutes: 60},
|
||||
pick: Indoor,
|
||||
},
|
||||
{
|
||||
name: "thunder is a hard no",
|
||||
f: forecast(weather.Hour{Temp: 22, Feels: 22, Code: 95}, nil),
|
||||
req: Request{Intensity: Easy, Minutes: 30},
|
||||
pick: Indoor,
|
||||
},
|
||||
{
|
||||
name: "rain now, dry later: suggests the later window",
|
||||
f: forecast(nice, func(i int, h *weather.Hour) {
|
||||
if i < 2 {
|
||||
h.Precip, h.PrecipProb, h.Code = 3, 90, 63
|
||||
}
|
||||
}),
|
||||
req: Request{Activity: Ride, Intensity: Moderate, Minutes: 60},
|
||||
pick: Indoor,
|
||||
check: func(t *testing.T, a Assessment) {
|
||||
if a.Later == nil || a.Later.Start.Hour() != 16 || a.Later.Activity != Ride {
|
||||
t.Errorf("later = %+v, want a ride at 16:00", a.Later)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hard intensity doesn't pick a stroll",
|
||||
f: forecast(weather.Hour{Temp: 15, Feels: 14, Wind: 10, Gusts: 20, Precip: 0.6, Code: 61}, nil),
|
||||
req: Request{Intensity: Hard, Minutes: 60},
|
||||
pick: Run,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := Assess(tt.f, tt.req)
|
||||
if a.Pick != tt.pick {
|
||||
t.Errorf("pick = %s, want %s (scores %+v)", a.Pick, tt.pick, a.Scores)
|
||||
}
|
||||
if tt.check != nil {
|
||||
tt.check(t, a)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarkIsMentioned(t *testing.T) {
|
||||
f := forecast(nice, nil)
|
||||
f.Hours = f.Hours[4:] // 18:00, the last daylight hour, then night
|
||||
f.Now = f.Hours[0]
|
||||
s := Assess(f, Request{Intensity: Easy, Minutes: 90}).ScoreOf(Run)
|
||||
if !strings.Contains(strings.Join(s.Reasons, ","), "dark") {
|
||||
t.Errorf("reasons %v should mention the dark", s.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetPace(t *testing.T) {
|
||||
km, pace := target(Run, Moderate, 60)
|
||||
if km != 10.5 || pace != "about 5:45 min/km" {
|
||||
t.Errorf("got %v %q", km, pace)
|
||||
}
|
||||
if km, _ := target(Indoor, Hard, 60); km != 0 {
|
||||
t.Errorf("indoor target = %v, want 0", km)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactsCarryTheNumbers(t *testing.T) {
|
||||
f := forecast(nice, nil)
|
||||
p := Plan{Place: "Gent", Forecast: f, Request: Request{Intensity: Moderate, Minutes: 60, Note: "sore knee"},
|
||||
Assessment: Assess(f, Request{Intensity: Moderate, Minutes: 60}), RoutesLoaded: true}
|
||||
got := p.SystemPrompt()
|
||||
for _, want := range []string{"Gent", "15°C", "sore knee", "The app's pick: running", "about 10.5 km", "Routes: none found nearby"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("system prompt lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user