ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Only avoid recipes you cooked in the two weeks (#381)

* Limit avoidance to recently cooked recipes and show save feedback

* parallel fetch of old

* save regenerated ones

* simplify

* wait group update and move last recipes

* keep mail matching

* go fumpt

authored by

Paul Miller and committed by
GitHub
0d3bcfe5 baeb5880

+362 -37
+2 -2
internal/ai/client.go
··· 348 348 ingredientsMessage += buf.String() 349 349 messages = append(messages, user(ingredientsMessage)) 350 350 351 - // Previous recipes to avoid (if any). Reduce this to already cooked? 351 + // Previously cooked recipes to avoid (if any). 352 352 if len(lastRecipes) > 0 { 353 353 var prevRecipesMsg strings.Builder 354 - prevRecipesMsg.WriteString("Avoid recipes similar to these from the past 2 weeks:\n") 354 + prevRecipesMsg.WriteString("Avoid recipes similar to these previously cooked:\n") 355 355 for _, recipe := range lastRecipes { 356 356 fmt.Fprintf(&prevRecipesMsg, "%s\n", recipe) 357 357 }
+24 -6
internal/mail/mail.go
··· 21 21 "careme/internal/users" 22 22 utypes "careme/internal/users/types" 23 23 24 + "github.com/samber/lo" 25 + lop "github.com/samber/lo/parallel" 24 26 "github.com/sendgrid/rest" 25 27 "github.com/sendgrid/sendgrid-go" 26 28 "github.com/sendgrid/sendgrid-go/helpers/mail" ··· 135 137 136 138 p := recipes.DefaultParams(l, date) 137 139 // p.UserID = user.ID 138 - for _, last := range user.LastRecipes { 139 - if last.CreatedAt.Before(time.Now().AddDate(0, 0, -14)) { 140 - continue 141 - } 142 - p.LastRecipes = append(p.LastRecipes, last.Title) 143 - } 144 140 145 141 paramsHash := p.Hash() 146 142 sentKey := mailSentPrefix + paramsHash + "/" + user.ID ··· 169 165 } 170 166 } 171 167 168 + // TODO refactor with recipes/server.go 169 + recent := lo.Filter(user.LastRecipes, func(r utypes.Recipe, _ int) bool { 170 + return r.CreatedAt.After(time.Now().AddDate(0, 0, -14)) // magic number. Should it be loner and shoul we use star rating? 171 + }) 172 + 173 + keep := lop.Map(recent, func(r utypes.Recipe, _ int) bool { 174 + feedback, err := rio.FeedbackFromCache(ctx, r.Hash) 175 + if err != nil { 176 + if !errors.Is(err, cache.ErrNotFound) { 177 + slog.WarnContext(ctx, "failed to load recipe feedback while building avoid list", "recipe_hash", r.Hash, "error", err) 178 + } 179 + return false 180 + } 181 + return feedback.Cooked 182 + }) 183 + 184 + for i, last := range recent { 185 + if !keep[i] { 186 + continue 187 + } 188 + p.LastRecipes = append(p.LastRecipes, last.Title) 189 + } 172 190 // can orphan recipes here with crash or shutdown. Params should have a start time 173 191 174 192 shoppingList, err = m.generator.GenerateRecipes(ctx, p)
+16 -1
internal/recipes/generator.go
··· 8 8 "hash/fnv" 9 9 "io" 10 10 "log/slog" 11 + "slices" 11 12 "strings" 12 13 "time" 13 14 ··· 131 132 for _, dismissed := range p.Dismissed { 132 133 instructions = append(instructions, "Passed on "+dismissed.Title) 133 134 } 135 + for _, saved := range newlySaved(p.Saved, p.PriorSavedHashes) { 136 + instructions = append(instructions, "Enjoyed and saved so don't repeat: "+saved) 137 + } 134 138 135 139 shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ConversationID) 136 140 if err != nil { 137 141 return nil, fmt.Errorf("failed to regenerate recipes with AI: %w", err) 138 142 } 139 - // want to add saved to insructions but only once. TODO 140 143 // Include saved recipes in the shopping list 141 144 shoppingList.Recipes = append(shoppingList.Recipes, p.Saved...) 142 145 ··· 225 228 lo.Must(io.WriteString(fnv, normalizedStyle)) 226 229 return "wines/" + base64.RawURLEncoding.EncodeToString(fnv.Sum(nil)) 227 230 } 231 + 232 + func newlySaved(saved []ai.Recipe, priorSavedHashes []string) []string { 233 + titles := make([]string, 0, len(saved)) 234 + for _, recipe := range saved { 235 + hash := recipe.ComputeHash() 236 + if slices.Contains(priorSavedHashes, hash) { 237 + continue 238 + } 239 + titles = append(titles, recipe.Title) 240 + } 241 + return lo.Uniq(titles) 242 + }
+12
internal/recipes/generator_hash_test.go
··· 77 77 } 78 78 } 79 79 80 + func TestGeneratorParamsHash_IgnoresPriorSavedHashes(t *testing.T) { 81 + p := DefaultParams(&locations.Location{ID: "34567890", Name: "Hash Store"}, time.Date(2025, 9, 17, 0, 0, 0, 0, time.UTC)) 82 + 83 + before := p.Hash() 84 + p.PriorSavedHashes = []string{"saved-1", "saved-2"} 85 + after := p.Hash() 86 + 87 + if before != after { 88 + t.Fatalf("expected prior saved hashes not to affect params hash: before=%s after=%s", before, after) 89 + } 90 + } 91 + 80 92 func TestNormalizeLegacyRecipeHash(t *testing.T) { 81 93 p := DefaultParams(&locations.Location{ID: "34567890", Name: "Legacy Store"}, time.Date(2025, 9, 17, 0, 0, 0, 0, time.UTC)) 82 94 hash := p.Hash()
+95
internal/recipes/generator_test.go
··· 20 20 selection *ai.WineSelection 21 21 } 22 22 23 + type captureRegenerateAIClient struct { 24 + instructions []string 25 + conversationID string 26 + shoppingList *ai.ShoppingList 27 + } 28 + 23 29 type captureWineStaplesProvider struct { 24 30 mu sync.Mutex 25 31 searches []string ··· 51 57 } 52 58 53 59 func (c *captureWineQuestionAIClient) Ready(ctx context.Context) error { 60 + return nil 61 + } 62 + 63 + func (c *captureRegenerateAIClient) GenerateRecipes(ctx context.Context, location *locations.Location, ingredients []kroger.Ingredient, instructions []string, date time.Time, lastRecipes []string) (*ai.ShoppingList, error) { 64 + panic("unexpected call to GenerateRecipes") 65 + } 66 + 67 + func (c *captureRegenerateAIClient) Regenerate(ctx context.Context, newinstructions []string, conversationID string) (*ai.ShoppingList, error) { 68 + c.instructions = append([]string(nil), newinstructions...) 69 + c.conversationID = conversationID 70 + if c.shoppingList != nil { 71 + return c.shoppingList, nil 72 + } 73 + return &ai.ShoppingList{}, nil 74 + } 75 + 76 + func (c *captureRegenerateAIClient) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 77 + panic("unexpected call to AskQuestion") 78 + } 79 + 80 + func (c *captureRegenerateAIClient) PickWine(ctx context.Context, conversationID string, recipeTitle string, wines []kroger.Ingredient) (*ai.WineSelection, error) { 81 + panic("unexpected call to PickWine") 82 + } 83 + 84 + func (c *captureRegenerateAIClient) Ready(ctx context.Context) error { 54 85 return nil 55 86 } 56 87 ··· 179 210 t.Fatalf("expected recipe title %q, got %q", "Salmon", aiStub.recipeTitle) 180 211 } 181 212 } 213 + 214 + func TestGenerateRecipes_RegenerateIncludesOnlyNewlySavedRecipesInAvoidInstruction(t *testing.T) { 215 + alreadySaved := ai.Recipe{Title: "Already Saved", Description: "Saved earlier"} 216 + newlySaved := ai.Recipe{Title: "Newly Saved", Description: "Saved now"} 217 + dismissed := ai.Recipe{Title: "Dismissed Recipe", Description: "Passed on"} 218 + newResult := ai.Recipe{Title: "Brand New Dinner", Description: "Fresh idea"} 219 + 220 + aiStub := &captureRegenerateAIClient{ 221 + shoppingList: &ai.ShoppingList{ 222 + ConversationID: "conv-123", 223 + Recipes: []ai.Recipe{newResult}, 224 + }, 225 + } 226 + g := &Generator{ 227 + io: IO(cache.NewFileCache(t.TempDir())), 228 + aiClient: aiStub, 229 + } 230 + 231 + params := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 232 + params.ConversationID = "conv-123" 233 + params.Instructions = "make it vegetarian" 234 + params.Saved = []ai.Recipe{alreadySaved, newlySaved} 235 + params.Dismissed = []ai.Recipe{dismissed} 236 + params.PriorSavedHashes = []string{alreadySaved.ComputeHash()} 237 + 238 + got, err := g.GenerateRecipes(t.Context(), params) 239 + if err != nil { 240 + t.Fatalf("GenerateRecipes returned error: %v", err) 241 + } 242 + 243 + wantInstructions := []string{ 244 + "make it vegetarian", 245 + "Passed on Dismissed Recipe", 246 + "Enjoyed and saved so don't repeat: Newly Saved", 247 + } 248 + if !slices.Equal(aiStub.instructions, wantInstructions) { 249 + t.Fatalf("unexpected regenerate instructions: got %v want %v", aiStub.instructions, wantInstructions) 250 + } 251 + if aiStub.conversationID != "conv-123" { 252 + t.Fatalf("expected conversation ID %q, got %q", "conv-123", aiStub.conversationID) 253 + } 254 + if got == nil || len(got.Recipes) != 3 { 255 + t.Fatalf("expected regenerated list plus saved recipes, got %+v", got) 256 + } 257 + if got.Recipes[0].Title != "Brand New Dinner" || got.Recipes[1].Title != "Already Saved" || got.Recipes[2].Title != "Newly Saved" { 258 + t.Fatalf("unexpected recipe order after regenerate: %+v", got.Recipes) 259 + } 260 + } 261 + 262 + func TestNewlySaved(t *testing.T) { 263 + foo := ai.Recipe{Title: "foo", Description: "blah"} 264 + salmon := ai.Recipe{Title: "Salmon", Description: "previusly saved"} 265 + hash := foo.ComputeHash() 266 + 267 + got := newlySaved( 268 + []ai.Recipe{foo, salmon, salmon}, 269 + []string{hash}, 270 + ) 271 + 272 + want := []string{salmon.Title} 273 + if !slices.Equal(got, want) { 274 + t.Fatalf("unexpected saved avoid instruction: got %q want %q", got, want) 275 + } 276 + }
+2 -5
internal/recipes/io_test.go
··· 30 30 31 31 const n = 32 32 32 var wg sync.WaitGroup 33 - wg.Add(n) 34 - 35 33 errs := make(chan error, n) 36 34 for range n { 37 - go func() { 38 - defer wg.Done() 35 + wg.Go(func() { 39 36 errs <- rio.SaveParams(t.Context(), p) 40 - }() 37 + }) 41 38 } 42 39 wg.Wait() 43 40 close(errs)
+5 -2
internal/recipes/params.go
··· 34 34 // per round instuctions 35 35 Instructions string `json:"instructions,omitempty"` 36 36 Directive string `json:"directive,omitempty"` // this is the new one that will be used. Can remove GenerationPrompt after a while. 37 - LastRecipes []string `json:"last_recipes,omitempty"` 37 + LastRecipes []string `json:"-"` // this doesn't get populated until after save. 38 38 // UserID string `json:"user_id,omitempty"` 39 39 ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in separately to generate recipes? 40 - // TODO Both should just be title and hash insread of full ai.Recipe 40 + // TODO Both should just be title and hash instead of full ai.Recipe 41 41 Saved []ai.Recipe `json:"saved_recipes,omitempty"` 42 42 Dismissed []ai.Recipe `json:"dismissed_recipes,omitempty"` 43 + 44 + // regeneration-only context from the origin params; not persisted or hashed 45 + PriorSavedHashes []string `json:"-"` 43 46 } 44 47 45 48 func DefaultParams(l *locations.Location, date time.Time) *generatorParams {
+33 -21
internal/recipes/server.go
··· 26 26 utypes "careme/internal/users/types" 27 27 28 28 "github.com/samber/lo" 29 + lop "github.com/samber/lo/parallel" 29 30 ) 30 31 31 32 func setTextContent(w http.ResponseWriter) { ··· 103 104 var thread []RecipeThreadEntry 104 105 var wineRecommendation *ai.WineSelection 105 106 var loadWG sync.WaitGroup 106 - loadWG.Add(3) 107 - go func() { 108 - defer loadWG.Done() 107 + loadWG.Go(func() { 109 108 existing, err := s.FeedbackFromCache(ctx, hash) 110 109 if err != nil { 111 110 if !errors.Is(err, cache.ErrNotFound) { ··· 114 113 return 115 114 } 116 115 feedback = *existing 117 - }() 118 - go func() { 119 - defer loadWG.Done() 116 + }) 117 + loadWG.Go(func() { 120 118 existing, err := s.ThreadFromCache(ctx, hash) 121 119 if err != nil { 122 120 if !errors.Is(err, cache.ErrNotFound) { ··· 125 123 return 126 124 } 127 125 thread = existing 128 - }() 129 - go func() { 130 - defer loadWG.Done() 126 + }) 127 + loadWG.Go(func() { 131 128 selection, err := s.WineFromCache(ctx, hash) 132 129 if err != nil { 133 130 if !errors.Is(err, cache.ErrNotFound) { ··· 136 133 return 137 134 } 138 135 wineRecommendation = selection 139 - }() 136 + }) 140 137 loadWG.Wait() 141 138 142 139 if recipe.OriginHash == "" { ··· 687 684 688 685 params := *baseParams 689 686 params.Instructions = instructions 687 + params.PriorSavedHashes = lo.Map(baseParams.Saved, func(r ai.Recipe, _ int) string { return r.ComputeHash() }) 690 688 s.mergeParamsWithSelection(ctx, &params, selection, currentList.Recipes) 691 689 if params.ConversationID == "" { 692 690 params.ConversationID = currentList.ConversationID ··· 857 855 } 858 856 859 857 func (s *server) kickgeneration(ctx context.Context, p *generatorParams, currentUser *utypes.User) { 860 - for _, last := range currentUser.LastRecipes { 861 - if last.CreatedAt.Before(time.Now().AddDate(0, 0, -14)) { 862 - break 863 - } 864 - p.LastRecipes = append(p.LastRecipes, last.Title) 865 - } 866 - 867 858 hash := p.Hash() 868 859 869 - s.wg.Add(1) 870 - go func() { 871 - defer s.wg.Done() 860 + s.wg.Go(func() { 872 861 // copy over request id to new context? can't be same context because end of http request will cancel it. 873 862 ctx := context.WithoutCancel(ctx) 863 + 864 + recent := lo.Filter(currentUser.LastRecipes, func(r utypes.Recipe, _ int) bool { 865 + return r.CreatedAt.After(time.Now().AddDate(0, 0, -14)) // magic number. Should it be loner and shoul we use star rating? 866 + }) 867 + 868 + keep := lop.Map(recent, func(r utypes.Recipe, _ int) bool { 869 + feedback, err := s.FeedbackFromCache(ctx, r.Hash) 870 + if err != nil { 871 + if !errors.Is(err, cache.ErrNotFound) { 872 + slog.WarnContext(ctx, "failed to load recipe feedback while building avoid list", "recipe_hash", r.Hash, "error", err) 873 + } 874 + return false 875 + } 876 + return feedback.Cooked 877 + }) 878 + 879 + for i, last := range recent { 880 + if !keep[i] { 881 + continue 882 + } 883 + p.LastRecipes = append(p.LastRecipes, last.Title) 884 + } 885 + 874 886 slog.InfoContext(ctx, "generating cached recipes", "params", p.String(), "hash", hash) 875 887 shoppingList, err := s.generator.GenerateRecipes(ctx, p) 876 888 if err != nil { ··· 884 896 slog.ErrorContext(ctx, "save error", "error", err) 885 897 return 886 898 } 887 - }() 899 + }) 888 900 } 889 901 890 902 func (s *server) Spin(w http.ResponseWriter, r *http.Request) {
+173
internal/recipes/server_test.go
··· 9 9 "net/http/httptest" 10 10 "net/url" 11 11 "path/filepath" 12 + "slices" 12 13 "strings" 14 + "sync" 13 15 "testing" 14 16 "time" 15 17 ··· 495 497 496 498 if rr.Code != http.StatusBadRequest { 497 499 t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code) 500 + } 501 + } 502 + 503 + type captureKickgenerationGenerator struct { 504 + mu sync.Mutex 505 + last *generatorParams 506 + called chan struct{} 507 + } 508 + 509 + func (c *captureKickgenerationGenerator) GenerateRecipes(ctx context.Context, p *generatorParams) (*ai.ShoppingList, error) { 510 + c.mu.Lock() 511 + clone := *p 512 + clone.LastRecipes = append([]string(nil), p.LastRecipes...) 513 + clone.PriorSavedHashes = append([]string(nil), p.PriorSavedHashes...) 514 + clone.Saved = append([]ai.Recipe(nil), p.Saved...) 515 + clone.Dismissed = append([]ai.Recipe(nil), p.Dismissed...) 516 + c.last = &clone 517 + c.mu.Unlock() 518 + if c.called != nil { 519 + select { 520 + case c.called <- struct{}{}: 521 + default: 522 + } 523 + } 524 + return &ai.ShoppingList{}, nil 525 + } 526 + 527 + func (c *captureKickgenerationGenerator) AskQuestion(ctx context.Context, question string, conversationID string) (string, error) { 528 + panic("unexpected call to AskQuestion") 529 + } 530 + 531 + func (c *captureKickgenerationGenerator) PickAWine(ctx context.Context, conversationID, location string, recipe ai.Recipe, date time.Time) (*ai.WineSelection, error) { 532 + panic("unexpected call to PickAWine") 533 + } 534 + 535 + func (c *captureKickgenerationGenerator) Ready(ctx context.Context) error { 536 + return nil 537 + } 538 + 539 + func (c *captureKickgenerationGenerator) LastParams() *generatorParams { 540 + c.mu.Lock() 541 + defer c.mu.Unlock() 542 + if c.last == nil { 543 + return nil 544 + } 545 + clone := *c.last 546 + clone.LastRecipes = append([]string(nil), c.last.LastRecipes...) 547 + clone.PriorSavedHashes = append([]string(nil), c.last.PriorSavedHashes...) 548 + clone.Saved = append([]ai.Recipe(nil), c.last.Saved...) 549 + clone.Dismissed = append([]ai.Recipe(nil), c.last.Dismissed...) 550 + return &clone 551 + } 552 + 553 + func TestKickgeneration_OnlyAvoidsRecentlyCookedRecipes(t *testing.T) { 554 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 555 + storage := users.NewStorage(cacheStore) 556 + generator := &captureKickgenerationGenerator{called: make(chan struct{}, 1)} 557 + s := &server{ 558 + recipeio: recipeio{Cache: cacheStore}, 559 + storage: storage, 560 + generator: generator, 561 + } 562 + t.Cleanup(s.Wait) 563 + 564 + now := time.Now() 565 + cookedRecent := utypes.Recipe{Title: "Cooked Recently", Hash: "hash-cooked-recent", CreatedAt: now.Add(-48 * time.Hour)} 566 + notCookedRecent := utypes.Recipe{Title: "Only Saved", Hash: "hash-saved-recent", CreatedAt: now.Add(-24 * time.Hour)} 567 + tooOldCooked := utypes.Recipe{Title: "Cooked Too Old", Hash: "hash-cooked-old", CreatedAt: now.Add(-15 * 24 * time.Hour)} 568 + currentUser := &utypes.User{ 569 + ID: "user-1", 570 + Email: []string{"chef@example.com"}, 571 + ShoppingDay: "Saturday", 572 + LastRecipes: []utypes.Recipe{cookedRecent, notCookedRecent, tooOldCooked}, 573 + } 574 + 575 + if err := s.SaveFeedback(t.Context(), cookedRecent.Hash, RecipeFeedback{Cooked: true, UpdatedAt: now}); err != nil { 576 + t.Fatalf("failed to seed cooked feedback: %v", err) 577 + } 578 + if err := s.SaveFeedback(t.Context(), notCookedRecent.Hash, RecipeFeedback{Cooked: false, UpdatedAt: now}); err != nil { 579 + t.Fatalf("failed to seed uncooked feedback: %v", err) 580 + } 581 + if err := s.SaveFeedback(t.Context(), tooOldCooked.Hash, RecipeFeedback{Cooked: true, UpdatedAt: now}); err != nil { 582 + t.Fatalf("failed to seed old cooked feedback: %v", err) 583 + } 584 + 585 + params := DefaultParams(&locations.Location{ID: "70001001", Name: "Store"}, now) 586 + s.kickgeneration(t.Context(), params, currentUser) 587 + 588 + select { 589 + case <-generator.called: 590 + case <-time.After(2 * time.Second): 591 + t.Fatal("timed out waiting for generator call") 592 + } 593 + 594 + captured := generator.LastParams() 595 + if captured == nil { 596 + t.Fatal("expected captured params") 597 + } 598 + if got, want := captured.LastRecipes, []string{"Cooked Recently"}; !slices.Equal(got, want) { 599 + t.Fatalf("expected only recently cooked recipes in avoid list, got %v", got) 498 600 } 499 601 } 500 602 ··· 1258 1360 } 1259 1361 if len(updatedParams.Dismissed) != 1 || updatedParams.Dismissed[0].ComputeHash() != dismissedRecipe.ComputeHash() { 1260 1362 t.Fatalf("expected dismissed recipe selection to persist in params, got %#v", updatedParams.Dismissed) 1363 + } 1364 + } 1365 + 1366 + func TestHandleRegenerate_PassesPriorSavedHashesToGenerator(t *testing.T) { 1367 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 1368 + storage := users.NewStorage(cacheStore) 1369 + generator := &captureKickgenerationGenerator{called: make(chan struct{}, 1)} 1370 + s := &server{ 1371 + recipeio: recipeio{Cache: cacheStore}, 1372 + storage: storage, 1373 + clerk: auth.DefaultMock(), 1374 + generator: generator, 1375 + } 1376 + t.Cleanup(s.Wait) 1377 + 1378 + alreadySaved := ai.Recipe{Title: "Already Saved", Description: "Saved earlier"} 1379 + newlySaved := ai.Recipe{Title: "Newly Saved", Description: "Saved now"} 1380 + available := ai.Recipe{Title: "Still Available", Description: "Fresh"} 1381 + 1382 + p := DefaultParams(&locations.Location{ID: "70004001", Name: "Store"}, time.Now()) 1383 + p.ConversationID = "conv-123" 1384 + p.Saved = []ai.Recipe{alreadySaved} 1385 + originHash := p.Hash() 1386 + if err := s.SaveParams(t.Context(), p); err != nil { 1387 + t.Fatalf("failed to save params: %v", err) 1388 + } 1389 + 1390 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{alreadySaved, newlySaved, available}, originHash); err != nil { 1391 + t.Fatalf("failed to save recipes: %v", err) 1392 + } 1393 + if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 1394 + Recipes: []ai.Recipe{alreadySaved, newlySaved, available}, 1395 + ConversationID: "conv-123", 1396 + }, originHash); err != nil { 1397 + t.Fatalf("failed to save shopping list: %v", err) 1398 + } 1399 + 1400 + if err := s.saveRecipeSelection(t.Context(), "mock-clerk-user-id", originHash, recipeSelection{ 1401 + SavedHashes: []string{alreadySaved.ComputeHash(), newlySaved.ComputeHash()}, 1402 + }); err != nil { 1403 + t.Fatalf("failed to save selection: %v", err) 1404 + } 1405 + 1406 + form := url.Values{"instructions": {"make it faster"}} 1407 + req := httptest.NewRequest(http.MethodPost, "/recipes/"+originHash+"/regenerate", strings.NewReader(form.Encode())) 1408 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 1409 + req.Header.Set("HX-Request", "true") 1410 + req.SetPathValue("hash", originHash) 1411 + rr := httptest.NewRecorder() 1412 + 1413 + s.handleRegenerate(rr, req) 1414 + 1415 + if rr.Code != http.StatusOK { 1416 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 1417 + } 1418 + 1419 + select { 1420 + case <-generator.called: 1421 + case <-time.After(2 * time.Second): 1422 + t.Fatal("timed out waiting for generator call") 1423 + } 1424 + 1425 + captured := generator.LastParams() 1426 + if captured == nil { 1427 + t.Fatal("expected captured params") 1428 + } 1429 + if got, want := captured.PriorSavedHashes, []string{alreadySaved.ComputeHash()}; !slices.Equal(got, want) { 1430 + t.Fatalf("expected prior saved hashes %v, got %v", want, got) 1431 + } 1432 + if len(captured.Saved) != 2 { 1433 + t.Fatalf("expected both current saved recipes, got %#v", captured.Saved) 1261 1434 } 1262 1435 } 1263 1436