314 lines
11 KiB
Go
314 lines
11 KiB
Go
package shopping_test
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"database/sql"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"slices"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||
|
|
)
|
||
|
|
|
||
|
|
// titled is archive_title's reply giving title.
|
||
|
|
func titled(title string) llm.GenerateResult {
|
||
|
|
js := fmt.Sprintf(`{"title":%q}`, title)
|
||
|
|
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
||
|
|
}
|
||
|
|
|
||
|
|
// titleWeek stores three dinners, one with no recipe, and plans them on
|
||
|
|
// the week's first three days; its job runner is started.
|
||
|
|
func titleWeek(t *testing.T) *env {
|
||
|
|
t.Helper()
|
||
|
|
e := newEnv(t)
|
||
|
|
e.start(t)
|
||
|
|
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||
|
|
e.dinner(t, "Fish Tacos")
|
||
|
|
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||
|
|
e.plan(t, "Pad Thai", "Fish Tacos", "Beef Chili")
|
||
|
|
return e
|
||
|
|
}
|
||
|
|
|
||
|
|
// storedTitle returns the week's list's stored title; nil when NULL.
|
||
|
|
func (e *env) storedTitle(t *testing.T) *string {
|
||
|
|
t.Helper()
|
||
|
|
var title sql.NullString
|
||
|
|
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&title); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if !title.Valid {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return &title.String
|
||
|
|
}
|
||
|
|
|
||
|
|
// runStatuses returns the status of every archive_title run recorded in
|
||
|
|
// ai_runs, oldest first.
|
||
|
|
func (e *env) runStatuses(t *testing.T) []string {
|
||
|
|
t.Helper()
|
||
|
|
rows, err := e.st.Reader.Query(`SELECT status FROM ai_runs WHERE skill = 'archive_title' ORDER BY created_at, rowid`)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
var out []string
|
||
|
|
for rows.Next() {
|
||
|
|
var s string
|
||
|
|
if err := rows.Scan(&s); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
out = append(out, s)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func wantTitle(t *testing.T, what string, got *string, want string) {
|
||
|
|
t.Helper()
|
||
|
|
if got == nil || *got != want {
|
||
|
|
t.Errorf("%s = %v, want %q", what, deref(got), want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func wantUntitled(t *testing.T, what string, got *string) {
|
||
|
|
t.Helper()
|
||
|
|
if got != nil {
|
||
|
|
t.Errorf("%s = %q, want none", what, *got)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func deref(s *string) any {
|
||
|
|
if s == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return *s
|
||
|
|
}
|
||
|
|
|
||
|
|
// wantJob fails t unless job ended with status and, when failed, code.
|
||
|
|
func wantJob(t *testing.T, job jobs.Job, status jobs.Status, code string) {
|
||
|
|
t.Helper()
|
||
|
|
if job.Status != status || (code != "" && (job.Error == nil || *job.Error != code)) {
|
||
|
|
t.Errorf("title job = %s %v, want %s %s", job.Status, deref(job.Error), status, code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestGenerateTitlesNewList: a new list is returned untitled with a title
|
||
|
|
// job, which titles it by archive_title from the plan's dinners in day
|
||
|
|
// order, a dinner with no group included, and stores the title.
|
||
|
|
func TestGenerateTitlesNewList(t *testing.T) {
|
||
|
|
e := titleWeek(t)
|
||
|
|
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||
|
|
|
||
|
|
g := e.generate(t)
|
||
|
|
wantUntitled(t, "returned title", g.List.Title)
|
||
|
|
job := e.waitTitle(t, g)
|
||
|
|
|
||
|
|
wantJob(t, job, jobs.StatusSucceeded, "")
|
||
|
|
if want := `{"list_id":"` + g.List.ID + `","title":"Noodles, Tacos and Chili"}`; string(job.Result) != want {
|
||
|
|
t.Errorf("job result = %s, want %s", job.Result, want)
|
||
|
|
}
|
||
|
|
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||
|
|
got, err := e.svc.Get(t.Context(), week)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
wantTitle(t, "title read back", got.Title, "Noodles, Tacos and Chili")
|
||
|
|
reqs := e.model.Requests()
|
||
|
|
if len(reqs) != 1 {
|
||
|
|
t.Fatalf("%d model calls, want 1", len(reqs))
|
||
|
|
}
|
||
|
|
want := "Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili"
|
||
|
|
if user := reqs[0].Messages[0].Content; user != want {
|
||
|
|
t.Errorf("user message = %q, want %q", user, want)
|
||
|
|
}
|
||
|
|
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusOK) {
|
||
|
|
t.Errorf("archive_title runs = %q, want one ok", s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestGenerateTitleTooLongRepaired: a title over 48 characters is repaired
|
||
|
|
// through the repair turn, and the repaired title is stored, not a
|
||
|
|
// truncation of the first.
|
||
|
|
func TestGenerateTitleTooLongRepaired(t *testing.T) {
|
||
|
|
e := titleWeek(t)
|
||
|
|
long := "Pad Thai Monday, Fish Taco Tuesday and Chili Wednesday"
|
||
|
|
e.model.QueueGenerate(titled(long), titled("Noodles, Tacos and Chili"))
|
||
|
|
|
||
|
|
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusSucceeded, "")
|
||
|
|
|
||
|
|
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||
|
|
reqs := e.model.Requests()
|
||
|
|
if len(reqs) != 2 || !strings.Contains(reqs[1].Messages[2].Content, "has 54 characters; shorten it to at most 48") {
|
||
|
|
t.Fatalf("model calls = %+v, want a repair turn asking for a shorter title", reqs)
|
||
|
|
}
|
||
|
|
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusRepaired) {
|
||
|
|
t.Errorf("archive_title runs = %q, want one repaired", s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestGenerateTitleFailureKeepsList: archive_title giving an emoji twice
|
||
|
|
// fails, and the list is still made, whole and untitled, with the failure
|
||
|
|
// logged, recorded in ai_runs and failing the title job.
|
||
|
|
func TestGenerateTitleFailureKeepsList(t *testing.T) {
|
||
|
|
e := titleWeek(t)
|
||
|
|
e.model.QueueGenerate(titled("Taco Week 🌮"), titled("Taco Week 🌮"))
|
||
|
|
|
||
|
|
g, err := e.svc.Generate(t.Context(), week)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Generate = %v, want the list made though its title failed", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
want := []string{
|
||
|
|
"0 2026-10-12 Pad Thai: 0 rice noodles [8 oz] needed",
|
||
|
|
"1 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed",
|
||
|
|
}
|
||
|
|
wantLines(t, "list", lines(g.List), want)
|
||
|
|
wantLines(t, "stored list", e.stored(t), want)
|
||
|
|
if len(g.Skipped) != 1 || g.Skipped[0].DinnerName != "Fish Tacos" {
|
||
|
|
t.Errorf("skipped = %+v, want Fish Tacos", g.Skipped)
|
||
|
|
}
|
||
|
|
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||
|
|
wantUntitled(t, "returned title", g.List.Title)
|
||
|
|
wantUntitled(t, "stored title", e.storedTitle(t))
|
||
|
|
if g.List.Status != shopping.StatusCurrent {
|
||
|
|
t.Errorf("status = %q, want current", g.List.Status)
|
||
|
|
}
|
||
|
|
log := e.log.String()
|
||
|
|
for _, part := range []string{"level=WARN", "Supply run left untitled", "week=2026-10-12", "has an emoji (U+1F32E)"} {
|
||
|
|
if !strings.Contains(log, part) {
|
||
|
|
t.Errorf("log lacks %q:\n%s", part, log)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusBadOutput) {
|
||
|
|
t.Errorf("archive_title runs = %q, want one bad_output", s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestGenerateRetriesTitle: a list left untitled is titled by the week's
|
||
|
|
// next generation, Generate or GeneratePushed; once titled, a remake keeps
|
||
|
|
// its title and asks for none.
|
||
|
|
func TestGenerateRetriesTitle(t *testing.T) {
|
||
|
|
e := titleWeek(t)
|
||
|
|
e.model.QueueGenerate(llm.GenerateResult{Err: fmt.Errorf("%w: model down", llm.ErrProvider)})
|
||
|
|
first := e.generate(t)
|
||
|
|
wantJob(t, e.waitTitle(t, first), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||
|
|
wantUntitled(t, "first title", e.storedTitle(t))
|
||
|
|
if !strings.Contains(e.log.String(), "model down") {
|
||
|
|
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||
|
|
}
|
||
|
|
|
||
|
|
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||
|
|
plan, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
second, err := e.svc.GeneratePushed(t.Context(), week, plan.Revision)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if second.List.ID != first.List.ID {
|
||
|
|
t.Fatalf("second generation made list %s, want list %s again", second.List.ID, first.List.ID)
|
||
|
|
}
|
||
|
|
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
|
||
|
|
wantTitle(t, "retried title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||
|
|
|
||
|
|
e.swap(t, "2026-10-12", "Beef Chili")
|
||
|
|
third := e.generate(t)
|
||
|
|
wantTitle(t, "title after a remake", third.List.Title, "Noodles, Tacos and Chili")
|
||
|
|
if third.TitleJobID != nil {
|
||
|
|
t.Errorf("a remake of a titled list enqueued title job %s", *third.TitleJobID)
|
||
|
|
}
|
||
|
|
if n := len(e.model.Requests()); n != 2 {
|
||
|
|
t.Errorf("%d model calls, want 2: a titled list is not titled again", n)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestGenerateTitleNotStoredOnFrozenList: a list frozen into the Supply
|
||
|
|
// archive while its title is written keeps no title, and the failure is
|
||
|
|
// logged.
|
||
|
|
func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
|
||
|
|
e := titleWeek(t)
|
||
|
|
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||
|
|
e.beforeTitle = func() {
|
||
|
|
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||
|
|
t.Error(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||
|
|
|
||
|
|
wantUntitled(t, "stored title", e.storedTitle(t))
|
||
|
|
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||
|
|
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestGenerateTitleOverlapAcrossRename: a dinner renamed between two
|
||
|
|
// generations of an untitled list changes the names to title but not the
|
||
|
|
// plan revision. The two title jobs, running at once, each send the model
|
||
|
|
// their own names; neither is given the other's title.
|
||
|
|
func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
||
|
|
e := titleWeek(t)
|
||
|
|
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"), titled("Drunken Noodles Week"))
|
||
|
|
// Each model call waits for the other, so the runs overlap.
|
||
|
|
var arrived sync.WaitGroup
|
||
|
|
arrived.Add(2)
|
||
|
|
e.beforeModel = func(llm.Request) {
|
||
|
|
arrived.Done()
|
||
|
|
done := make(chan struct{})
|
||
|
|
go func() { arrived.Wait(); close(done) }()
|
||
|
|
select {
|
||
|
|
case <-done:
|
||
|
|
case <-time.After(5 * time.Second):
|
||
|
|
t.Error("the title runs did not overlap: one model call never came")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
revision := func() int64 {
|
||
|
|
p, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return p.Revision
|
||
|
|
}
|
||
|
|
|
||
|
|
before := revision()
|
||
|
|
first := e.generate(t)
|
||
|
|
if _, err := e.st.Writer.Exec(`UPDATE dinners SET name = 'Drunken Noodles', normalized_name = 'drunken noodles' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
second := e.generate(t)
|
||
|
|
if revision() != before {
|
||
|
|
t.Fatalf("plan revision moved from %d to %d: the rename must leave it", before, revision())
|
||
|
|
}
|
||
|
|
|
||
|
|
wantJob(t, e.waitTitle(t, first), jobs.StatusSucceeded, "")
|
||
|
|
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
|
||
|
|
var users []string
|
||
|
|
for _, r := range e.model.Requests() {
|
||
|
|
users = append(users, r.Messages[0].Content)
|
||
|
|
}
|
||
|
|
slices.Sort(users)
|
||
|
|
want := []string{
|
||
|
|
"Title the week whose dinners are, in day order from Monday:\n- Drunken Noodles\n- Fish Tacos\n- Beef Chili",
|
||
|
|
"Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili",
|
||
|
|
}
|
||
|
|
if !slices.Equal(users, want) {
|
||
|
|
t.Errorf("model was sent %q, want each generation's own names %q", users, want)
|
||
|
|
}
|
||
|
|
if s := e.runStatuses(t); len(s) != 2 {
|
||
|
|
t.Errorf("archive_title runs = %q, want two", s)
|
||
|
|
}
|
||
|
|
if got := e.storedTitle(t); got == nil || (*got != "Noodles, Tacos and Chili" && *got != "Drunken Noodles Week") {
|
||
|
|
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||
|
|
}
|
||
|
|
}
|