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.
145 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|