diff --git a/apps/ios/App/ContentView.swift b/apps/ios/App/ContentView.swift index 152af9999..e45ac135c 100644 --- a/apps/ios/App/ContentView.swift +++ b/apps/ios/App/ContentView.swift @@ -97,10 +97,16 @@ struct ContentView: View { private struct HomeRecommendationRoute: Identifiable, Hashable, Sendable { let ingredientIds: Set let ingredientNames: [String] - let preferredRecipeID: Int64? + let preferredID: ScoredRecipeID? var id: String { - let recipePart = preferredRecipeID.map(String.init) ?? "none" + let recipePart: String + switch preferredID { + case .legacy(let legacyID): recipePart = "recipe-\(legacyID)" + case .catalog(let catalogID): recipePart = "catalog-\(catalogID)" + case .unsaved(let title): recipePart = "unsaved-\(title)" + case nil: recipePart = "none" + } let ingredientPart = ingredientIds.sorted().map(String.init).joined(separator: ",") return "\(recipePart)|\(ingredientPart)|\(ingredientNames.joined(separator: ","))" } @@ -352,7 +358,7 @@ struct ContentView: View { RecipeResultsView( ingredientIds: route.ingredientIds, ingredientNames: route.ingredientNames, - preferredRecipeID: route.preferredRecipeID, + preferredID: route.preferredID, logsMeals: true, engine: deps.makeRecommendationEngine() ) @@ -618,7 +624,7 @@ struct ContentView: View { homeRecommendationRoute = HomeRecommendationRoute( ingredientIds: recommendation.ingredientIDs, ingredientNames: [], - preferredRecipeID: recommendation.recipeID + preferredID: recommendation.preferredID ) } @@ -736,15 +742,12 @@ struct ContentView: View { return } - let ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)] - if let recipeID = recipe.recipe.id { - ingredients = (try? deps.recipeRepository.ingredientsForRecipe(id: recipeID)) ?? [] - } else { - ingredients = [] - } + let context = LiveAssistantRecipeContextFactory( + recipeRepository: deps.recipeRepository + ).context(for: recipe) presentLiveAssistant( - recipeContext: LiveAssistantRecipeContext(scoredRecipe: recipe, ingredients: ingredients), + recipeContext: context, replaySpotlightOnAppear: true, marksTutorialProgressOnComplete: false ) diff --git a/apps/ios/Capability/Core/Intelligence/GeminiCloudAgent.swift b/apps/ios/Capability/Core/Intelligence/GeminiCloudAgent.swift index 233c24df4..1ad207899 100644 --- a/apps/ios/Capability/Core/Intelligence/GeminiCloudAgent.swift +++ b/apps/ios/Capability/Core/Intelligence/GeminiCloudAgent.swift @@ -220,10 +220,14 @@ final class GeminiCloudAgent: @unchecked Sendable { "\($0.label):\(Int(($0.confidence * 100).rounded()))" }.joined(separator: ", ") - let candidateSummary = candidates.prefix(8).compactMap { candidate -> String? in - guard let recipeID = candidate.recipe.recipe.id else { return nil } + // Every candidate rides under its handle — the legacy id when it has one, a + // negative alias when it is a catalog entry — so catalog recipes are ranked + // instead of being silently dropped, and the model's integer-keyed schema holds. + let handles = ReverseScanCandidateHandle.handles(for: candidates) + let candidateSummary = candidates.prefix(8).enumerated().compactMap { index, candidate -> String? in + guard index < handles.count else { return nil } return - "id=\(recipeID), title=\(candidate.recipe.recipe.title), local_conf=\(candidate.confidenceScore), missing_required=\(candidate.recipe.missingRequiredCount)" + "id=\(handles[index]), title=\(candidate.recipe.recipe.title), local_conf=\(candidate.confidenceScore), missing_required=\(candidate.recipe.missingRequiredCount)" }.joined(separator: "\n") guard !candidateSummary.isEmpty else { return nil } @@ -403,10 +407,14 @@ final class GeminiCloudAgent: @unchecked Sendable { guard let baseURL = config.backendBaseURL else { return nil } let endpoint = baseURL.appendingPathComponent("v1/reverse-scan/rank") - let candidatePayload = candidates.compactMap { candidate -> BackendReverseScanCandidate? in - guard let recipeID = candidate.recipe.recipe.id else { return nil } + // Catalog candidates ride under negative handles so the backend's integer + // recipe_id contract holds without a legacy row; the service maps rankings back + // through the same rule (ReverseScanCandidateHandle). + let handles = ReverseScanCandidateHandle.handles(for: candidates) + let candidatePayload = candidates.enumerated().compactMap { index, candidate -> BackendReverseScanCandidate? in + guard index < handles.count else { return nil } return BackendReverseScanCandidate( - recipeId: recipeID, + recipeId: handles[index], title: candidate.recipe.recipe.title, localConfidence: candidate.confidenceScore, missingRequiredCount: candidate.recipe.missingRequiredCount diff --git a/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift b/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift new file mode 100644 index 000000000..a7ef703ff --- /dev/null +++ b/apps/ios/Capability/Core/Intelligence/ReverseScanCandidateHandle.swift @@ -0,0 +1,25 @@ +import Foundation + +/// The stable integer handle a reverse-scan candidate rides under in the cloud ranking +/// round trip. Both the direct Gemini prompt and the backend `/v1/reverse-scan/rank` +/// contract key rankings on an integer `recipe_id`; catalog candidates (no legacy row, +/// `Recipe(id: nil, …)`) keep their identity under a negative handle — legacy ids are +/// always positive, so the two spaces never collide. The agent assigns handles when it +/// builds the request and the service maps rankings back through the same rule, so the +/// pairing lives in one tested place, not in an agreed-on convention. +enum ReverseScanCandidateHandle { + /// The handle for each candidate, in the order the candidates will be sent. Legacy + /// recipes keep their positive row id; catalog candidates number from -1 in their own + /// counter — a global position offset would leave holes (-2, -4, …) between legacy + /// entries and make the handle space look sparser than it is to the ranking backend. + static func handles(for candidates: [ReverseScanRecipeCandidate]) -> [Int64] { + var catalogHandle = Int64(0) + return candidates.map { candidate in + guard let id = candidate.recipe.recipe.id else { + catalogHandle += 1 + return -catalogHandle + } + return id + } + } +} diff --git a/apps/ios/Capability/Core/Services/ReverseScanService.swift b/apps/ios/Capability/Core/Services/ReverseScanService.swift index 90f4efc89..60eb7a285 100644 --- a/apps/ios/Capability/Core/Services/ReverseScanService.swift +++ b/apps/ios/Capability/Core/Services/ReverseScanService.swift @@ -145,12 +145,15 @@ final class ReverseScanService: Sendable { ), !cloudRankings.isEmpty { - let rankingByID = Dictionary( + let rankingByHandle = Dictionary( uniqueKeysWithValues: cloudRankings.map { ($0.recipeID, $0) }) - candidates = candidates.map { candidate in - guard let recipeID = candidate.recipe.recipe.id, - let cloud = rankingByID[recipeID] + // Rankings map back through the same handle rule the agent sent: the legacy + // id when the candidate has one, the negative alias when it is a catalog + // entry — so catalog candidates blend cloud confidence too. + let handles = ReverseScanCandidateHandle.handles(for: candidates) + candidates = candidates.enumerated().map { index, candidate in + guard index < handles.count, let cloud = rankingByHandle[handles[index]] else { return candidate } diff --git a/apps/ios/Feature/Assistant/LiveAssistantModels.swift b/apps/ios/Feature/Assistant/LiveAssistantModels.swift index 6c23f8692..2a8f6c438 100644 --- a/apps/ios/Feature/Assistant/LiveAssistantModels.swift +++ b/apps/ios/Feature/Assistant/LiveAssistantModels.swift @@ -1,3 +1,4 @@ +import FLFeatureLogic import Foundation import Observation @@ -24,6 +25,15 @@ struct LiveAssistantRecipeContext: Identifiable, Codable, Hashable, Sendable { let instructions: String let ingredients: [LiveAssistantIngredientContext] let matchedAt: Date + /// The catalog identity this session cooks at, when the recipe is a catalog recipe: + /// the cook session links the exact `(catalogId, revision)` that was cooked. nil keeps + /// the legacy integer-id session reference. + let catalogID: String? + let catalogRevision: Int? + /// The packet's structured steps — stated timers and thermometer checks — verbatim + /// from the installed bundle-v2 revision. nil for legacy recipes, which cook from the + /// parsed `instructions` text. + let catalogSteps: [CookStep]? init( recipeID: Int64?, @@ -32,7 +42,10 @@ struct LiveAssistantRecipeContext: Identifiable, Codable, Hashable, Sendable { servings: Int, instructions: String, ingredients: [LiveAssistantIngredientContext], - matchedAt: Date = .now + matchedAt: Date = .now, + catalogID: String? = nil, + catalogRevision: Int? = nil, + catalogSteps: [CookStep]? = nil ) { self.id = recipeID.map(String.init) ?? UUID().uuidString self.recipeID = recipeID @@ -42,6 +55,9 @@ struct LiveAssistantRecipeContext: Identifiable, Codable, Hashable, Sendable { self.instructions = instructions self.ingredients = ingredients self.matchedAt = matchedAt + self.catalogID = catalogID + self.catalogRevision = catalogRevision + self.catalogSteps = catalogSteps } } diff --git a/apps/ios/Feature/Assistant/LiveAssistantRecipeContextFactory.swift b/apps/ios/Feature/Assistant/LiveAssistantRecipeContextFactory.swift new file mode 100644 index 000000000..dec743f86 --- /dev/null +++ b/apps/ios/Feature/Assistant/LiveAssistantRecipeContextFactory.swift @@ -0,0 +1,56 @@ +import FLFeatureLogic +import Foundation + +/// Builds the LeChef recipe context for a scored result, over the same repository paths +/// the cooking guide and preview drawer use, so every surface that receives a +/// recommended recipe agrees on its content. A catalog result — the recommend pool +/// builds `Recipe(id: nil, …)` and carries the identity in the match's catalog ref — +/// resolves ingredients and structured steps from the catalog cook packet; a legacy +/// result keeps the legacy ingredient rows and free-text instructions. Extracted from +/// the views so tests exercise the exact production resolution. +struct LiveAssistantRecipeContextFactory { + let recipeRepository: RecipeRepository + + func context(for scoredRecipe: ScoredRecipe) -> LiveAssistantRecipeContext { + let ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)] + var catalogID: String? + var catalogRevision: Int? + var catalogSteps: [CookStep]? + var instructions = scoredRecipe.recipe.instructions + + if let packet = try? recipeRepository.catalogCookPacket(for: scoredRecipe) { + // Catalog entries have no legacy row: the context cooks the packet — structured + // steps (timers, thermometer checks) and the packet's bridged ingredient lines. + ingredients = (try? recipeRepository.cookPacketIngredients(packet)) ?? [] + catalogID = packet.catalogId + catalogRevision = packet.revision + catalogSteps = packet.steps + let stepText = packet.steps.map(\.text).joined(separator: "\n") + if !stepText.isEmpty { + instructions = stepText + } + } else if let recipeID = scoredRecipe.recipe.id { + ingredients = (try? recipeRepository.ingredientsForRecipe(id: recipeID)) ?? [] + } else { + ingredients = [] + } + + return LiveAssistantRecipeContext( + recipeID: scoredRecipe.recipe.id, + title: scoredRecipe.recipe.title, + timeMinutes: scoredRecipe.recipe.timeMinutes, + servings: scoredRecipe.recipe.servings, + instructions: instructions, + ingredients: ingredients.map { + LiveAssistantIngredientContext( + name: $0.ingredient.name, + quantityText: $0.quantity.displayQuantity, + quantityGrams: $0.quantity.quantityGrams + ) + }, + catalogID: catalogID, + catalogRevision: catalogRevision, + catalogSteps: catalogSteps + ) + } +} diff --git a/apps/ios/Feature/Assistant/LiveAssistantViewModel.swift b/apps/ios/Feature/Assistant/LiveAssistantViewModel.swift index 6d8d154b7..d94591219 100644 --- a/apps/ios/Feature/Assistant/LiveAssistantViewModel.swift +++ b/apps/ios/Feature/Assistant/LiveAssistantViewModel.swift @@ -168,9 +168,20 @@ final class LiveAssistantViewModel: ObservableObject { // The chat surface's cook session: in-memory, fail-closed on allergen knowledge // (unknown blocks, LeChef §7), no meal logging — this is not the persisted cook-mode - // surface, but proposals still execute against a real session. + // surface, but proposals still execute against a real session. A catalog recipe + // runs the packet's structured steps and links the exact (catalogId, revision); + // legacy recipes cook from the parsed instructions and the integer-id reference. var liveDependencies = CookModeViewModel.Dependencies.ephemeral() liveDependencies.substituteIsAllergen = { _ in nil } + let catalogContent: CatalogCookContent? + if let catalogID = recipeContext.catalogID, let revision = recipeContext.catalogRevision, + let steps = recipeContext.catalogSteps, !steps.isEmpty + { + catalogContent = CatalogCookContent( + catalogId: catalogID, revision: revision, steps: steps) + } else { + catalogContent = nil + } let cookViewModel = CookModeViewModel( recipe: Recipe( id: recipeContext.recipeID, @@ -182,7 +193,8 @@ final class LiveAssistantViewModel: ObservableObject { source: .bundled, createdAt: nil), logsMeal: false, - dependencies: liveDependencies) + dependencies: liveDependencies, + catalog: catalogContent) self.cookViewModel = cookViewModel let client = self.client diff --git a/apps/ios/Feature/Finalization/MealFinalizationSheet.swift b/apps/ios/Feature/Finalization/MealFinalizationSheet.swift index a1e948314..e5d8488c1 100644 --- a/apps/ios/Feature/Finalization/MealFinalizationSheet.swift +++ b/apps/ios/Feature/Finalization/MealFinalizationSheet.swift @@ -13,12 +13,17 @@ struct MealFinalizationSheet: View { recipe: Recipe, defaultServings: Int = 1, deps: AppDependencies, + catalogId: String? = nil, + catalogRevision: Int? = nil, onComplete: @escaping () -> Void ) { _vm = State( wrappedValue: MealFinalizationViewModel( recipe: recipe, defaultServings: defaultServings, + catalogId: catalogId, + catalogRevision: catalogRevision, + recipeRepository: deps.recipeRepository, personalizationService: deps.personalizationService, inventoryRepository: deps.inventoryRepository )) diff --git a/apps/ios/Feature/Finalization/MealFinalizationViewModel.swift b/apps/ios/Feature/Finalization/MealFinalizationViewModel.swift index 0e7374fda..0e6f4eb28 100644 --- a/apps/ios/Feature/Finalization/MealFinalizationViewModel.swift +++ b/apps/ios/Feature/Finalization/MealFinalizationViewModel.swift @@ -22,18 +22,30 @@ final class MealFinalizationViewModel { // MARK: - Context let recipe: Recipe + /// The catalog identity this meal cooked at, when the recipe is a catalog recipe: + /// the log links the exact `(catalogId, revision)`, so later revision bumps never + /// rewrite its nutrition. nil logs the legacy integer-id row. + private let catalogId: String? + private let catalogRevision: Int? + private let recipeRepository: RecipeRepository private let personalizationService: PersonalizationService private let inventoryRepository: InventoryRepository init( recipe: Recipe, defaultServings: Int = 1, + catalogId: String? = nil, + catalogRevision: Int? = nil, + recipeRepository: RecipeRepository, personalizationService: PersonalizationService, inventoryRepository: InventoryRepository ) { self.recipe = recipe self.servings = defaultServings + self.catalogId = catalogId + self.catalogRevision = catalogRevision + self.recipeRepository = recipeRepository self.personalizationService = personalizationService self.inventoryRepository = inventoryRepository } @@ -46,17 +58,20 @@ final class MealFinalizationViewModel { defer { isSaving = false } do { - guard let recipeId = recipe.id else { - errorMessage = "Recipe has no ID." - return - } + // Catalog entries carry no integer recipe id; the log lands on the legacy row the + // meal resolves to (a title match, or a minimal row when none exists), and links + // the (catalogId, revision) that was cooked so nutrition reports from the frozen + // per-revision snapshot. Legacy recipes keep their integer id unchanged. + let recipeId = try recipe.id ?? recipeRepository.resolvePersistedRecipeID(for: recipe) try personalizationService.recordCooking( recipeId: recipeId, rating: rating > 0 ? rating : nil, imagePath: photoPath, servingsConsumed: servings, - recipeSource: TasteRecipeSource(recipeSource: recipe.source) + recipeSource: TasteRecipeSource(recipeSource: recipe.source), + catalogId: catalogId, + catalogRevision: catalogRevision ) try inventoryRepository.applyConsumption( diff --git a/apps/ios/Feature/Home/HomeDashboardModels.swift b/apps/ios/Feature/Home/HomeDashboardModels.swift index 06c3019f4..a949f29a4 100644 --- a/apps/ios/Feature/Home/HomeDashboardModels.swift +++ b/apps/ios/Feature/Home/HomeDashboardModels.swift @@ -16,7 +16,10 @@ struct WeekdayCookingPoint: Identifiable, Sendable, Equatable { } struct HomeRecommendation: Identifiable, Sendable { - let recipeID: Int64? + /// The scored identity the card opens — a catalog ref when the recommendation is a + /// catalog recipe, the legacy integer id otherwise — so the Results screen + /// auto-presents this exact result instead of guessing it from list order. + let preferredID: ScoredRecipeID? let recipeName: String let explanation: String let cookTimeMinutes: Int? @@ -25,7 +28,12 @@ struct HomeRecommendation: Identifiable, Sendable { let ingredientIDs: Set var id: String { - recipeID.map(String.init) ?? recipeName + switch preferredID { + case .legacy(let legacyID): return "recipe-\(legacyID)" + case .catalog(let catalogID): return "catalog-\(catalogID)" + case .unsaved(let title): return "unsaved-\(title)" + case nil: return recipeName + } } } diff --git a/apps/ios/Feature/Home/HomeDashboardViewModel.swift b/apps/ios/Feature/Home/HomeDashboardViewModel.swift index f69246f80..27fa17130 100644 --- a/apps/ios/Feature/Home/HomeDashboardViewModel.swift +++ b/apps/ios/Feature/Home/HomeDashboardViewModel.swift @@ -102,7 +102,7 @@ final class HomeDashboardViewModel: ObservableObject { now: Date()) if let top = all.first { primary = HomeRecommendation( - recipeID: top.recipe.id, + preferredID: top.id, recipeName: top.recipe.title, explanation: top.rankingReasons.prefix(3).joined(separator: ". ") + ".", cookTimeMinutes: top.recipe.timeMinutes, @@ -113,7 +113,7 @@ final class HomeDashboardViewModel: ObservableObject { } fallbacks = Array(all.dropFirst().prefix(3)).map { scored in HomeRecommendation( - recipeID: scored.recipe.id, + preferredID: scored.id, recipeName: scored.recipe.title, explanation: scored.rankingReasons.prefix(2).joined(separator: ". "), cookTimeMinutes: scored.recipe.timeMinutes, diff --git a/apps/ios/Feature/Recipe/CookingGuideView.swift b/apps/ios/Feature/Recipe/CookingGuideView.swift index 7613a9559..d17526df9 100644 --- a/apps/ios/Feature/Recipe/CookingGuideView.swift +++ b/apps/ios/Feature/Recipe/CookingGuideView.swift @@ -37,6 +37,9 @@ struct CookingGuideView: View { } @State private var currentPage: Int = 0 + /// Structured guide steps for a catalog entry, loaded with the ingredients; the init-time + /// `guide` stays empty for catalog recipes because their content lives in the packet. + @State private var catalogGuide: CookingGuideSteps? @State private var ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)] = [] @State private var checkedIngredients: Set = [] @State private var completedSteps: Set = [] @@ -57,9 +60,25 @@ struct CookingGuideView: View { /// and safety checks and the meal links to the exact revision that was cooked. nil keeps /// the legacy parsed-instructions path. private var catalogContent: CatalogCookContent? { - guard let legacyId = scoredRecipe.recipe.id, - let catalogId = try? deps.recipeRepository.catalogRecipeID(forLegacyRecipeID: legacyId), - let packet = try? deps.recipeRepository.catalogCookPacket(catalogId: catalogId) + CookingGuideView.makeCatalogContent(for: scoredRecipe, in: deps.recipeRepository) + } + + /// Resolves the catalog content a guide cooks from for this scored result. A bridged + /// legacy recipe resolves through `recipes_catalog.legacy_recipe_id`; a catalog-native + /// result — the recommend pool builds `Recipe(id: nil, …)` and carries the identity in + /// its own match's catalog ref — resolves through that ref. Unsaved drafts (no integer + /// id, no catalog match) and recipes whose packet cannot load stay on the legacy + /// parsed-instructions path. Extracted so tests exercise the exact production gate. + static func makeCatalogContent( + for scoredRecipe: ScoredRecipe, in repository: RecipeRepository + ) -> CatalogCookContent? { + let catalogId: String? + if let legacyId = scoredRecipe.recipe.id { + catalogId = try? repository.catalogRecipeID(forLegacyRecipeID: legacyId) + } else { + catalogId = scoredRecipe.catalog?.ref.id + } + guard let catalogId, let packet = try? repository.catalogCookPacket(catalogId: catalogId) else { return nil } return CatalogCookContent( catalogId: packet.catalogId, @@ -82,7 +101,10 @@ struct CookingGuideView: View { .sorted { $0.originalIngredientId < $1.originalIngredientId } } - private var instructionSteps: [String] { guide.steps } + private var instructionSteps: [String] { + if let catalogGuide, !catalogGuide.steps.isEmpty { return catalogGuide.steps } + return guide.steps + } private var totalPages: Int { 1 + instructionSteps.count } private var totalSteps: Int { instructionSteps.count } @@ -314,6 +336,18 @@ struct CookingGuideView: View { } private func loadIngredients() async { + // Catalog entries carry no integer recipe id; their guide steps and ingredient + // section come from the structured catalog packet — the same repository path the + // preview drawer and the assistant context use — so all surfaces agree. Falls back + // to the legacy path for legacy recipes. + if let packet = try? deps.recipeRepository.catalogCookPacket(for: scoredRecipe), + !packet.steps.isEmpty + { + catalogGuide = CookingGuideSteps( + instructions: packet.steps.map(\.text).joined(separator: "\n")) + ingredients = (try? deps.recipeRepository.cookPacketIngredients(packet)) ?? [] + return + } guard let recipeId = recipe.id else { return } ingredients = (try? dependencies.ingredientsForRecipe(recipeId)) ?? [] } diff --git a/apps/ios/Feature/Recipe/RecipePreviewDrawer.swift b/apps/ios/Feature/Recipe/RecipePreviewDrawer.swift index e1b73ebc6..66489e1c1 100644 --- a/apps/ios/Feature/Recipe/RecipePreviewDrawer.swift +++ b/apps/ios/Feature/Recipe/RecipePreviewDrawer.swift @@ -196,74 +196,22 @@ struct RecipePreviewDrawer: View { // MARK: - Data Loading private func loadIngredients() async { - // Catalog entries carry no integer recipe id; their ingredient section comes from the - // structured catalog content. Lines whose key resolved to an app ingredient (exact - // hydration-time identity) render with that ingredient; keys with no bridge row stay - // out — they are "not in your kitchen" and are never matched by name. Falls back to - // the legacy instructions path when the packet is missing or empty. - if let catalogId = scoredRecipe.catalog?.ref.id, - let packet = try? deps.recipeRepository.catalogCookPacket(catalogId: catalogId), - !packet.lines.isEmpty + // Catalog entries carry no integer recipe id; their ingredient section comes from + // the structured catalog packet — the same repository path the cooking guide and the + // assistant context use — so all three surfaces agree. Falls back to the legacy + // instructions path when the packet is missing or empty. + if let packet = try? deps.recipeRepository.catalogCookPacket(for: scoredRecipe), + let mapped = try? deps.recipeRepository.cookPacketIngredients(packet), + !mapped.isEmpty { - let bridgedIDs = Set(packet.lines.compactMap(\.bridgedIngredientId)) - let ingredientsByID: [Int64: Ingredient] = - bridgedIDs.isEmpty - ? [:] - : (try? deps.recipeRepository.ingredients(withIDs: bridgedIDs)) ?? [:] - let mapped: [(ingredient: Ingredient, quantity: RecipeIngredient)] = packet.lines - .compactMap { line in - guard let id = line.bridgedIngredientId, let ingredient = ingredientsByID[id] - else { return nil } - return ( - ingredient, - RecipeIngredient( - recipeId: recipe.id ?? 0, - ingredientId: id, - isRequired: line.required, - quantityGrams: Self.quantityGrams(line.quantity) ?? 0, - displayQuantity: Self.quantityText(line.quantity) - ) - ) - } - if !mapped.isEmpty { - ingredients = mapped - return - } + ingredients = mapped + return } guard let recipeId = recipe.id else { return } ingredients = (try? deps.recipeRepository.ingredientsForRecipe(id: recipeId)) ?? [] } - /// Mass for the substitution sheet, only when the quantity carries evidence-backed grams. - private static func quantityGrams(_ quantity: CookQuantity) -> Double? { - switch quantity { - case .grams(let grams): - return grams - case .household(_, _, let grams), .count(_, _, let grams): - return grams - case .unknown: - return nil - } - } - - /// Renders the quantity as authored: grams weighed, otherwise the household unit text, - /// otherwise the recipe's own note ("to taste"). No synthesized units. - private static func quantityText(_ quantity: CookQuantity) -> String { - switch quantity { - case .grams(let grams): - let rounded = (grams * 10).rounded() / 10 - return rounded == rounded.rounded() - ? "\(Int(rounded)) g" : String(format: "%.1f g", rounded) - case .count(let amount, let unit, _), .household(let amount, let unit, _): - let amountText = - amount == amount.rounded() ? String(Int(amount)) : String(format: "%.2g", amount) - return unit.isEmpty ? amountText : "\(amountText) \(unit)" - case .unknown(let note): - return note ?? "" - } - } - private func loadExistingPhoto() async { guard let recipeId = recipe.id else { return } if let path = try? deps.userDataRepository.latestPhotoPath(forRecipeId: recipeId) { diff --git a/apps/ios/Feature/Results/PreferredRecipeResolver.swift b/apps/ios/Feature/Results/PreferredRecipeResolver.swift new file mode 100644 index 000000000..9497fd86d --- /dev/null +++ b/apps/ios/Feature/Results/PreferredRecipeResolver.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Resolves the identity a screen passes in — a Home recommendation card, a demo +/// scenario, a replay — to the exact scored result to auto-present. Catalog entries +/// carry no legacy row, so matching runs on the scored identity +/// (`ScoredRecipeID`), never on list order or the integer column alone. +enum PreferredRecipeResolver { + /// The first result whose scored identity matches, keeping list order for ties. + static func resolve( + preferredID: ScoredRecipeID?, + in recommendations: [ScoredRecipe] + ) -> ScoredRecipe? { + guard let preferredID else { return nil } + return recommendations.first { $0.id == preferredID } + } +} diff --git a/apps/ios/Feature/Results/RecipeResultsView.swift b/apps/ios/Feature/Results/RecipeResultsView.swift index cdd429a3a..5533decd4 100644 --- a/apps/ios/Feature/Results/RecipeResultsView.swift +++ b/apps/ios/Feature/Results/RecipeResultsView.swift @@ -17,7 +17,7 @@ struct RecipeResultsView: View { let ingredientNames: [String] let fridgePhoto: UIImage? let scanConfidenceScore: Double? - let preferredRecipeID: Int64? + let preferredID: ScoredRecipeID? let includesKitchenInventory: Bool /// False for demo and tutorial results: cooking through them is practice and must not add a /// meal to history or take food out of the real Kitchen. @@ -40,7 +40,7 @@ struct RecipeResultsView: View { ingredientNames: [String] = [], fridgePhoto: UIImage? = nil, scanConfidenceScore: Double? = nil, - preferredRecipeID: Int64? = nil, + preferredID: ScoredRecipeID? = nil, includesKitchenInventory: Bool = false, logsMeals: Bool, engine: RecommendationEngine, @@ -50,7 +50,7 @@ struct RecipeResultsView: View { self.ingredientNames = ingredientNames self.fridgePhoto = fridgePhoto self.scanConfidenceScore = scanConfidenceScore - self.preferredRecipeID = preferredRecipeID + self.preferredID = preferredID self.includesKitchenInventory = includesKitchenInventory self.logsMeals = logsMeals _replaySpotlightPending = State(initialValue: replaySpotlightOnAppear) @@ -362,16 +362,15 @@ struct RecipeResultsView: View { } private func storeRecipeForAssistant(_ scored: ScoredRecipe) { - let ingredients: [(ingredient: Ingredient, quantity: RecipeIngredient)] - if let recipeID = scored.recipe.id { - ingredients = (try? deps.recipeRepository.ingredientsForRecipe(id: recipeID)) ?? [] - } else { - ingredients = [] - } + // One factory for every surface: catalog results resolve the context from the + // structured packet, legacy results from the legacy rows. + let context = LiveAssistantRecipeContextFactory( + recipeRepository: deps.recipeRepository + ).context(for: scored) liveAssistantCoordinator.storeRecipeMatch( scoredRecipe: scored, - context: LiveAssistantRecipeContext(scoredRecipe: scored, ingredients: ingredients) + context: context ) } @@ -398,11 +397,9 @@ struct RecipeResultsView: View { private func autoPresentPreferredRecipeIfNeeded() { guard - let preferredRecipeID, !didPresentPreferredRecipe, - let preferredRecipe = engine.recommendations.first(where: { - $0.recipe.id == preferredRecipeID - }) + let preferredRecipe = PreferredRecipeResolver.resolve( + preferredID: preferredID, in: engine.recommendations) else { return } diff --git a/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalog.swift b/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalog.swift index 966a60982..471b4efb8 100644 --- a/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalog.swift +++ b/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalog.swift @@ -343,4 +343,71 @@ extension RecipeRepository { ) } } + + /// The catalog cook packet for a recommendation drawn from the catalog pool, or nil + /// for legacy-only results and unknown refs. This is the single path the cooking guide + /// and the assistant context use to cook a catalog recommendation; legacy results keep + /// the legacy instructions path. + func catalogCookPacket(for scoredRecipe: ScoredRecipe) throws -> RecipeCatalogCookPacket? { + guard let catalogId = scoredRecipe.catalog?.ref.id else { return nil } + return try catalogCookPacket(catalogId: catalogId) + } + + /// The packet's bridged ingredient list in the legacy `(Ingredient, RecipeIngredient)` + /// shape the cooking guide and assistant context consume. Only lines whose key resolved + /// to an app ingredient at hydration appear — unresolved "not in your kitchen" keys are + /// never matched by name. Quantities render as authored: grams weighed, otherwise the + /// household unit text, otherwise the recipe's own note. No synthesized units. + func cookPacketIngredients( + _ packet: RecipeCatalogCookPacket + ) throws -> [(ingredient: Ingredient, quantity: RecipeIngredient)] { + let bridgedIDs = Set(packet.lines.compactMap(\.bridgedIngredientId)) + let ingredientsByID: [Int64: Ingredient] = + bridgedIDs.isEmpty + ? [:] + : try ingredients(withIDs: bridgedIDs) + return packet.lines.compactMap { line in + guard let id = line.bridgedIngredientId, let ingredient = ingredientsByID[id] + else { return nil } + return ( + ingredient, + RecipeIngredient( + recipeId: 0, + ingredientId: id, + isRequired: line.required, + quantityGrams: Self.quantityGrams(line.quantity) ?? 0, + displayQuantity: Self.quantityText(line.quantity) + ) + ) + } + } + + /// Mass for a quantity, only when it carries evidence-backed grams. + private static func quantityGrams(_ quantity: CookQuantity) -> Double? { + switch quantity { + case .grams(let grams): + return grams + case .household(_, _, let grams), .count(_, _, let grams): + return grams + case .unknown: + return nil + } + } + + /// Renders the quantity as authored: grams weighed, otherwise the household unit text, + /// otherwise the recipe's own note ("to taste"). No synthesized units. + private static func quantityText(_ quantity: CookQuantity) -> String { + switch quantity { + case .grams(let grams): + let rounded = (grams * 10).rounded() / 10 + return rounded == rounded.rounded() + ? "\(Int(rounded)) g" : String(format: "%.1f g", rounded) + case .count(let amount, let unit, _), .household(let amount, let unit, _): + let amountText = + amount == amount.rounded() ? String(Int(amount)) : String(format: "%.2g", amount) + return unit.isEmpty ? amountText : "\(amountText) \(unit)" + case .unknown(let note): + return note ?? "" + } + } } diff --git a/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalogRecommend.swift b/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalogRecommend.swift index 30226c066..0d6986c4a 100644 --- a/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalogRecommend.swift +++ b/apps/ios/Platform/Persistence/Repository/RecipeRepositoryCatalogRecommend.swift @@ -90,6 +90,12 @@ extension RecipeRepository { continue case "vegan" where claims?.vegan != true: continue + case "keto": + // Fail closed: the claims schema has no low-carb field, so no catalog recipe + // can prove keto compliance. The legacy pool enforces the .lowCarb tag mask, + // so without this arm a keto user loses every legacy result yet keeps every + // catalog result. Excluded until the data pipeline ships a low_carb claim. + continue default: break } diff --git a/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift b/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift index 5619e4236..1df27a517 100644 --- a/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift +++ b/apps/ios/Platform/Persistence/Repository/UserDataRepository.swift @@ -1,3 +1,4 @@ +import FLFeatureLogic import Foundation import GRDB @@ -449,11 +450,35 @@ final class UserDataRepository: Sendable { // MARK: - Macro Helpers - /// Compute absolute macros consumed for a single cooking event. + /// Compute absolute macros consumed for a single cooking event. A catalog-linked meal + /// reports the frozen per-serving snapshot at the revision it was logged against + /// (`recipes_cooking_catalog_refs` → `recipes_catalog_nutrition` — same join as + /// `RecipeRepository.catalogNutrition(forHistoryID:)`, keep the two in step), scaled by + /// what was actually consumed. Legacy cooks keep the ingredient math below: the bundle + /// apply never writes or rewrites legacy `recipes`/`recipe_ingredients` rows. private static func computeConsumedMacros( db: Database, historyId: Int64, recipeId: Int64, recipeServings: Int, servingsConsumed: Int, portionMultiplier: Double ) throws -> MacroTotals { + if let json = try String.fetchOne( + db, + sql: """ + SELECT n.nutrition + FROM recipes_cooking_catalog_refs r + JOIN recipes_catalog_nutrition n + ON n.catalog_id = r.catalog_id AND n.revision = r.revision + WHERE r.history_id = ? + """, + arguments: [historyId] + ) { + let snapshot = try JSONDecoder().decode(RecipeCatalogNutrition.self, from: Data(json.utf8)) + let consumedFactor = Double(servingsConsumed) * portionMultiplier + return MacroTotals( + calories: snapshot.kcal * consumedFactor, + protein: snapshot.proteinG * consumedFactor, + carbs: snapshot.carbsG * consumedFactor, + fat: snapshot.fatG * consumedFactor) + } let row = try Row.fetchOne( db, sql: """ diff --git a/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift b/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift index 65c75e95f..08a7b4b75 100644 --- a/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift +++ b/apps/ios/Tests/CatalogCookFlow/CatalogCookFlowTests.swift @@ -402,4 +402,98 @@ final class CatalogCookFlowTests: XCTestCase { XCTAssertEqual(active.session.recipeRef?.revision, 1) _ = repository } + + // MARK: - The guide's production gate and the journal read path + + /// The recommend pool builds catalog results as `Recipe(id: nil, …)`; the guide must + /// still resolve the packet through the result's own catalog ref, or catalog-native + /// recipes silently cook the legacy path with a synthetic integer ref. + func testGuideCatalogContentResolvesForCatalogNativeResultsWithoutLegacyID() throws { + let db = try makeDatabase() + try hydrate(db, revision: 1, kcal: 420) + let (repository, _, _) = try makeStack(db) + let scored = ScoredRecipe( + recipe: catalogRecipe(), + matchedRequired: 2, + totalRequired: 2, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 420, proteinPerServing: 24, carbsPerServing: 4, fatPerServing: 30, + fiberPerServing: 1, sugarPerServing: 0, sodiumPerServing: 520), + healthScore: HealthScore(rating: 3, label: "Moderate", reasoning: ""), + personalScore: 0, + rankingScore: 0, + rankingReasons: [], + matchTier: .exact, + catalog: CatalogRecipeMatch( + ref: .catalog(id: Self.catalogId, revision: 1), successorToLegacyRecipeID: nil)) + + let content = try XCTUnwrap(CookingGuideView.makeCatalogContent(for: scored, in: repository)) + XCTAssertEqual(content.catalogId, Self.catalogId) + XCTAssertEqual(content.revision, 1) + XCTAssertEqual(content.steps.count, 2, "the structured packet steps, not parsed legacy text") + } + + /// A legacy recipe bridged to its catalog successor cooks from the catalog packet too. + func testGuideCatalogContentResolvesThroughTheLegacyBridge() throws { + let db = try makeDatabase() + try hydrate(db, revision: 1, kcal: 420) + let (repository, _, _) = try makeStack(db) + try db.write { db in + try db.execute( + sql: """ + INSERT INTO recipes (id, title, time_minutes, servings, instructions, tags, source) + VALUES (1, 'Legacy Scramble', 12, 2, 'Beat the eggs.', 0, 'bundled') + """) + try db.execute( + sql: "UPDATE recipes_catalog SET legacy_recipe_id = 1 WHERE catalog_id = ?", + arguments: [Self.catalogId]) + } + let scored = ScoredRecipe( + recipe: Recipe( + id: 1, title: "Legacy Scramble", timeMinutes: 12, servings: 2, + instructions: "Beat the eggs.", tags: 0, source: .bundled, createdAt: nil), + matchedRequired: 2, + totalRequired: 2, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 420, proteinPerServing: 24, carbsPerServing: 4, fatPerServing: 30, + fiberPerServing: 1, sugarPerServing: 0, sodiumPerServing: 520), + healthScore: HealthScore(rating: 3, label: "Moderate", reasoning: ""), + personalScore: 0, + rankingScore: 0, + rankingReasons: [], + matchTier: .exact) + + let content = try XCTUnwrap(CookingGuideView.makeCatalogContent(for: scored, in: repository)) + XCTAssertEqual(content.catalogId, Self.catalogId, "the bridge resolves the packet") + XCTAssertEqual(content.revision, 1) + } + + /// The journal reports the frozen per-serving snapshot the meal was logged against — + /// not the legacy ingredient math, which has no rows for catalog-native recipes. + func testJournalReportsTheFrozenSnapshotForCatalogMeals() throws { + let db = try makeDatabase() + try hydrate(db, revision: 1, kcal: 420) + let (repository, _, mealLog) = try makeStack(db) + let clock = Date(timeIntervalSince1970: 1_790_000_000) + let deps = makeDependencies(db: db, mealLog: mealLog, clock: { clock }) + + _ = try cookToRememberedMeal(db: db, deps: deps, repository: repository) + + let consumed = try db.read { + try Int.fetchOne($0, sql: "SELECT servings_consumed FROM cooking_history LIMIT 1") + } ?? 2 + let journal = try UserDataRepository(db: db).cookingJournal() + XCTAssertEqual(journal.count, 1) + XCTAssertEqual( + journal[0].macrosConsumed.calories, 420 * Double(consumed), accuracy: 0.5, + "the journal reads the frozen per-serving snapshot, not the legacy rows") + XCTAssertEqual(journal[0].macrosConsumed.protein, 24 * Double(consumed), accuracy: 0.5) + } + } diff --git a/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift b/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift new file mode 100644 index 000000000..9109c8030 --- /dev/null +++ b/apps/ios/Tests/CatalogCookFlow/LiveAssistantContextAndFinalizationTests.swift @@ -0,0 +1,307 @@ +import FLFeatureLogic +import GRDB +import XCTest + +@testable import FridgeLuck + +/// The surfaces a recommended recipe reaches — LeChef's context and cook session, and the +/// meal finalization log — resolve a catalog result through its packet instead of the +/// legacy integer column. Over a hydrated bundle-v2 fixture: the context carries the +/// packet's structured steps (stated timer, cited thermometer check) and the exact +/// (catalogId, revision); the finalization log for a recipe with no persisted id still +/// records a history row linked to the (catalogId, revision) that was cooked, so a later +/// revision bump cannot rewrite what the meal says it was. Harness mirrors +/// CatalogCookFlowTests: real loader, real migrations, real repositories. +@MainActor +final class LiveAssistantContextAndFinalizationTests: XCTestCase { + // MARK: - Fixtures + + private static let catalogId = "rcp-900001" + + private func makeDatabase() throws -> DatabaseQueue { + let db = try DatabaseQueue() + try DatabaseMigrations.migrate(db) + return db + } + + private func hydrate(_ db: DatabaseQueue, revision: Int) throws { + let bundle = try RecipeBundleV2.decode(from: Data(Self.bundleJSON(revision: revision).utf8)) + try db.write { db in + try RecipeBundleV2Loader.apply(bundle: bundle, marker: "live-assistant-context-tests", db: db) + } + } + + private func makeRepository(_ db: DatabaseQueue) throws -> RecipeRepository { + let nutritionService = NutritionService(db: db) + let healthScoring = HealthScoringService(nutritionService: nutritionService, db: db) + let personalization = PersonalizationService(db: db) + return RecipeRepository( + db: db, + nutritionService: nutritionService, + healthScoringService: healthScoring, + personalizationService: personalization) + } + + /// The recipe object the recommend pool builds for a catalog entry: no persisted id. + private func catalogRecipe() -> Recipe { + Recipe( + id: nil, + title: "Catalog Scramble", + timeMinutes: 12, + servings: 2, + instructions: "Beat the eggs.", // legacy fallback text; catalog steps win + tags: 0, + source: .bundled, + createdAt: nil) + } + + private func scored(_ recipe: Recipe, catalogID: String?, revision: Int) -> ScoredRecipe { + var scored = ScoredRecipe( + recipe: recipe, + matchedRequired: 1, + totalRequired: 1, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 58, proteinPerServing: 24, carbsPerServing: 4, + fatPerServing: 30, fiberPerServing: 1, sugarPerServing: 1, sodiumPerServing: 520), + healthScore: HealthScore(rating: 4, label: "Good", reasoning: "test"), + personalScore: 0.5, + rankingScore: 1.0, + rankingReasons: [], + matchTier: .exact) + if let catalogID { + scored.catalog = CatalogRecipeMatch( + ref: .catalog(id: catalogID, revision: revision), successorToLegacyRecipeID: nil) + } + return scored + } + + private func count(_ db: DatabaseQueue, _ sql: String) throws -> Int { + try db.read { try Int.fetchOne($0, sql: sql) ?? 0 } + } + + // MARK: - Factory: catalog context + + func testFactoryBuildsContextFromTheCatalogPacket() throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let repository = try makeRepository(db) + + let scored = scored( + catalogRecipe(), catalogID: Self.catalogId, revision: 3) + let context = LiveAssistantRecipeContextFactory(recipeRepository: repository) + .context(for: scored) + + XCTAssertEqual(context.catalogID, Self.catalogId) + XCTAssertEqual(context.catalogRevision, 3) + XCTAssertEqual(context.recipeID, nil) // catalog entries carry no legacy row + XCTAssertEqual(context.catalogSteps?.count, 2) + XCTAssertEqual(context.catalogSteps?[0].timerSeconds, 90) + XCTAssertEqual(context.catalogSteps?[1].thermometer?.endpointC, 74) + // The cook session runs the packet's steps, so the context text is the packet's + // own wording — not the legacy fallback instruction. + XCTAssertEqual( + context.instructions, "Beat the eggs in a bowl.\nCook gently until the centre just sets.") + } + + func testFactoryKeepsLegacyInstructionsForAnUnknownCatalogRef() throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let repository = try makeRepository(db) + + let scored = scored(catalogRecipe(), catalogID: "rcp-does-not-exist", revision: 1) + let context = LiveAssistantRecipeContextFactory(recipeRepository: repository) + .context(for: scored) + + XCTAssertNil(context.catalogID) + XCTAssertNil(context.catalogRevision) + XCTAssertNil(context.catalogSteps) + XCTAssertEqual(context.instructions, "Beat the eggs.") + } + + func testFactoryKeepsLegacyPathForAResultWithAnIntegerID() throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let repository = try makeRepository(db) + + var recipe = catalogRecipe() + recipe.id = 42 + let scored = scored(recipe, catalogID: nil, revision: 3) + let context = LiveAssistantRecipeContextFactory(recipeRepository: repository) + .context(for: scored) + + XCTAssertEqual(context.recipeID, 42) + XCTAssertNil(context.catalogID) + XCTAssertNil(context.catalogSteps) + XCTAssertEqual(context.instructions, "Beat the eggs.") + } + + // MARK: - Finalization: catalog log + + func testFinalizationLogsACatalogRecipeWithNoLegacyIDAgainstTheCookedRevision() async throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let nutritionService = NutritionService(db: db) + let healthScoring = HealthScoringService(nutritionService: nutritionService, db: db) + let personalization = PersonalizationService(db: db) + let repository = RecipeRepository( + db: db, + nutritionService: nutritionService, + healthScoringService: healthScoring, + personalizationService: personalization) + + let vm = MealFinalizationViewModel( + recipe: catalogRecipe(), + defaultServings: 2, + catalogId: Self.catalogId, + catalogRevision: 3, + recipeRepository: repository, + personalizationService: personalization, + inventoryRepository: InventoryRepository(db: db)) + + await vm.save() + + XCTAssertNil(vm.errorMessage) + XCTAssertTrue(vm.didSave) + + // The catalog entry has no integer id, so the log resolves a legacy row to anchor + // the foreign key, and links the exact (catalogId, revision) that was cooked. + let historyRows = try count(db, "SELECT COUNT(*) FROM cooking_history") + XCTAssertEqual(historyRows, 1) + let refRows = try count( + db, + """ + SELECT COUNT(*) FROM recipes_cooking_catalog_refs + WHERE catalog_id = '\(Self.catalogId)' AND revision = 3 + """) + XCTAssertEqual(refRows, 1) + } + + func testFinalizationLogsALegacyRecipeWithoutACatalogRef() async throws { + let db = try makeDatabase() + try hydrate(db, revision: 3) + let nutritionService = NutritionService(db: db) + let healthScoring = HealthScoringService(nutritionService: nutritionService, db: db) + let personalization = PersonalizationService(db: db) + let repository = RecipeRepository( + db: db, + nutritionService: nutritionService, + healthScoringService: healthScoring, + personalizationService: personalization) + + var recipe = catalogRecipe() + // A legacy finalization carries a persisted recipes.id — the same contract the sheet + // sees in production, where the recipe object came from the store. The resolver + // returns the hydrated legacy mirror row's id (title match), so the FK holds. + recipe.id = try repository.resolvePersistedRecipeID(for: catalogRecipe()) + let vm = MealFinalizationViewModel( + recipe: recipe, + defaultServings: 1, + recipeRepository: repository, + personalizationService: personalization, + inventoryRepository: InventoryRepository(db: db)) + + await vm.save() + + XCTAssertNil(vm.errorMessage) + XCTAssertTrue(vm.didSave) + XCTAssertEqual(try count(db, "SELECT COUNT(*) FROM cooking_history"), 1) + XCTAssertEqual( + try count(db, "SELECT COUNT(*) FROM recipes_cooking_catalog_refs"), 0) + } +} + +extension LiveAssistantContextAndFinalizationTests { + /// Two-ingredient, two-step catalog recipe: step 1 carries a 90 s timer, step 2 a + /// checkable thermometer endpoint with its pinned source. The schema requires at least + /// 2 ingredient lines and 2 steps (RecipeBundleV2 compile-time validation), so the + /// fixture carries butter alongside the egg. Same fixture shape as CatalogCookFlowTests. + fileprivate static func bundleJSON(revision: Int) -> String { + """ + { + "schema": "https://fridgeluck.app/schemas/recipe-bundle-v2.schema.json", + "bundle_version": 2, + "generated_at": "2026-10-16T00:00:00Z", + "cuisines": ["american", "asian", "indian", "italian", "mediterranean", "mexican"], + "ingredients": [ + { + "key": "egg", + "fdc_id": null, + "legacy_id": null, + "display_name": "Egg", + "category": "dairy_egg", + "per_100g": null, + "aliases": ["eggs"] + }, + { + "key": "butter", + "fdc_id": null, + "legacy_id": null, + "display_name": "Butter", + "category": "dairy_egg", + "per_100g": null, + "aliases": [] + } + ], + "recipes": [ + { + "catalog_id": "rcp-900001", + "revision": \(revision), + "title": "Catalog Scramble", + "cuisine": "american", + "meal_slots": ["breakfast"], + "collections": ["quick"], + "minutes": 12, + "servings": 2, + "equipment": [], + "ingredients": [ + { + "key": "egg", + "quantity": {"kind": "count", "amount": 2, "unit": "large", "grams": 100}, + "required": true, + "assumed_staple": false, + "note": null + }, + { + "key": "butter", + "quantity": {"kind": "household", "amount": 1, "unit": "tbsp", "grams": 14}, + "required": true, + "assumed_staple": false, + "note": null + } + ], + "steps": [ + { + "text": "Beat the eggs in a bowl.", + "uses": [0], + "timer_seconds": 90, + "thermometer": null + }, + { + "text": "Cook gently until the centre just sets.", + "uses": [0], + "timer_seconds": null, + "thermometer": {"endpoint_c": 74, "food": "scramble centre", "source": "usda-fsis: egg dishes 160 F"} + } + ], + "claims": {"vegetarian": true, "vegan": false, "gluten_free": true, "dairy_free": false}, + "nutrition_per_serving": { + "kcal": 58, + "protein_g": 24, + "carbs_g": 4, + "fat_g": 30, + "fiber_g": 1, + "sodium_mg": 520, + "coverage": "estimated", + "basis": "test fixture: household-to-gram estimates" + }, + "rights": {"status": "original", "evidence": "test fixture: original wording"}, + "keywords": [] + } + ] + } + """ + } +} diff --git a/apps/ios/Tests/RecipeCatalogRecommendTests.swift b/apps/ios/Tests/RecipeCatalogRecommendTests.swift index d6b980906..422ccb79a 100644 --- a/apps/ios/Tests/RecipeCatalogRecommendTests.swift +++ b/apps/ios/Tests/RecipeCatalogRecommendTests.swift @@ -185,6 +185,49 @@ final class RecipeCatalogRecommendTests: XCTestCase { XCTAssertNil(try repo.catalogCookPacket(catalogId: "rcp-does-not-exist")) } + // MARK: - Review round (PR #172) + + // Review finding 1: a catalog recommendation must yield cookable content through the + // repository — structured steps for the guide and its bridged ingredient list for the + // cooking guide and assistant context — instead of dead-ending on the nil-id legacy + // stand-in (empty instructions, no ingredients). + func testCatalogRecommendationYieldsCookPacketAndBridgedIngredients() throws { + let repo = try makeRepository() + let results = try repo.findMakeable(with: [1, 2], profile: .default) + let catalogResult = try XCTUnwrap( + results.first { $0.catalog != nil && $0.recipe.id == nil }, + "the pantry should match a standalone catalog recipe") + + let packet = try XCTUnwrap(try repo.catalogCookPacket(for: catalogResult)) + XCTAssertEqual(packet.title, catalogResult.recipe.title) + XCTAssertFalse(packet.steps.isEmpty, "the guide needs structured steps") + XCTAssertFalse( + packet.steps.map(\.text).joined(separator: "\n").isEmpty, + "guide instructions must not be empty for a catalog recipe") + + let ingredients = try repo.cookPacketIngredients(packet) + XCTAssertFalse( + ingredients.isEmpty, + "bridged lines must surface as the ingredient list; empty means Le Chef sees nothing") + for (ingredient, quantity) in ingredients { + XCTAssertFalse(ingredient.name.isEmpty) + XCTAssertEqual(quantity.ingredientId, ingredient.id) + XCTAssertFalse(quantity.displayQuantity.isEmpty) + } + } + + // Review finding 2: keto must fail closed on the catalog pool. The claims schema has + // no low-carb field, so no catalog recipe can prove compliance, while the legacy pool + // enforces the .lowCarb tag mask — the asymmetry would empty the legacy pool and leave + // the catalog pool fully ungated. + func testKetoDietExcludesCatalogPoolFailingClosed() throws { + let repo = try makeRepository() + let results = try repo.findMakeable(with: [1, 2], profile: profileWithDiet("keto")) + XCTAssertFalse( + results.contains { $0.catalog != nil }, + "keto must exclude the catalog pool: no low-carb claim exists to prove compliance") + } + // The bridge rebuild: legacy_id wins when the app ingredient exists; fdc_id resolves // through the USDA source row's exact name and aliases; unresolved keys stay unmapped. func testBridgeRebuildResolvesByLegacyIDThenFDCIdentity() throws { diff --git a/apps/ios/Tests/Results/PreferredRecipeResolverTests.swift b/apps/ios/Tests/Results/PreferredRecipeResolverTests.swift new file mode 100644 index 000000000..aa211af3e --- /dev/null +++ b/apps/ios/Tests/Results/PreferredRecipeResolverTests.swift @@ -0,0 +1,82 @@ +import XCTest + +@testable import FridgeLuck + +/// The resolver maps an incoming preferred identity — a Home card, a demo scenario, a +/// replay — to the exact scored result: the legacy id when the entry has one, the +/// catalog ref when it does not. Catalog entries carry `Recipe(id: nil, …)`, so identity +/// can never ride the integer column alone. +final class PreferredRecipeResolverTests: XCTestCase { + private func fixture( + id: Int64?, + title: String, + catalogID: String? = nil + ) -> ScoredRecipe { + var scored = ScoredRecipe( + recipe: Recipe( + id: id, + title: title, + timeMinutes: 10, + servings: 2, + instructions: "Cook.", + tags: 0, + source: .bundled, + createdAt: nil), + matchedRequired: 1, + totalRequired: 1, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 100, proteinPerServing: 5, carbsPerServing: 10, + fatPerServing: 3, fiberPerServing: 1, sugarPerServing: 1, sodiumPerServing: 50), + healthScore: HealthScore(rating: 4, label: "Good", reasoning: "test"), + personalScore: 0.5, + rankingScore: 1.0, + rankingReasons: [], + matchTier: .exact) + if let catalogID { + scored.catalog = CatalogRecipeMatch( + ref: .catalog(id: catalogID, revision: 1), successorToLegacyRecipeID: nil) + } + return scored + } + + func testNilPreferredPresentsNothing() { + let results = [fixture(id: 1, title: "Legacy")] + XCTAssertNil(PreferredRecipeResolver.resolve(preferredID: nil, in: results)) + } + + func testLegacyIdentityResolvesByIntegerID() { + let legacy = fixture(id: 7, title: "Legacy Pancakes") + let catalog = fixture(id: nil, title: "Catalog Pancakes", catalogID: "rcp-000123") + let resolved = PreferredRecipeResolver.resolve( + preferredID: .legacy(7), in: [catalog, legacy]) + XCTAssertEqual(resolved?.id, .legacy(7)) + XCTAssertEqual(resolved?.recipe.title, "Legacy Pancakes") + } + + func testCatalogIdentityResolvesByRefWhenRecipeIDIsNil() { + let legacy = fixture(id: 7, title: "Legacy Pancakes") + let catalog = fixture(id: nil, title: "Catalog Pancakes", catalogID: "rcp-000123") + let resolved = PreferredRecipeResolver.resolve( + preferredID: .catalog("rcp-000123"), in: [legacy, catalog]) + XCTAssertEqual(resolved?.id, .catalog("rcp-000123")) + XCTAssertEqual(resolved?.recipe.title, "Catalog Pancakes") + } + + func testUnmatchedIdentityReturnsNil() { + let results = [fixture(id: 7, title: "Legacy Pancakes")] + XCTAssertNil(PreferredRecipeResolver.resolve(preferredID: .legacy(8), in: results)) + XCTAssertNil( + PreferredRecipeResolver.resolve(preferredID: .catalog("rcp-404"), in: results)) + } + + func testFirstMatchWinsOnDuplicateIdentities() { + let first = fixture(id: 7, title: "First") + let second = fixture(id: 7, title: "Second") + let resolved = PreferredRecipeResolver.resolve( + preferredID: .legacy(7), in: [first, second]) + XCTAssertEqual(resolved?.recipe.title, "First") + } +} diff --git a/apps/ios/Tests/ReverseScan/ReverseScanCandidateHandleTests.swift b/apps/ios/Tests/ReverseScan/ReverseScanCandidateHandleTests.swift new file mode 100644 index 000000000..ffed6532b --- /dev/null +++ b/apps/ios/Tests/ReverseScan/ReverseScanCandidateHandleTests.swift @@ -0,0 +1,85 @@ +import XCTest + +@testable import FridgeLuck + +/// Cloud ranking keys every round trip on an integer `recipe_id`. Legacy candidates ride +/// their real positive id; catalog candidates — `Recipe(id: nil, …)` — ride a negative +/// alias derived from list position. The two spaces cannot collide, and the agent and +/// the service both read the rule from `ReverseScanCandidateHandle`, so a ranking can +/// never map back to the wrong candidate. +final class ReverseScanCandidateHandleTests: XCTestCase { + private func candidate(id: Int64?, title: String, catalogID: String? = nil) + -> ReverseScanRecipeCandidate + { + var scored = ScoredRecipe( + recipe: Recipe( + id: id, + title: title, + timeMinutes: 10, + servings: 2, + instructions: "Cook.", + tags: 0, + source: .bundled, + createdAt: nil), + matchedRequired: 1, + totalRequired: 1, + matchedOptional: 0, + missingRequiredCount: 0, + missingIngredientIds: [], + macros: RecipeMacros( + caloriesPerServing: 100, proteinPerServing: 5, carbsPerServing: 10, + fatPerServing: 3, fiberPerServing: 1, sugarPerServing: 1, sodiumPerServing: 50), + healthScore: HealthScore(rating: 4, label: "Good", reasoning: "test"), + personalScore: 0.5, + rankingScore: 1.0, + rankingReasons: [], + matchTier: .exact) + if let catalogID { + scored.catalog = CatalogRecipeMatch( + ref: .catalog(id: catalogID, revision: 1), successorToLegacyRecipeID: nil) + } + return ReverseScanRecipeCandidate( + recipe: scored, confidenceScore: 0.5, matchedDetectedIngredients: 1, explanation: nil) + } + + func testLegacyCandidatesKeepTheirPositiveID() { + let handles = ReverseScanCandidateHandle.handles(for: [ + candidate(id: 12, title: "A"), candidate(id: 345, title: "B"), + ]) + XCTAssertEqual(handles, [12, 345]) + } + + func testCatalogCandidatesRideNegativeAliasesInOrder() { + let handles = ReverseScanCandidateHandle.handles(for: [ + candidate(id: nil, title: "First", catalogID: "rcp-000001"), + candidate(id: nil, title: "Second", catalogID: "rcp-000002"), + ]) + XCTAssertEqual(handles, [-1, -2]) + } + + func testMixedListKeepsBothSpacesDisjoint() { + let handles = ReverseScanCandidateHandle.handles(for: [ + candidate(id: 1, title: "Legacy 1"), + candidate(id: nil, title: "Catalog", catalogID: "rcp-000009"), + candidate(id: 2, title: "Legacy 2"), + candidate(id: nil, title: "Draft"), // unsaved generated draft: negative alias too + ]) + XCTAssertEqual(handles, [1, -1, 2, -2]) + } + + func testNegativeAliasesNeverCollideWithLegacyIDs() { + // Legacy ids are row keys — always positive — so a mixed list can never send the + // same handle twice with different identities. + let handles = ReverseScanCandidateHandle.handles(for: [ + candidate(id: 1, title: "Legacy"), + candidate(id: nil, title: "Catalog A", catalogID: "rcp-000001"), + candidate(id: nil, title: "Catalog B", catalogID: "rcp-000002"), + ]) + XCTAssertEqual(Set(handles).count, handles.count) + XCTAssertTrue(handles.allSatisfy { $0 != 0 }) + } + + func testEmptyListProducesNoHandles() { + XCTAssertTrue(ReverseScanCandidateHandle.handles(for: []).isEmpty) + } +}