Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
426 lines
15 KiB
Go
426 lines
15 KiB
Go
package dinners_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"slices"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
// failedDinner stores a Regular named name whose grounding failed with
|
|
// no_recipe_found, without a photo or recipe_url, and returns its id.
|
|
func (e *env) failedDinner(t *testing.T, name string) string {
|
|
t.Helper()
|
|
q := db.New(e.st.Writer)
|
|
now := store.Now().String()
|
|
row, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
|
|
ID: store.NewID(),
|
|
Name: name,
|
|
NormalizedName: harness.Normalize(name),
|
|
Status: dinners.StatusRegular,
|
|
Origin: "regular",
|
|
GroundingStatus: dinners.GroundingPending,
|
|
UpdatedBy: sql.NullString{String: member, Valid: true},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := q.FailPendingDinnerGrounding(t.Context(), db.FailPendingDinnerGroundingParams{
|
|
GroundingError: sql.NullString{String: dinners.ReasonNoRecipe, Valid: true},
|
|
UpdatedAt: now,
|
|
ID: row.ID,
|
|
GroundingRevision: row.GroundingRevision,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return row.ID
|
|
}
|
|
|
|
func outcome(id, out string, reason *string) dinners.BackfillOutcome {
|
|
return dinners.BackfillOutcome{DinnerID: id, Outcome: out, Reason: reason}
|
|
}
|
|
|
|
func checkOutcomes(t *testing.T, got, want []dinners.BackfillOutcome) {
|
|
t.Helper()
|
|
gj, _ := json.Marshal(got)
|
|
wj, _ := json.Marshal(want)
|
|
if string(gj) != string(wj) {
|
|
t.Fatalf("outcomes = %s, want %s", gj, wj)
|
|
}
|
|
}
|
|
|
|
func TestBackfillGroundsFirstCandidateWithAPhoto(t *testing.T) {
|
|
t.Parallel()
|
|
e := newUnstartedEnv(t)
|
|
// curryA ranks first but its photo is gone; curryB's photo caches.
|
|
delete(e.images.JPEGs, "https://a.example/curry.jpg")
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
id := e.failedDinner(t, "Chicken curry")
|
|
|
|
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(id, dinners.BackfillGrounded, nil)})
|
|
|
|
row := e.dinner(t, id)
|
|
if row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid || !row.RecipeID.Valid || row.GroundingError.Valid {
|
|
t.Fatalf("dinner = %+v, want grounded with a photo and no error", row)
|
|
}
|
|
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.SourceUrl != curryB || rec.SiteName.String != "B Kitchen" || rec.PhotoID != row.PhotoID {
|
|
t.Errorf("recipe = %s from %q photo %s, want %s from B Kitchen photo %s", rec.SourceUrl, rec.SiteName.String, rec.PhotoID.String, curryB, row.PhotoID.String)
|
|
}
|
|
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
|
|
t.Errorf("searched %q, want the dinner's name", got)
|
|
}
|
|
}
|
|
|
|
func TestBackfillReusesCachedRecipeAndPhoto(t *testing.T) {
|
|
t.Parallel()
|
|
e := newUnstartedEnv(t)
|
|
e.search.Results["Curry chicken"] = []recipes.Candidate{{URL: curryA}}
|
|
// One reply: the second dinner must reuse the recipe the first stored.
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
first := e.failedDinner(t, "Chicken curry")
|
|
second := e.failedDinner(t, "Curry chicken")
|
|
|
|
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkOutcomes(t, got, []dinners.BackfillOutcome{
|
|
outcome(first, dinners.BackfillGrounded, nil),
|
|
outcome(second, dinners.BackfillGrounded, nil),
|
|
})
|
|
a, b := e.dinner(t, first), e.dinner(t, second)
|
|
if a.RecipeID != b.RecipeID || a.PhotoID != b.PhotoID || !a.PhotoID.Valid {
|
|
t.Errorf("dinners grounded in recipe %s photo %s and recipe %s photo %s, want the same cached ones",
|
|
a.RecipeID.String, a.PhotoID.String, b.RecipeID.String, b.PhotoID.String)
|
|
}
|
|
if n := len(e.model.Requests()); n != 1 {
|
|
t.Errorf("normalize_recipe ran %d times, want 1", n)
|
|
}
|
|
if n := slices.Index(e.pages.Fetched(), curryA); n < 0 || slices.Contains(e.pages.Fetched()[n+1:], curryA) {
|
|
t.Errorf("fetched %q, want %s fetched once", e.pages.Fetched(), curryA)
|
|
}
|
|
}
|
|
|
|
func TestBackfillIsolatesEachDinnersFailure(t *testing.T) {
|
|
t.Parallel()
|
|
e := newUnstartedEnv(t)
|
|
e.search.Fail = map[string]error{"Broken search": errors.New("search provider down")}
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
// Listed by normalized name: both failures come before the curry.
|
|
broken := e.failedDinner(t, "Broken search")
|
|
curry := e.failedDinner(t, "Chicken curry")
|
|
absent := e.failedDinner(t, "Abalone surprise")
|
|
|
|
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkOutcomes(t, got, []dinners.BackfillOutcome{
|
|
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
|
|
outcome(broken, dinners.BackfillFailed, ptr(dinners.ReasonSearchFailed)),
|
|
outcome(curry, dinners.BackfillGrounded, nil),
|
|
})
|
|
for id, reason := range map[string]string{absent: dinners.ReasonNoRecipe, broken: dinners.ReasonSearchFailed} {
|
|
row := e.dinner(t, id)
|
|
if row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != reason || row.PhotoID.Valid {
|
|
t.Errorf("dinner %s = %+v, want failed with %s and no photo", row.Name, row, reason)
|
|
}
|
|
}
|
|
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid {
|
|
t.Errorf("curry = %+v, want grounded with a photo", row)
|
|
}
|
|
}
|
|
|
|
func TestBackfillSkipsDinnersNotMissingAPhoto(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.waitJob(t, *c.GroundingJobID)
|
|
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingGrounded {
|
|
t.Fatalf("dinner = %+v, want grounded", row)
|
|
}
|
|
// A failed dinner with a pasted link keeps that link for a fix by hand.
|
|
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.waitJob(t, *linked.GroundingJobID)
|
|
if row := e.dinner(t, linked.ID); row.GroundingStatus != dinners.GroundingFailed {
|
|
t.Fatalf("linked dinner = %+v, want failed", row)
|
|
}
|
|
archived := e.failedDinner(t, "Old stew")
|
|
if _, err := e.svc.Archive(t.Context(), archived, member); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
searched := len(e.search.Queries())
|
|
|
|
id, err := e.svc.StartPhotoBackfill(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
job := e.waitJob(t, id)
|
|
if job.Kind != dinners.KindBackfillPhotos || string(job.Result) != `{"dinners":[]}` {
|
|
t.Fatalf("job = %+v (result %s), want a backfill with no dinners", job, job.Result)
|
|
}
|
|
if got := e.search.Queries()[searched:]; len(got) != 0 {
|
|
t.Errorf("backfill searched %q, want nothing", got)
|
|
}
|
|
}
|
|
|
|
// TestPhotoBackfillEligibleIsWhatTheBackfillLists holds the API's
|
|
// photo_backfill_eligible to ListPhotoBackfillDinners for a dinner the
|
|
// backfill grounds and for each kind it never does.
|
|
func TestPhotoBackfillEligibleIsWhatTheBackfillLists(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
grounded, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.waitJob(t, *grounded.GroundingJobID)
|
|
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.waitJob(t, *linked.GroundingJobID)
|
|
failed := e.failedDinner(t, "Abalone surprise")
|
|
archived, err := e.svc.Archive(t.Context(), e.failedDinner(t, "Old stew"), member)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
shown := map[string]dinners.Dinner{archived.ID: archived}
|
|
regulars, err := e.svc.List(t.Context(), dinners.StatusRegular)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, d := range regulars {
|
|
shown[d.ID] = d
|
|
}
|
|
q := db.New(e.st.Reader)
|
|
for id, want := range map[string]bool{grounded.ID: false, linked.ID: false, failed: true, archived.ID: false} {
|
|
listed, err := q.ListPhotoBackfillDinners(t.Context(), sql.NullString{String: id, Valid: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d, ok := shown[id]
|
|
if !ok {
|
|
t.Fatalf("dinner %s not shown", id)
|
|
}
|
|
if got := len(listed) == 1; got != want || d.PhotoBackfillEligible != want {
|
|
t.Errorf("%s: listed %t, photo_backfill_eligible %t, want both %t", d.Name, got, d.PhotoBackfillEligible, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackfillOfOneDinnerGroundsOnlyIt(t *testing.T) {
|
|
t.Parallel()
|
|
e := newUnstartedEnv(t)
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
curry := e.failedDinner(t, "Chicken curry")
|
|
other := e.failedDinner(t, "Abalone surprise")
|
|
|
|
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: curry})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(curry, dinners.BackfillGrounded, nil)})
|
|
if row := e.dinner(t, other); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
|
|
t.Errorf("other dinner = %+v, want left failed", row)
|
|
}
|
|
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
|
|
t.Errorf("searched %q, want only the dinner's name", got)
|
|
}
|
|
}
|
|
|
|
func TestBackfillLeavesDinnerChangedWhileGrounding(t *testing.T) {
|
|
t.Parallel()
|
|
e := newUnstartedEnv(t)
|
|
id := e.failedDinner(t, "Chicken curry")
|
|
gate := make(chan struct{})
|
|
e.search.Gate = gate
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
|
|
done := make(chan []dinners.BackfillOutcome)
|
|
go func() {
|
|
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: id})
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
done <- got
|
|
}()
|
|
// The backfill listed the dinner and waits at its search.
|
|
for len(e.search.Queries()) == 0 {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken korma")}, member); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
close(gate)
|
|
checkOutcomes(t, <-done, []dinners.BackfillOutcome{outcome(id, dinners.BackfillSkipped, nil)})
|
|
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
|
|
t.Errorf("dinner = %+v, want left failed without a photo", row)
|
|
}
|
|
}
|
|
|
|
// triggerAtOnce calls svc.StartPhotoBackfill from n goroutines released
|
|
// together and returns the job ids they got.
|
|
func triggerAtOnce(t *testing.T, svc *dinners.Service, n int) []string {
|
|
t.Helper()
|
|
ids := make([]string, n)
|
|
errs := make([]error, n)
|
|
release := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
for i := range n {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-release
|
|
ids[i], errs[i] = svc.StartPhotoBackfill(t.Context())
|
|
}()
|
|
}
|
|
close(release)
|
|
wg.Wait()
|
|
if err := errors.Join(errs...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// backfillJobs counts the KindBackfillPhotos jobs in st.
|
|
func backfillJobs(t *testing.T, st *store.Store) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := st.Reader.QueryRowContext(t.Context(), `SELECT count(*) FROM jobs WHERE kind = ?`, dinners.KindBackfillPhotos).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestStartPhotoBackfillIsSingleFlight(t *testing.T) {
|
|
t.Parallel()
|
|
const triggers = 8
|
|
dataDir := t.TempDir()
|
|
st := openStore(t, dataDir)
|
|
e := newUnstartedEnvOn(t, st, dataDir)
|
|
e.failedDinner(t, "Chicken curry")
|
|
gate := make(chan struct{})
|
|
e.search.Gate = gate
|
|
|
|
// Queued: simultaneous triggers all get the one pass they enqueued.
|
|
ids := triggerAtOnce(t, e.svc, triggers)
|
|
first := ids[0]
|
|
for _, id := range ids {
|
|
if id != first {
|
|
t.Fatalf("simultaneous triggers = %q, want one job id", ids)
|
|
}
|
|
}
|
|
if n := backfillJobs(t, st); n != 1 {
|
|
t.Fatalf("%d backfill jobs after simultaneous triggers, want 1", n)
|
|
}
|
|
|
|
// After a restart the queued pass is still the one: the claim is the
|
|
// stored job, not the process's memory.
|
|
if err := st.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st = openStore(t, dataDir)
|
|
restarted := newUnstartedEnvOn(t, st, dataDir)
|
|
restarted.search.Gate = gate
|
|
restarted.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
if again, err := restarted.svc.StartPhotoBackfill(t.Context()); err != nil || again != first {
|
|
t.Fatalf("trigger after restart = %q, %v; want the queued %q", again, err, first)
|
|
}
|
|
|
|
// Running: the pass waits at its search, and simultaneous triggers all
|
|
// return it.
|
|
restarted.start(t)
|
|
for len(restarted.search.Queries()) == 0 {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
for _, id := range triggerAtOnce(t, restarted.svc, triggers) {
|
|
if id != first {
|
|
t.Fatalf("trigger while running = %q, want the running %q", id, first)
|
|
}
|
|
}
|
|
if n := backfillJobs(t, st); n != 1 {
|
|
t.Fatalf("%d backfill jobs, want 1", n)
|
|
}
|
|
|
|
// Finished: the next trigger starts a new pass.
|
|
close(gate)
|
|
if job := restarted.waitJob(t, first); job.Status != jobs.StatusSucceeded {
|
|
t.Fatalf("job = %+v (result %s), want succeeded", job, job.Result)
|
|
}
|
|
next, err := restarted.svc.StartPhotoBackfill(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if next == first {
|
|
t.Fatalf("trigger after the pass finished = %q, want a new job", next)
|
|
}
|
|
restarted.waitJob(t, next)
|
|
}
|
|
|
|
func TestBackfillFailsJobWhenAWriteFails(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
absent := e.failedDinner(t, "Abalone surprise")
|
|
curry := e.failedDinner(t, "Chicken curry")
|
|
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_grounded BEFORE UPDATE OF grounding_status ON dinners
|
|
WHEN NEW.id = '`+curry+`' AND NEW.grounding_status = 'grounded'
|
|
BEGIN SELECT RAISE(ABORT, 'grounded refused'); END`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
id, err := e.svc.StartPhotoBackfill(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
job := e.waitJob(t, id)
|
|
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != jobs.CodeInternal {
|
|
t.Fatalf("job = %+v, want failed with %s", job, jobs.CodeInternal)
|
|
}
|
|
var got struct {
|
|
Dinners []dinners.BackfillOutcome `json:"dinners"`
|
|
}
|
|
if err := json.Unmarshal(job.Result, &got); err != nil {
|
|
t.Fatalf("result %s: %v", job.Result, err)
|
|
}
|
|
// The other dinner was still tried and recorded.
|
|
checkOutcomes(t, got.Dinners, []dinners.BackfillOutcome{
|
|
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
|
|
outcome(curry, dinners.BackfillFailed, ptr(dinners.ReasonGroundingError)),
|
|
})
|
|
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
|
|
t.Errorf("curry = %+v, want left failed without a photo", row)
|
|
}
|
|
}
|