Files
MealQuest/internal/recipes/recipestest/recipestest.go
T
dev_agentandClaude Opus 5.5 dff58d491f
CI / ci (push) Successful in 2m36s
CI / ci (pull_request) Successful in 2m22s
Fix review findings R1–R3 on Regular grounding and name folding
R1: Migration 0010 now writes revision 0 into every ground_dinner job
payload that has none. Jobs queued before the upgrade carry only
dinner_id, so the boot cleanup, which matches a pending dinner to its job
by revision, failed those dinners as interrupted while their jobs were
still queued. The migration runs before the job runner starts. A new test
migrates a database to 0009, adds a pending Regular with a legacy queued
job, upgrades and boots, and checks the dinner stays pending and is then
grounded.

R2: Archiving a dinner, or renaming a pending one, now cancels the
grounding of any ground_dinner job running for it at an older revision,
so no further search, page fetch, photo download or model call is made.
The job is registered before it reads the dinner, so an archive or rename
committed after that read always finds it. A stopped job succeeds and
reports the dinner's current grounding status. The archive and rename
tests now check that the job ends while its search is still held, with
no page fetched and no model call for the stale name.

R3: gen_folds.py writes web/src/dinners/letterFolds.json from the same
table as folds_table.go, and names.ts folds through it instead of a
hand-copied subset. names.ts also lowercases letter by letter, as Go's
strings.ToLower does, rather than by context. TestLetterFoldsMatchPWA
fails when the two tables differ. The shared cases add "Ƈhili", "Ꞔarbanzo
Stew" and "ΜΟΥΣΑΚΑΣ" (final sigma).

Maestro task 5037ff86-181d-448e-927b-c2fd2258a25d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:40:34 -04:00

184 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package recipestest provides fakes for tests that ground dinners outside
// package recipes: a search, page and image fetchers serving fixtures, and
// a Grounder over them that normalizes through the real harness and
// normalize_recipe skill with a scripted model.
package recipestest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/color"
"image/jpeg"
"sync"
"testing"
"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/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Search is a recipes.SearchSource and recipes.SearchProvider answering
// each query from Results. Err, when set, fails every search. Gate, when
// set, makes every search wait for it to close.
type Search struct {
Results map[string][]recipes.Candidate
Err error
Gate chan struct{}
mu sync.Mutex
queries []string
}
func (s *Search) Provider(context.Context) (recipes.SearchProvider, error) { return s, nil }
func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Candidate, error) {
s.mu.Lock()
s.queries = append(s.queries, query)
s.mu.Unlock()
if s.Gate != nil {
select {
case <-s.Gate:
case <-ctx.Done():
return nil, ctx.Err()
}
}
if s.Err != nil {
return nil, s.Err
}
c := s.Results[query]
return c[:min(n, len(c))], nil
}
// Queries returns every query searched, in order.
func (s *Search) Queries() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.queries...)
}
// Pages is a recipes.PageFetcher serving Bodies by URL; any other URL is a
// 404. Gate, when set, makes every fetch wait for it to close.
type Pages struct {
Bodies map[string][]byte
Gate chan struct{}
mu sync.Mutex
fetched []string
}
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
p.mu.Lock()
p.fetched = append(p.fetched, rawURL)
p.mu.Unlock()
if p.Gate != nil {
select {
case <-p.Gate:
case <-ctx.Done():
return recipes.Page{}, ctx.Err()
}
}
body, ok := p.Bodies[rawURL]
if !ok {
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
}
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
}
// Fetched returns every URL fetched, in order.
func (p *Pages) Fetched() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.fetched...)
}
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
// a 404.
type Images struct {
JPEGs map[string][]byte
}
func (i *Images) FetchImage(_ context.Context, rawURL string) (recipes.Page, error) {
body, ok := i.JPEGs[rawURL]
if !ok {
return recipes.Page{}, &recipes.StatusError{URL: rawURL, Status: 404}
}
return recipes.Page{URL: rawURL, ContentType: "image/jpeg", Body: body}, nil
}
// JPEG is a w×h JPEG whose pixels depend on seed, so different seeds give
// different photos.
func JPEG(t testing.TB, w, h int, seed uint8) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{uint8(x), uint8(y), seed, 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// RecipePage is a page whose schema.org Recipe is title, from site, with
// two ingredients, two steps, a total time of minutes and the image at
// imageURL.
func RecipePage(title, site, imageURL string, minutes int) []byte {
return fmt.Appendf(nil, `<!DOCTYPE html><html><head>
<meta property="og:site_name" content=%q>
<script type="application/ld+json">{
"@context": "https://schema.org",
"@type": "Recipe",
"name": %q,
"image": %q,
"recipeIngredient": ["1 onion", "2 cloves garlic, minced"],
"recipeInstructions": ["Chop the onion.", "Fry with the garlic."],
"totalTime": "PT%dM"
}</script></head><body></body></html>`, site, title, imageURL, minutes)
}
// NormalizedReply is a usable normalize_recipe reply for a RecipePage with
// method and minutes.
func NormalizedReply(method string, minutes int) llm.GenerateResult {
js := fmt.Sprintf(`{"ingredients":[
{"name":"onion","qty":"1","unit":"","note":""},
{"name":"garlic","qty":"2","unit":"cloves","note":"minced"}
],"method":%q,"difficulty":null,"minutes_total":%d}`, method, minutes)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
// NewGrounder returns a Grounder storing in st, with photos cached under
// dataDir, normalizing through the real harness with model.
func NewGrounder(t testing.TB, st *store.Store, dataDir string, search recipes.SearchSource, pages recipes.PageFetcher, images recipes.ImageFetcher, model llm.Provider) *recipes.Grounder {
t.Helper()
photos, err := recipes.NewPhotoCache(images, st, dataDir)
if err != nil {
t.Fatal(err)
}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test"},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
Store: st,
Guard: guard,
}
normalize := func(ctx context.Context, scope string, in skills.RecipeText) (skills.NormalizedRecipe, error) {
return skills.NormalizeRecipe(ctx, cfg, scope, in)
}
g, err := recipes.NewGrounder(st, search, pages, photos, normalize)
if err != nil {
t.Fatal(err)
}
return g
}