fix(backend): add role guard to variety-preview and extract shared scoring method

- Add @RequiresHouseholdRole("member") to GET /{planId}/variety-preview endpoint
  to require household membership (was accessible to any authenticated user)
- Extract scoreFromSimulatedSlots() private method eliminating duplicate logic
  between simulateVarietyScore() and the old computeCurrentScore()
- Fix loose variety preview test assertions (isBetween → exact assertEquals)
- Add test verifying negative scoreDelta when candidate is a duplicate recipe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 08:11:45 +02:00
parent 4333dc0d84
commit ea7113ec53
4 changed files with 61 additions and 81 deletions

View File

@@ -166,68 +166,12 @@ public class PlanningService {
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
// Build a simulated slot list: existing slots + candidate on slotDate
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
for (WeekPlanSlot slot : plan.getSlots()) {
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
}
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
// 1. Tag-type repeats on consecutive days
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (Tag tag : slot.recipe.getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
.add(slot.date);
}
}
}
long tagRepeatCount = tagDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
// 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
if (!ri.getIngredient().isStaple()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
.add(slot.date);
}
}
}
long ingredientOverlapCount = ingredientDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
// 3. Recent repeats from cooking log
long recentRepeatCount = simulatedSlots.stream()
.map(s -> s.recipe.getId())
.distinct()
.filter(recentlyCookedIds::contains)
.count();
// 4. Duplicate recipes within the simulated plan
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
long duplicatePenaltyCount = recipeCounts.values().stream()
.filter(c -> c > 1)
.mapToLong(c -> c - 1)
.sum();
double score = 10.0;
score -= tagRepeatCount * wTagRepeat;
score -= ingredientOverlapCount * wIngredientOverlap;
score -= recentRepeatCount * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
return Math.max(0, Math.min(10, score));
return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds);
}
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
@@ -247,54 +191,57 @@ public class PlanningService {
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
// Current score: simulate with only existing slots
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
// Projected score: add candidate on the given date
List<SimulatedSlot> currentSlots = plan.getSlots().stream()
.map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate()))
.toList();
double currentScore = currentSlots.isEmpty() ? 10.0
: scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds);
double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds);
return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore);
}
private double computeCurrentScore(WeekPlan plan, VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
List<WeekPlanSlot> slots = plan.getSlots();
if (slots.isEmpty()) return 10.0;
private double scoreFromSimulatedSlots(List<SimulatedSlot> slots, VarietyScoreConfig config,
Set<UUID> recentlyCookedIds) {
List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
// 1. Tag-type repeats on consecutive days
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (Tag tag : slot.getRecipe().getTags()) {
for (SimulatedSlot slot : slots) {
for (Tag tag : slot.recipe.getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()).add(slot.getSlotDate());
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>()).add(slot.date);
}
}
}
long tagRepeatCount = tagDays.values().stream().filter(this::hasConsecutiveDays).count();
// 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
for (SimulatedSlot slot : slots) {
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
if (!ri.getIngredient().isStaple()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
.add(slot.getSlotDate());
.add(slot.date);
}
}
}
long ingredientOverlapCount = ingredientDays.values().stream().filter(this::hasConsecutiveDays).count();
// 3. Recent repeats from cooking log
long recentRepeatCount = slots.stream()
.map(s -> s.getRecipe().getId())
.map(s -> s.recipe.getId())
.distinct()
.filter(recentlyCookedIds::contains)
.count();
// 4. Duplicate recipes within the plan
Map<UUID, Long> recipeCounts = slots.stream()
.collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting()));
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
long duplicatePenaltyCount = recipeCounts.values().stream()
.filter(c -> c > 1)
.mapToLong(c -> c - 1)