|
|
|
|
@@ -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
|
|
|
|
|
|