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

170 lines
5.5 KiB
Go

package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"git.b0b.be/bdeb/workout-suggester/internal/config"
)
// fakeUpstreams serves Open-Meteo (real Ghent fixtures), Overpass and an LLM
// that echoes the start of its system prompt, recording what it was sent.
func fakeUpstreams(t *testing.T, prompts *[]string) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/forecast":
http.ServeFile(w, r, "../weather/testdata/forecast.json")
case "/v1/search":
http.ServeFile(w, r, "../weather/testdata/geocode.json")
case "/overpass":
fmt.Fprint(w, `{"elements":[{"type":"relation","id":7,"tags":{"route":"foot","name":"Bourgoyenpad","roundtrip":"yes"},
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.775}]}]}]}`)
case "/brouter":
http.ServeFile(w, r, "../routes/testdata/brouter.json")
case "/llm/models":
fmt.Fprint(w, `{"data":[{"id":"fake-gemma"}]}`)
case "/llm/chat/completions":
var req struct {
Messages []struct{ Role, Content string }
}
json.NewDecoder(r.Body).Decode(&req)
*prompts = append(*prompts, req.Messages[0].Content+"\n---\n"+req.Messages[len(req.Messages)-1].Content)
w.Header().Set("Content-Type", "text/event-stream")
for _, part := range []string{"**Go for a run** ", "<script>x</script>"} {
b, _ := json.Marshal(part)
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\n", b)
}
fmt.Fprint(w, "data: [DONE]\n\n")
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func newTestApp(t *testing.T, prompts *[]string) (*httptest.Server, *http.Client) {
t.Helper()
up := fakeUpstreams(t, prompts)
cfg := config.Config{
BaseURL: up.URL + "/llm",
Timeout: 10 * time.Second,
OverpassURL: up.URL + "/overpass",
BRouterURL: up.URL + "/brouter",
ForecastURL: up.URL + "/v1/forecast",
GeocodeURL: up.URL + "/v1/search",
}
app := httptest.NewServer(New(cfg, "", slog.New(slog.NewTextHandler(io.Discard, nil))).Handler())
t.Cleanup(app.Close)
jar, _ := cookiejar.New(nil)
return app, &http.Client{Jar: jar}
}
var streamURL = regexp.MustCompile(`sse-connect="(/stream/[0-9a-f]+)"`)
func do(t *testing.T, c *http.Client, method, u string, form url.Values) string {
t.Helper()
var resp *http.Response
var err error
if method == http.MethodPost {
resp, err = c.PostForm(u, form)
} else {
resp, err = c.Get(u)
}
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
func TestPlanFlow(t *testing.T) {
var prompts []string
app, c := newTestApp(t, &prompts)
page := do(t, c, "GET", app.URL+"/", nil)
if !strings.Contains(page, "Get me outside") {
t.Fatal("index lacks the form")
}
plan := do(t, c, "POST", app.URL+"/plan", url.Values{
"place": {"Gent"}, "activity": {"run"}, "intensity": {"easy"}, "minutes": {"30"}, "note": {"bad knee"},
})
for _, want := range []string{"Ghent, Belgium", "15°", "great", `hx-get="/routes"`} {
if !strings.Contains(plan, want) {
t.Errorf("plan lacks %q", want)
}
}
m := streamURL.FindStringSubmatch(plan)
if m == nil {
t.Fatal("plan has no streaming reply")
}
// The first reply waits for routes, so the model sees them.
stream := do(t, c, "GET", app.URL+m[1], nil)
if !strings.Contains(stream, "event: done") || !strings.Contains(stream, "<strong>Go for a run</strong>") {
t.Errorf("stream = %s", stream)
}
if strings.Contains(stream, "<script>") {
t.Error("model output was not escaped")
}
if len(prompts) != 1 {
t.Fatalf("llm called %d times", len(prompts))
}
for _, want := range []string{"Ghent, Belgium", `"bad knee"`, `"Bourgoyenpad": signposted`, "a loop from the user's door", "m of climbing", "The app's pick: running", "What should I do right now?"} {
if !strings.Contains(prompts[0], want) {
t.Errorf("prompt lacks %q:\n%s", want, prompts[0])
}
}
// Reconnecting to a finished reply doesn't generate it again.
do(t, c, "GET", app.URL+m[1], nil)
if len(prompts) != 1 {
t.Error("reply was generated twice")
}
routes := do(t, c, "GET", app.URL+"/routes", nil)
if !strings.Contains(routes, "Bourgoyenpad") || !strings.Contains(routes, "/gpx/7") || !strings.Contains(routes, "from your door") || !strings.Contains(routes, "/gpx/-1") {
t.Errorf("routes card = %s", routes)
}
if gpx := do(t, c, "GET", app.URL+"/gpx/7", nil); !strings.Contains(gpx, "<trkpt") {
t.Errorf("gpx = %s", gpx)
}
// Follow-ups keep the plan and get the brevity hint.
ask := do(t, c, "POST", app.URL+"/ask", url.Values{"message": {"make it harder"}})
m = streamURL.FindStringSubmatch(ask)
if m == nil || !strings.Contains(ask, "make it harder") {
t.Fatalf("ask = %s", ask)
}
do(t, c, "GET", app.URL+m[1], nil)
if len(prompts) != 2 || !strings.Contains(prompts[1], "make it harder\n\n(Answer in a few short lines") {
t.Errorf("follow-up prompt = %q", prompts[len(prompts)-1])
}
// Reloading the page shows the plan and the chat again.
page = do(t, c, "GET", app.URL+"/", nil)
if !strings.Contains(page, "make it harder") || !strings.Contains(page, "Ghent, Belgium") {
t.Error("reload lost the plan")
}
}
func TestPlanNeedsAPlace(t *testing.T) {
var prompts []string
app, c := newTestApp(t, &prompts)
if got := do(t, c, "POST", app.URL+"/plan", url.Values{"minutes": {"60"}}); !strings.Contains(got, "Where are you?") {
t.Errorf("got %s", got)
}
}