Feature 23: Meal photos for manually added dinners (#207)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #207.
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user