Files
bdeb1337 d7342d6096 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.
2026-10-11 19:29:58 +02:00

145 lines
4.2 KiB
Go

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)
}
}
}