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