From 037a232dbbbca555f86d65378f96c6e49c5ed31c Mon Sep 17 00:00:00 2001 From: Amolith Date: Thu, 11 Jun 2026 16:38:52 -0600 Subject: [PATCH] cooked: accept fractional recipe portions --- features/cooked-mcp/recipes.feature.yaml | 3 + internal/cooked/client.go | 79 +++---------------- .../cooked/client_preview_validation_test.go | 18 ++--- .../cooked/client_recipe_validation_test.go | 24 +++++- internal/cooked/client_test.go | 15 ++-- internal/mcp/server.go | 63 ++++++++------- internal/mcp/server_save_draft_test.go | 2 +- internal/mcp/server_save_url_test.go | 2 +- internal/mcp/server_test.go | 48 +++++------ 9 files changed, 117 insertions(+), 137 deletions(-) diff --git a/features/cooked-mcp/recipes.feature.yaml b/features/cooked-mcp/recipes.feature.yaml index 67778f204ac42a5e6952bc14b5d28a8c3dcb9aac..e8165bad476610868d142d566cbc092fd1a2732e 100644 --- a/features/cooked-mcp/recipes.feature.yaml +++ b/features/cooked-mcp/recipes.feature.yaml @@ -15,6 +15,7 @@ components: 6-2: A single recipe read includes owner or edit-permission information when Cooked provides it. 7: A single recipe read includes recipe content. 8: A single recipe read includes portions. + 8-1: Recipe reads preserve fractional portions when Cooked provides them. 9: Recipe reads omit image URLs unless a future feature explicitly exposes them. PREVIEW_TEXT: requirements: @@ -24,6 +25,7 @@ components: 2: The preview_recipe_text tool returns the preview title. 3: The preview_recipe_text tool returns preview markdown. 4: The preview_recipe_text tool returns preview portions. + 4-1: The preview_recipe_text tool preserves fractional portions when Cooked provides them. SAVE: requirements: 1: The save_recipe tool saves a recipe from raw text. @@ -46,6 +48,7 @@ components: 8-1: Updating an existing saved recipe requires a recipe ID. 8-2: Updating an existing saved recipe requires recipe markdown. 8-3: Updating an existing saved recipe requires portions. + 8-4: Saving or updating recipes preserves fractional portions supplied by the agent. 9: Recipe save and update results include the affected recipe ID. 10: Recipe save requests use explicit source values. 10-1: The recipe save source values are raw_text, prepared, url, draft, and existing. diff --git a/internal/cooked/client.go b/internal/cooked/client.go index 24ed5025b2d5cf9756fdc09f7bfbb86b587f7d39..c3f93458d1a50c0f79aa1217b55da1bd750f2f9d 100644 --- a/internal/cooked/client.go +++ b/internal/cooked/client.go @@ -12,11 +12,9 @@ import ( "errors" "fmt" "io" - "math/big" "net/http" "net/http/cookiejar" "net/url" - "strconv" "strings" "sync" "time" @@ -89,37 +87,15 @@ type RecipeMetadata struct { // RecipeContent is the Markdown-style body and portions for a Cooked recipe. type RecipeContent struct { - Content string `json:"content"` - Portions int `json:"portions"` + Content string `json:"content"` + Portions float64 `json:"portions"` } // RecipeTextPreview is Cooked's preview of raw recipe text before saving. type RecipeTextPreview struct { - Title string `json:"title"` - Markdown string `json:"markdown"` - Portions int `json:"portions"` -} - -// UnmarshalJSON accepts Cooked's integer-valued decimal portions, such as 1.0. -func (p *RecipeTextPreview) UnmarshalJSON(data []byte) error { - type raw RecipeTextPreview - var response struct { - raw - Portions json.Number `json:"portions"` - } - if err := json.Unmarshal(data, &response); err != nil { - return err - } - - portions, err := jsonNumberToInt(response.Portions) - if err != nil { - return fmt.Errorf("portions: %w", err) - } - - *p = RecipeTextPreview(response.raw) - p.Portions = portions - - return nil + Title string `json:"title"` + Markdown string `json:"markdown"` + Portions float64 `json:"portions"` } // RecipeURLImport is the result of importing a recipe URL. @@ -316,7 +292,7 @@ func (c *Client) PreviewRecipeText(ctx context.Context, title, text string) (Rec } // SavePreparedRecipe logs in when needed and saves prepared recipe markdown as a new recipe. -func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) { +func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, portions float64) (string, error) { body, err := json.Marshal(savePreparedRecipeRequest{Title: title, Description: markdown, Portions: portions}) if err != nil { return "", fmt.Errorf("encode Cooked prepared recipe save request: %w", err) @@ -337,7 +313,7 @@ func (c *Client) SavePreparedRecipe(ctx context.Context, title, markdown string, } // SaveRecipeDraft logs in when needed and saves a reviewed URL import draft. -func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions int) (string, error) { +func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions float64) (string, error) { body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions}) if err != nil { return "", fmt.Errorf("encode Cooked recipe draft save request: %w", err) @@ -359,7 +335,7 @@ func (c *Client) SaveRecipeDraft(ctx context.Context, draftID, markdown string, } // UpdateRecipeContent logs in when needed and updates an existing recipe's content. -func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error { +func (c *Client) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions float64) error { body, err := json.Marshal(recipeContentRequest{Description: markdown, Portions: portions}) if err != nil { return fmt.Errorf("encode Cooked recipe update request: %w", err) @@ -498,35 +474,6 @@ func validateShoppingListProductGroup(aisleIndex, productIndex int, product Prod return nil } -func jsonNumberToInt(number json.Number) (int, error) { - text := number.String() - if text == "" { - return 0, fmt.Errorf("missing") - } - - rational, ok := new(big.Rat).SetString(text) - if !ok { - return 0, fmt.Errorf("invalid JSON number %q", text) - } - if !rational.IsInt() { - return 0, fmt.Errorf("must be integer-valued, got %s", text) - } - - integer := rational.Num() - if !integer.IsInt64() { - return 0, fmt.Errorf("out of range, got %s", text) - } - - value := integer.Int64() - maxInt := int64(1<<(strconv.IntSize-1) - 1) - minInt := -maxInt - 1 - if value < minInt || value > maxInt { - return 0, fmt.Errorf("out of range for int, got %s", text) - } - - return int(value), nil -} - func (c *Client) doAuthenticated( ctx context.Context, method, path string, @@ -686,14 +633,14 @@ type previewRecipeTextRequest struct { } type savePreparedRecipeRequest struct { - Title string `json:"title"` - Description string `json:"description"` - Portions int `json:"portions"` + Title string `json:"title"` + Description string `json:"description"` + Portions float64 `json:"portions"` } type recipeContentRequest struct { - Description string `json:"description"` - Portions int `json:"portions"` + Description string `json:"description"` + Portions float64 `json:"portions"` } type importRecipeURLRequest struct { diff --git a/internal/cooked/client_preview_validation_test.go b/internal/cooked/client_preview_validation_test.go index 9a43453a857d463ac25d61023ec811ce734c31ef..459184a1ecf406396d7b1c44000bcc2cc5b0583c 100644 --- a/internal/cooked/client_preview_validation_test.go +++ b/internal/cooked/client_preview_validation_test.go @@ -7,7 +7,6 @@ package cooked import ( "context" "net/http" - "strings" "testing" ) @@ -28,11 +27,12 @@ func TestPreviewRecipeTextACIDAPIClientResponses1AcceptsIntegerDecimalPortions(t t.Fatalf("preview calls = %d, want 1", previewCalls) } if preview.Portions != 1 { - t.Fatalf("preview portions = %d, want 1", preview.Portions) + t.Fatalf("preview portions = %g, want 1", preview.Portions) } } -func TestPreviewRecipeTextACIDAPIClientResponses1RejectsFractionalPortions(t *testing.T) { +// api-client.RESPONSES.1 recipes.PREVIEW_TEXT.4-1 +func TestPreviewRecipeTextACIDAPIClientResponses1AndRecipesPreviewText4_1PreservesFractionalPortions(t *testing.T) { var previewCalls int client, closeServer := newTestClient(t, rawPreviewRecipeTextTestHandler( t, @@ -41,16 +41,16 @@ func TestPreviewRecipeTextACIDAPIClientResponses1RejectsFractionalPortions(t *te )) defer closeServer() - _, err := client.PreviewRecipeText(context.Background(), "Pasta", "Boil pasta.") - if err == nil { - t.Fatal("PreviewRecipeText() error = nil, want fractional portions error") - } - if !strings.Contains(err.Error(), "must be integer-valued") { - t.Fatalf("PreviewRecipeText() error = %v, want integer-valued portions error", err) + preview, err := client.PreviewRecipeText(context.Background(), "Pasta", "Boil pasta.") + if err != nil { + t.Fatalf("PreviewRecipeText() error = %v", err) } if previewCalls != 1 { t.Fatalf("preview calls = %d, want 1", previewCalls) } + if preview.Portions != 1.5 { + t.Fatalf("preview portions = %g, want 1.5", preview.Portions) + } } func rawPreviewRecipeTextTestHandler(t *testing.T, previewCalls *int, body string) http.HandlerFunc { diff --git a/internal/cooked/client_recipe_validation_test.go b/internal/cooked/client_recipe_validation_test.go index ea00be12f902902b9ac63219d97b56d61daa87e2..68eab1ffc45602da4c0e3fbc872b843785df4631 100644 --- a/internal/cooked/client_recipe_validation_test.go +++ b/internal/cooked/client_recipe_validation_test.go @@ -111,7 +111,7 @@ func TestSavePreparedRecipeACIDAPIClientResponses1RejectsMissingOrBlankRecipeID( context.Background(), "Pasta", "# Pasta\n\n1. Boil pasta.", - 2, + 2.5, ) if err == nil { t.Fatal("SavePreparedRecipe() error = nil, want missing recipe ID error") @@ -126,6 +126,28 @@ func TestSavePreparedRecipeACIDAPIClientResponses1RejectsMissingOrBlankRecipeID( } } +// api-client.RESPONSES.1 recipes.READ.8-1 +func TestReadRecipeContentACIDAPIClientResponses1AndRecipesRead8_1PreservesFractionalPortions(t *testing.T) { + var contentCalls int + client, closeServer := newTestClient(t, rawRecipeContentTestHandler( + t, + &contentCalls, + `{"content":"# Pasta","portions":1.5}`, + )) + defer closeServer() + + content, err := client.ReadRecipeContent(context.Background(), "recipe/with space") + if err != nil { + t.Fatalf("ReadRecipeContent() error = %v", err) + } + if contentCalls != 1 { + t.Fatalf("content calls = %d, want 1", contentCalls) + } + if content.Portions != 1.5 { + t.Fatalf("content portions = %g, want 1.5", content.Portions) + } +} + // api-client.RESPONSES.1 recipes.READ.7 recipes.READ.8 func TestReadRecipeContentACIDAPIClientResponses1RejectsMissingContentOrPortions(t *testing.T) { tests := []struct { diff --git a/internal/cooked/client_test.go b/internal/cooked/client_test.go index 0c2dbb79f79c99a15b5972cd0e02a2d74106f4e1..7628decb6e6f6fe0a26b351deda6717cb428441c 100644 --- a/internal/cooked/client_test.go +++ b/internal/cooked/client_test.go @@ -99,7 +99,7 @@ func TestReadRecipeContentACIDRecipesRead7And8ReadsContentAndPortions(t *testing t.Fatalf("content = %q, want recipe markdown", content.Content) } if content.Portions != 2 { - t.Fatalf("portions = %d, want 2", content.Portions) + t.Fatalf("portions = %g, want 2", content.Portions) } } @@ -123,16 +123,17 @@ func TestPreviewRecipeTextACIDRecipesPreviewText1To4PreviewsWithoutSaving(t *tes t.Fatalf("preview markdown = %q, want recipe markdown", preview.Markdown) } if preview.Portions != 2 { - t.Fatalf("preview portions = %d, want 2", preview.Portions) + t.Fatalf("preview portions = %g, want 2", preview.Portions) } } -func TestSavePreparedRecipeACIDRecipesSave2And9And13SavesMarkdownAsDescription(t *testing.T) { +// recipes.SAVE.2 recipes.SAVE.8-4 recipes.SAVE.9 recipes.SAVE.13 +func TestSavePreparedRecipeACIDRecipesSave2And8_4And9And13SavesMarkdownAsDescription(t *testing.T) { var saveCalls int client, closeServer := newTestClient(t, savePreparedRecipeTestHandler(t, &saveCalls)) defer closeServer() - recipeID, err := client.SavePreparedRecipe(context.Background(), "Pasta", "# Pasta\n\n1. Boil pasta.", 2) + recipeID, err := client.SavePreparedRecipe(context.Background(), "Pasta", "# Pasta\n\n1. Boil pasta.", 2.5) if err != nil { t.Fatalf("SavePreparedRecipe() error = %v", err) } @@ -833,8 +834,8 @@ func requireSavePreparedRecipeRequest(t *testing.T, r *http.Request) { if request.Description != "# Pasta\n\n1. Boil pasta." { t.Fatalf("save description = %q, want markdown", request.Description) } - if request.Portions != 2 { - t.Fatalf("save portions = %d, want 2", request.Portions) + if request.Portions != 2.5 { + t.Fatalf("save portions = %g, want 2.5", request.Portions) } } @@ -849,7 +850,7 @@ func requireRecipeContentRequest(t *testing.T, r *http.Request, description stri t.Fatalf("%s description = %q, want markdown", description, request.Description) } if request.Portions != 4 { - t.Fatalf("%s portions = %d, want 4", description, request.Portions) + t.Fatalf("%s portions = %g, want 4", description, request.Portions) } } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 536d311c87659eb91bd2992efa9e05c2d155148d..740b1a5650b6790c106e2c547c51a0eb509e05a5 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -8,6 +8,7 @@ package mcp import ( "context" "fmt" + "strconv" "strings" sdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -33,9 +34,9 @@ type Backend interface { ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error) ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error) PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error) - SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) - SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions int) (string, error) - UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error + SavePreparedRecipe(ctx context.Context, title, markdown string, portions float64) (string, error) + SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions float64) (string, error) + UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions float64) error ImportRecipeURL(ctx context.Context, recipeURL string) (cooked.RecipeURLImport, error) DeleteRecipe(ctx context.Context, recipeID string) error ClearShoppingList(ctx context.Context) error @@ -390,7 +391,7 @@ func (s *Server) callExistingSaveRecipeTool( return newRecipeSaveResult(recipeID) } -func validateSaveRecipeContent(source, markdown string, portions int) error { +func validateSaveRecipeContent(source, markdown string, portions float64) error { if strings.TrimSpace(markdown) == "" { return fmt.Errorf("markdown is required when source is %s", source) } @@ -404,7 +405,7 @@ func validateSaveRecipeContent(source, markdown string, portions int) error { func (s *Server) savePreparedRecipe( ctx context.Context, title, markdown string, - portions int, + portions float64, ) (*sdk.CallToolResult, SaveRecipeOutput, error) { recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions) if err != nil { @@ -1007,7 +1008,7 @@ func formatRecipe(recipe RecipeDetail) string { builder.WriteByte('\n') } fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission) - fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions) + fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(recipe.Portions)) content := strings.TrimSpace(recipe.Content) if content == "" { @@ -1030,7 +1031,7 @@ func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string { builder.WriteString("Recipe text preview: ") builder.WriteString(title) builder.WriteByte('\n') - fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions) + fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(preview.Portions)) markdown := strings.TrimSpace(preview.Markdown) if markdown == "" { @@ -1043,6 +1044,8 @@ func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string { return strings.TrimRight(builder.String(), "\n") } +func formatPortions(portions float64) string { return strconv.FormatFloat(portions, 'f', -1, 64) } + func formatRecipeSave(output SaveRecipeOutput) string { return "Saved recipe (id: " + output.RecipeID + ")." } @@ -1060,7 +1063,7 @@ func formatRecipeURLImportDraft(output SaveRecipeOutput) string { builder.WriteString(output.DraftID) builder.WriteString("). Review the markdown and portions before saving.") if output.Portions > 0 { - fmt.Fprintf(&builder, "\nPortions: %d", output.Portions) + fmt.Fprintf(&builder, "\nPortions: %s", formatPortions(output.Portions)) } if strings.TrimSpace(output.Markdown) != "" { builder.WriteString("\n\n") @@ -1108,12 +1111,12 @@ type RecipeSummary struct { // RecipeDetail is the MCP-safe single recipe output. type RecipeDetail struct { - ID string `json:"id"` - Title string `json:"title"` - Owner string `json:"owner"` - EditPermission bool `json:"edit_permission"` - Content string `json:"content"` - Portions int `json:"portions"` + ID string `json:"id"` + Title string `json:"title"` + Owner string `json:"owner"` + EditPermission bool `json:"edit_permission"` + Content string `json:"content"` + Portions float64 `json:"portions"` } // PreviewRecipeTextArguments contains preview_recipe_text tool arguments. @@ -1124,30 +1127,30 @@ type PreviewRecipeTextArguments struct { // PreviewRecipeTextOutput is the structured output for preview_recipe_text. type PreviewRecipeTextOutput struct { - Title string `json:"title"` - Markdown string `json:"markdown"` - Portions int `json:"portions"` + Title string `json:"title"` + Markdown string `json:"markdown"` + Portions float64 `json:"portions"` } // SaveRecipeArguments contains save_recipe tool arguments. type SaveRecipeArguments struct { - Source string `json:"source" jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."` - RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."` - DraftID string `json:"draft_id,omitempty" jsonschema:"Draft ID for reviewed URL import draft saves."` - Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."` - Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."` - URL string `json:"url,omitempty" jsonschema:"Recipe URL for url imports."` - Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates."` - Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."` + Source string `json:"source" jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."` + RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."` + DraftID string `json:"draft_id,omitempty" jsonschema:"Draft ID for reviewed URL import draft saves."` + Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."` + Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."` + URL string `json:"url,omitempty" jsonschema:"Recipe URL for url imports."` + Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates."` + Portions float64 `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."` } // SaveRecipeOutput is the structured output for save_recipe. type SaveRecipeOutput struct { - RecipeID string `json:"recipe_id,omitempty"` - DraftID string `json:"draft_id,omitempty"` - Title string `json:"title,omitempty"` - Markdown string `json:"markdown,omitempty"` - Portions int `json:"portions,omitempty"` + RecipeID string `json:"recipe_id,omitempty"` + DraftID string `json:"draft_id,omitempty"` + Title string `json:"title,omitempty"` + Markdown string `json:"markdown,omitempty"` + Portions float64 `json:"portions,omitempty"` } // DeleteRecipeArguments contains delete_recipe tool arguments. diff --git a/internal/mcp/server_save_draft_test.go b/internal/mcp/server_save_draft_test.go index da2385eb4873090cf8dffc7f563c59664f4c9b43..9d3cb61e7e2948a8dba5e5c60a7b63f8c4ccf65d 100644 --- a/internal/mcp/server_save_draft_test.go +++ b/internal/mcp/server_save_draft_test.go @@ -38,7 +38,7 @@ func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) { t.Fatalf("backend draft markdown = %q, want raw markdown preserved", backend.draftSaveMarkdown) } if backend.draftSavePortions != 2 { - t.Fatalf("backend draft portions = %d, want 2", backend.draftSavePortions) + t.Fatalf("backend draft portions = %g, want 2", backend.draftSavePortions) } if output.RecipeID != "recipe-1" || output.DraftID != "" || output.Markdown != "" || output.Portions != 0 { t.Fatalf("draft save output = %#v, want recipe_id only", output) diff --git a/internal/mcp/server_save_url_test.go b/internal/mcp/server_save_url_test.go index cfa579b3c617a683ffc22f957c0156631379ad49..d5bd8964b0b2142e90520478fe6c372dedd85bc3 100644 --- a/internal/mcp/server_save_url_test.go +++ b/internal/mcp/server_save_url_test.go @@ -94,7 +94,7 @@ func TestCallToolACIDRecipesSave5And12And14And15ReturnsURLImportDraft(t *testing t.Fatalf("draft output identity = %#v, want draft-1 Pasta with no recipe ID", output) } if output.Markdown != "# Pasta\n\n1. Boil pasta." || output.Portions != 2 { - t.Fatalf("draft output content = %q/%d, want markdown/2", output.Markdown, output.Portions) + t.Fatalf("draft output content = %q/%g, want markdown/2", output.Markdown, output.Portions) } if backend.saveCalls != 0 || backend.updateCalls != 0 { t.Fatalf("save/update calls = %d/%d, want none", backend.saveCalls, backend.updateCalls) diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 75ea61afbf789a5446b7f6a9bfe4658d15ceafde..3aec8779fad8326b4c2f5ac2c32833d2e9211efc 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -118,7 +118,8 @@ func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) { } } -func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) { +// recipes.READ.5 recipes.READ.8-1 +func TestCallToolACIDRecipesRead5And8_1ReadsSingleRecipe(t *testing.T) { backend := &fakeBackend{ recipeMetadata: cooked.RecipeMetadata{ Title: "Pasta", @@ -128,7 +129,7 @@ func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) { }, recipeContent: cooked.RecipeContent{ Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.", - Portions: 2, + Portions: 1.5, }, } server := NewServer(backend, "test") @@ -179,9 +180,9 @@ func requireSingleRecipeOutput(t *testing.T, output ReadOutput) { output.Recipe.EditPermission, ) } - if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 { + if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 1.5 { t.Fatalf( - "structured recipe content/portions = %q/%d, want markdown/2", + "structured recipe content/portions = %q/%g, want markdown/1.5", output.Recipe.Content, output.Recipe.Portions, ) @@ -219,11 +220,12 @@ func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) } } -func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) { +// recipes.PREVIEW_TEXT.1 recipes.PREVIEW_TEXT.4-1 +func TestCallToolACIDRecipesPreviewText1And4_1PreviewsRawText(t *testing.T) { backend := &fakeBackend{preview: cooked.RecipeTextPreview{ Title: "Pasta with Tomato Sauce", Markdown: "# Pasta\n\n1. Boil pasta.", - Portions: 2, + Portions: 2.5, }} server := NewServer(backend, "test") @@ -246,7 +248,7 @@ func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) { t.Fatalf("backend preview text = %q, want raw text preserved", backend.previewText) } if output.Title != "Pasta with Tomato Sauce" || output.Markdown != "# Pasta\n\n1. Boil pasta." || - output.Portions != 2 { + output.Portions != 2.5 { t.Fatalf("preview output = %#v, want title/markdown/portions", output) } requireStructuredContent(t, result, output) @@ -286,7 +288,8 @@ func TestCallToolACIDToolsPreviewRecipeTextTool2RequiresText(t *testing.T) { } } -func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) { +// recipes.SAVE.2 recipes.SAVE.8-4 recipes.SAVE.9 +func TestCallToolACIDRecipesSave2And8_4And9SavesPreparedRecipe(t *testing.T) { backend := &fakeBackend{saveRecipeID: "recipe-1"} server := NewServer(backend, "test") @@ -297,7 +300,7 @@ func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) { Source: " prepared ", Title: " Pasta ", Markdown: " # Pasta\n\n1. Boil pasta.\n", - Portions: 2, + Portions: 2.5, }, ) if err != nil { @@ -313,8 +316,8 @@ func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) { if backend.saveMarkdown != " # Pasta\n\n1. Boil pasta.\n" { t.Fatalf("backend save markdown = %q, want raw markdown preserved", backend.saveMarkdown) } - if backend.savePortions != 2 { - t.Fatalf("backend save portions = %d, want 2", backend.savePortions) + if backend.savePortions != 2.5 { + t.Fatalf("backend save portions = %g, want 2.5", backend.savePortions) } if output.RecipeID != "recipe-1" { t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID) @@ -393,7 +396,7 @@ func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) { t.Fatalf("save markdown = %q, want preview markdown", backend.saveMarkdown) } if backend.savePortions != 2 { - t.Fatalf("save portions = %d, want preview portions 2", backend.savePortions) + t.Fatalf("save portions = %g, want preview portions 2", backend.savePortions) } if output.RecipeID != "recipe-1" { t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID) @@ -425,7 +428,8 @@ func TestCallToolACIDRecipesSave1_1To1_2RequiresRawTextFields(t *testing.T) { } } -func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) { +// recipes.SAVE.8 recipes.SAVE.8-4 recipes.SAVE.9 +func TestCallToolACIDRecipesSave8And8_4And9UpdatesExistingRecipe(t *testing.T) { backend := &fakeBackend{} server := NewServer(backend, "test") @@ -436,7 +440,7 @@ func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) { Source: " existing ", RecipeID: " recipe-1 ", Markdown: " # Pasta\n\n1. Boil pasta.\n", - Portions: 4, + Portions: 4.5, }, ) if err != nil { @@ -452,8 +456,8 @@ func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) { if backend.updateMarkdown != " # Pasta\n\n1. Boil pasta.\n" { t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown) } - if backend.updatePortions != 4 { - t.Fatalf("backend update portions = %d, want 4", backend.updatePortions) + if backend.updatePortions != 4.5 { + t.Fatalf("backend update portions = %g, want 4.5", backend.updatePortions) } if output.RecipeID != "recipe-1" { t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID) @@ -958,13 +962,13 @@ type fakeBackend struct { importURL string saveTitle string saveMarkdown string - savePortions int + savePortions float64 draftSaveID string draftSaveMarkdown string - draftSavePortions int + draftSavePortions float64 updateRecipeID string updateMarkdown string - updatePortions int + updatePortions float64 deleteRecipeID string addShoppingListResult cooked.AddShoppingListResult addIngredients string @@ -1031,7 +1035,7 @@ func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) ( return f.preview, nil } -func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions int) (string, error) { +func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions float64) (string, error) { f.saveTitle = title f.saveMarkdown = markdown f.savePortions = portions @@ -1043,7 +1047,7 @@ func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown stri return "recipe-1", nil } -func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown string, portions int) (string, error) { +func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown string, portions float64) (string, error) { f.draftSaveID = draftID f.draftSaveMarkdown = markdown f.draftSavePortions = portions @@ -1055,7 +1059,7 @@ func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown strin return "recipe-1", nil } -func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error { +func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions float64) error { f.updateRecipeID = recipeID f.updateMarkdown = markdown f.updatePortions = portions