diff --git a/backend/src/main/java/com/recipeapp/planning/PlanningService.java b/backend/src/main/java/com/recipeapp/planning/PlanningService.java index 0e39c54..356c694 100644 --- a/backend/src/main/java/com/recipeapp/planning/PlanningService.java +++ b/backend/src/main/java/com/recipeapp/planning/PlanningService.java @@ -135,6 +135,12 @@ public class PlanningService { .map(cl -> cl.getRecipe().getId()) .collect(Collectors.toSet()); + List currentSlots = plan.getSlots().stream() + .map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate())) + .toList(); + double currentScore = currentSlots.isEmpty() ? 10.0 + : scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds); + List allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId); Set lowerTagFilters = tagFilters.stream() @@ -145,11 +151,13 @@ public class PlanningService { .filter(r -> !usedRecipeIds.contains(r.getId())) .filter(r -> matchesAllTags(r, lowerTagFilters)) .map(candidate -> { - double score = simulateVarietyScore( + double simulatedScore = simulateVarietyScore( plan, candidate, slotDate, config, recentlyCookedIds); - return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score); + double scoreDelta = simulatedScore - currentScore; + boolean hasConflict = scoreDelta <= 0; + return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict); }) - .sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore())) + .sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta())) .limit(limit) .toList(); diff --git a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java index 7c0f4ed..1844fcc 100644 --- a/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java +++ b/backend/src/main/java/com/recipeapp/planning/dto/SuggestionResponse.java @@ -6,6 +6,7 @@ public record SuggestionResponse(List suggestions) { public record SuggestionItem( SlotResponse.SlotRecipe recipe, - double simulatedScore + double scoreDelta, + boolean hasConflict ) {} } diff --git a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java index 3e7495f..97eecb2 100644 --- a/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java +++ b/backend/src/test/java/com/recipeapp/planning/SuggestionsTest.java @@ -165,7 +165,7 @@ class SuggestionsTest { } @Test - void emptyPlanWithRecipesShouldReturnAllWithPerfectScore() { + void emptyPlanWithRecipesShouldReturnAllWithZeroDelta() { var plan = createPlan(); var r1 = createRecipe("Pasta"); var r2 = createRecipe("Salad"); @@ -179,8 +179,9 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(3); + // Empty plan → currentScore = 10.0; no conflicts → scoreDelta = 0.0 for all assertThat(result.suggestions()).allSatisfy(s -> - assertThat(s.simulatedScore()).isEqualTo(10.0)); + assertThat(s.scoreDelta()).isEqualTo(0.0)); } @Test @@ -221,6 +222,117 @@ class SuggestionsTest { } } + // ═══════════════════════════════════════════════════════════ + // Category 1b: scoreDelta and hasConflict + // ═══════════════════════════════════════════════════════════ + + @Nested + class ScoreDeltaAndHasConflict { + + @Test + void recipeWithNoConflictsOnEmptyPlanShouldHaveZeroDeltaAndHasConflict() { + // Empty plan → currentScore = 10.0. Clean recipe → simulatedScore = 10.0. + // scoreDelta = 0.0, hasConflict = (0.0 <= 0) = true + var plan = createPlan(); + var recipe = createRecipe("Clean Recipe"); + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(recipe); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.scoreDelta()).isEqualTo(0.0); + assertThat(item.hasConflict()).isTrue(); + } + + @Test + void recipeWithTagConflictShouldHaveNegativeDeltaAndHasConflict() { + // Existing slot Mon=Monday Pasta (cuisine tag). Adding Tue=More Pasta → tag repeat penalty (-1.5). + // currentScore = 10.0 (1 slot, no consecutive). simulatedScore = 10.0 - 1.5 = 8.5. + // scoreDelta = -1.5, hasConflict = true. + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var existingRecipe = createRecipe("Monday Pasta"); + addTag(existingRecipe, pastaTag); + addSlot(plan, existingRecipe, MONDAY); + + var candidate = createRecipe("More Pasta"); + addTag(candidate, pastaTag); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.scoreDelta()).isEqualTo(-1.5); + assertThat(item.hasConflict()).isTrue(); + } + + @Test + void recipeWithIngredientConflictShouldHaveNegativeDeltaAndHasConflict() { + // Existing slot Mon=Tomato Soup (tomato ingredient). Adding Tue=Tomato Pasta → overlap (-0.3). + // currentScore = 10.0, simulatedScore = 9.7, scoreDelta = -0.3, hasConflict = true. + var plan = createPlan(); + var tomato = createIngredient("Tomatoes", false); + var existingRecipe = createRecipe("Tomato Soup"); + addIngredient(existingRecipe, tomato); + addSlot(plan, existingRecipe, MONDAY); + + var candidate = createRecipe("Tomato Pasta"); + addIngredient(candidate, tomato); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, candidate); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(1); + var item = result.suggestions().getFirst(); + assertThat(item.scoreDelta()).isCloseTo(-0.3, within(0.001)); + assertThat(item.hasConflict()).isTrue(); + } + + @Test + void scoreDeltaIsSortedDescendingCleanBeforeConflicting() { + // Clean recipe (scoreDelta = 0.0) should rank above conflicting (scoreDelta < 0). + var plan = createPlan(); + var pastaTag = createTag("Pasta", "cuisine"); + var existingRecipe = createRecipe("Monday Pasta"); + addTag(existingRecipe, pastaTag); + addSlot(plan, existingRecipe, MONDAY); + + var cleanRecipe = createRecipe("Plain Rice"); + var conflictingRecipe = createRecipe("More Pasta"); + addTag(conflictingRecipe, pastaTag); + + stubPlan(plan); + stubDefaultConfig(); + stubRecipes(existingRecipe, cleanRecipe, conflictingRecipe); + stubNoCookingLogs(); + + SuggestionResponse result = planningService.getSuggestions( + HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); + + assertThat(result.suggestions()).hasSize(2); + assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice"); + assertThat(result.suggestions().get(0).scoreDelta()).isEqualTo(0.0); + assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("More Pasta"); + assertThat(result.suggestions().get(1).scoreDelta()).isEqualTo(-1.5); + } + } + // ═══════════════════════════════════════════════════════════ // Category 2: Exclusion of In-Plan Recipes // ═══════════════════════════════════════════════════════════ @@ -402,8 +514,8 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(2); // B should rank higher (no tag penalty) assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Plain Rice"); - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); } @Test @@ -428,8 +540,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(2); - assertThat(result.suggestions().get(0).simulatedScore()) - .isEqualTo(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isEqualTo(result.suggestions().get(1).scoreDelta()); } @Test @@ -453,8 +565,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(1); - // No penalty — dietary not tracked - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // No penalty — dietary not tracked → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } } @@ -492,8 +604,8 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Mushroom Risotto"); - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); } @Test @@ -519,7 +631,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(1); - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // Staples ignored → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } } @@ -547,8 +660,8 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Stir Fry"); - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); } @Test @@ -566,7 +679,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(1); - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // No penalty → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } } @@ -631,7 +745,7 @@ class SuggestionsTest { } @Test - void rankingOrderShouldBeBySimulatedScoreDescending() { + void rankingOrderShouldBeByScoreDeltaDescending() { var plan = createPlan(); var pastaTag = createTag("Pasta", "cuisine"); var tomato = createIngredient("Tomatoes", false); @@ -666,11 +780,11 @@ class SuggestionsTest { assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Dry Pasta"); assertThat(result.suggestions().get(2).recipe().name()).isEqualTo("Tomato Pasta"); - // Verify scores are strictly descending - assertThat(result.suggestions().get(0).simulatedScore()) - .isGreaterThan(result.suggestions().get(1).simulatedScore()); - assertThat(result.suggestions().get(1).simulatedScore()) - .isGreaterThan(result.suggestions().get(2).simulatedScore()); + // Verify scoreDelta is strictly descending + assertThat(result.suggestions().get(0).scoreDelta()) + .isGreaterThan(result.suggestions().get(1).scoreDelta()); + assertThat(result.suggestions().get(1).scoreDelta()) + .isGreaterThan(result.suggestions().get(2).scoreDelta()); } @Test @@ -688,8 +802,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY, List.of(), 5); assertThat(result.suggestions()).hasSize(2); - assertThat(result.suggestions().get(0).simulatedScore()) - .isEqualTo(result.suggestions().get(1).simulatedScore()); + assertThat(result.suggestions().get(0).scoreDelta()) + .isEqualTo(result.suggestions().get(1).scoreDelta()); } } @@ -726,7 +840,7 @@ class SuggestionsTest { addTag(c1, pastaTag); addIngredient(c1, tomato); - // Candidate 2: Chicken only → protein repeat with Mon + // Candidate 2: Chicken only → protein repeat with Mon (Mon→Wed not consecutive) var c2 = createRecipe("Chicken Salad"); addTag(c2, chickenTag); @@ -745,7 +859,7 @@ class SuggestionsTest { stubPlan(plan); stubDefaultConfig(); stubRecipes(monRecipe, tueRecipe, c1, c2, c3, c4, c5); - // c1 was cooked recently + // c1 was cooked recently (within 14-day window) stubCookingLogs(createCookingLog(c1, MONDAY.minusDays(3))); // Slot date = Wednesday (adjacent to Tuesday) @@ -754,19 +868,20 @@ class SuggestionsTest { assertThat(result.suggestions()).hasSize(5); - // c2, c4, c5 all score 10.0 (no penalties — Chicken Mon→Wed not consecutive) + // currentScore = 10.0 (Mon+Tue plan: no consecutive conflicts between just those 2 slots) + // c2, c4, c5: no additional conflicts → scoreDelta = 0.0 var topThree = result.suggestions().subList(0, 3); assertThat(topThree).extracting(s -> s.recipe().name()) .containsExactlyInAnyOrder("Chicken Salad", "Mushroom Risotto", "Lentil Soup"); - assertThat(topThree).allSatisfy(s -> assertThat(s.simulatedScore()).isEqualTo(10.0)); + assertThat(topThree).allSatisfy(s -> assertThat(s.scoreDelta()).isEqualTo(0.0)); - // c3 (Cheese Omelette) has ingredient overlap Tue→Wed: -0.3 + // c3 (Cheese Omelette) has ingredient overlap Tue→Wed: scoreDelta = -0.3 assertThat(result.suggestions().get(3).recipe().name()).isEqualTo("Cheese Omelette"); - assertThat(result.suggestions().get(3).simulatedScore()).isCloseTo(9.7, within(0.001)); + assertThat(result.suggestions().get(3).scoreDelta()).isCloseTo(-0.3, within(0.001)); - // c1 (Tomato Spaghetti) has recent repeat: -1.0 + // c1 (Tomato Spaghetti) has recent repeat: scoreDelta = -1.0 assertThat(result.suggestions().get(4).recipe().name()).isEqualTo("Tomato Spaghetti"); - assertThat(result.suggestions().get(4).simulatedScore()).isEqualTo(9.0); + assertThat(result.suggestions().get(4).scoreDelta()).isEqualTo(-1.0); } @Test @@ -800,7 +915,7 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of("Quick meal"), 5); - // Only quick recipes, ranked by variety + // Only quick recipes, ranked by scoreDelta desc assertThat(result.suggestions()).hasSize(2); assertThat(result.suggestions().get(0).recipe().name()).isEqualTo("Quick Salad"); assertThat(result.suggestions().get(1).recipe().name()).isEqualTo("Quick Pasta"); @@ -815,7 +930,7 @@ class SuggestionsTest { class EdgeCases { @Test - void recipeWithNoTagsOrIngredientsShouldGetPerfectScore() { + void recipeWithNoTagsOrIngredientsShouldGetZeroDelta() { var plan = createPlan(); var existingRecipe = createRecipe("Existing"); addSlot(plan, existingRecipe, MONDAY); @@ -832,7 +947,8 @@ class SuggestionsTest { HOUSEHOLD_ID, plan.getId(), MONDAY.plusDays(1), List.of(), 5); assertThat(result.suggestions()).hasSize(1); - assertThat(result.suggestions().getFirst().simulatedScore()).isEqualTo(10.0); + // No conflicts → scoreDelta = 0.0 + assertThat(result.suggestions().getFirst().scoreDelta()).isEqualTo(0.0); } @Test diff --git a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java index e827493..77cd512 100644 --- a/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java +++ b/backend/src/test/java/com/recipeapp/planning/WeekPlanControllerTest.java @@ -162,7 +162,7 @@ class WeekPlanControllerTest { @Test void getSuggestionsShouldReturn200() throws Exception { var recipe = new SlotResponse.SlotRecipe(UUID.randomUUID(), "Stir Fry", "easy", (short) 15, null); - var item = new SuggestionResponse.SuggestionItem(recipe, 9.5); + var item = new SuggestionResponse.SuggestionItem(recipe, 1.5, false); var response = new SuggestionResponse(List.of(item)); when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID); @@ -175,7 +175,8 @@ class WeekPlanControllerTest { .param("slotDate", "2026-04-08")) .andExpect(status().isOk()) .andExpect(jsonPath("$.suggestions[0].recipe.name").value("Stir Fry")) - .andExpect(jsonPath("$.suggestions[0].simulatedScore").value(9.5)); + .andExpect(jsonPath("$.suggestions[0].scoreDelta").value(1.5)) + .andExpect(jsonPath("$.suggestions[0].hasConflict").value(false)); } @Test