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.
170 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|