Files
FamilyMealPlanner/MealMoodTests/AutocompleteEngineTests.swift
T
alexandrev-tibco 24a6743ed2 reglas: los avisos se calculan del plan actual, no de una marca guardada
Un plato aparecia en la lista de incumplimientos sin explicacion (captura
del usuario: "Ensalada campera"): findViolations listaba los slots con
isRuleOverridden, una marca puesta al asignar que quedaba obsoleta cuando
el conflicto ya se habia resuelto. Y al reves, editar una regla despues
de planificar no detectaba nada.

- findViolations recorre el plan y reporta solo lo que incumple AHORA,
  siempre con sus razones (nunca una entrada vacia)
- MealSlot.isRuleIgnored: "Ignorar" pasa a ser persistente, porque si no
  el aviso recalculado volveria enseguida; se resetea al cambiar o
  quitar el plato del hueco
- El triangulo del calendario usa tambien el estado real
- Campo en el payload KV (opcional, retrocompatible) y en el esquema
  CloudKit de Development
- 3 tests: marca obsoleta no se lista, regla endurecida despues se
  detecta, y lo ignorado no reaparece

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
2026-09-10 21:54:37 +02:00

199 lines
8.2 KiB
Swift

import XCTest
@testable import MealMood
final class AutocompleteEngineTests: XCTestCase {
func testValidateDropRejectsConsecutiveTag() {
let tag = Tag(
name: "Carne",
nameEN: "Meat",
color: "#E74C3C",
maxPerWeek: nil,
noConsecutive: true,
noDuplicateInDay: false
)
let dishA = Dish(name: "Pollo", tagIds: [tag.id])
let dishB = Dish(name: "Ternera", tagIds: [tag.id])
let plan = WeekPlan(weekStartDate: Date().startOfWeek())
let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: dishA.id)
let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil)
plan.slotList = [mondayDinner, tuesdayDinner]
let isValid = AutocompleteEngine.validateDrop(
dish: dishB,
slot: tuesdayDinner,
plan: plan,
allTags: [tag],
allDishes: [dishA, dishB]
)
XCTAssertFalse(isValid)
}
func testAutocompleteLeavesSlotEmptyWhenMaxPerWeekReached() {
let tag = Tag(
name: "Pescado",
nameEN: "Fish",
color: "#3498DB",
maxPerWeek: 1,
noConsecutive: false,
noDuplicateInDay: false
)
let fishDish = Dish(name: "Salmon", tagIds: [tag.id])
let plan = WeekPlan(weekStartDate: Date().startOfWeek())
let mondayDinner = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: fishDish.id)
let tuesdayDinner = MealSlot(dayOfWeek: 1, mealType: MealType.dinner.rawValue, dishId: nil)
plan.slotList = [mondayDinner, tuesdayDinner]
let result = AutocompleteEngine.autocomplete(
emptySlots: [tuesdayDinner],
currentPlan: plan,
allDishes: [fishDish],
allTags: [tag]
)
XCTAssertEqual(result.unfilledCount, 1)
XCTAssertNil(tuesdayDinner.dishId)
}
func testDishStableSortRemainsConsistentAfterDeletion() {
let base = Date()
let oldest = Dish(id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, name: "Old", createdAt: base.addingTimeInterval(-60))
let newest = Dish(id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, name: "New", createdAt: base)
let middle = Dish(id: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!, name: "Middle", createdAt: base.addingTimeInterval(-30))
let initial = Dish.stableSortedForDisplay([oldest, newest, middle]).map(\.id)
XCTAssertEqual(initial, [newest.id, middle.id, oldest.id])
let afterDeletion = Dish.stableSortedForDisplay([newest, oldest]).map(\.id)
XCTAssertEqual(afterDeletion, [newest.id, oldest.id])
}
func testPreferredDuplicateSlotKeepsAssignedDish() {
let duplicateWithDish = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: UUID())
let duplicateWithoutDish = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue, dishId: nil)
let preferred = MealSlot.preferredForDuplicateResolution([duplicateWithoutDish, duplicateWithDish])
XCTAssertEqual(preferred.id, duplicateWithDish.id)
}
}
/// The violations panel explains *which* rule is broken; these cover the
/// reason detection that feeds it.
final class ViolationReasonsTests: XCTestCase {
private func makePlan(slots: [(day: Int, meal: String, dishId: UUID?)]) -> WeekPlan {
let plan = WeekPlan(weekStartDate: Date().startOfWeek())
for s in slots {
let slot = MealSlot(dayOfWeek: s.day, mealType: s.meal, dishId: s.dishId)
slot.weekPlan = plan
plan.slotList.append(slot)
}
return plan
}
func testMaxPerWeekExceededIsReported() {
let tag = Tag(name: "Pasta", color: "#FF0000", maxPerWeek: 1)
let dish = Dish(name: "Espaguetis", tagIds: [tag.id])
let other = Dish(name: "Macarrones", tagIds: [tag.id])
let plan = makePlan(slots: [(0, "dinner", dish.id), (1, "dinner", other.id)])
let slot = plan.slotList[0]
let reasons = AutocompleteEngine.reasons(
dish: dish, slot: slot, plan: plan,
tagMap: [tag.id: tag], dishMap: [dish.id: dish, other.id: other]
)
XCTAssertTrue(reasons.contains { $0.kind == .maxPerWeek && $0.limit == 1 },
"Two pasta dishes with max 1/week must report maxPerWeek")
}
func testMealTypeRestrictionIsReported() {
let tag = Tag(name: "Ligero", color: "#00FF00", mealTypeRestriction: "dinner")
let dish = Dish(name: "Ensalada", tagIds: [tag.id])
let plan = makePlan(slots: [(0, "lunch", dish.id)])
let reasons = AutocompleteEngine.reasons(
dish: dish, slot: plan.slotList[0], plan: plan,
tagMap: [tag.id: tag], dishMap: [dish.id: dish]
)
XCTAssertTrue(reasons.contains { $0.kind == .mealTypeOnly && $0.restriction == "dinner" },
"A dinner-only tag placed at lunch must report mealTypeOnly")
}
func testRepeatedDishInWeekIsReported() {
let dish = Dish(name: "Tortilla")
let plan = makePlan(slots: [(0, "dinner", dish.id), (3, "dinner", dish.id)])
let reasons = AutocompleteEngine.reasons(
dish: dish, slot: plan.slotList[0], plan: plan,
tagMap: [:], dishMap: [dish.id: dish]
)
XCTAssertTrue(reasons.contains { $0.kind == .repeatedInWeek })
}
func testCompliantDishHasNoReasons() {
let tag = Tag(name: "Pescado", color: "#0000FF", maxPerWeek: 2)
let dish = Dish(name: "Merluza", tagIds: [tag.id])
let plan = makePlan(slots: [(0, "dinner", dish.id)])
let reasons = AutocompleteEngine.reasons(
dish: dish, slot: plan.slotList[0], plan: plan,
tagMap: [tag.id: tag], dishMap: [dish.id: dish]
)
XCTAssertTrue(reasons.isEmpty, "A dish within its limits must not report violations")
}
}
/// Violations come from the current plan, not from a stored flag.
final class ViolationFreshnessTests: XCTestCase {
private func plan(_ slots: [MealSlot]) -> WeekPlan {
let p = WeekPlan(weekStartDate: Date().startOfWeek())
slots.forEach { $0.weekPlan = p; p.slotList.append($0) }
return p
}
/// The bug from the screenshot: a meal flagged long ago, whose conflict is
/// already gone, was still listed with nothing to explain.
func testStaleFlagIsNotReported() {
let dish = Dish(name: "Ensalada campera")
let slot = MealSlot(dayOfWeek: 0, mealType: "dinner", dishId: dish.id)
slot.isRuleOverridden = true // marca antigua
let week = plan([slot])
let violations = AutocompleteEngine.findViolations(plan: week, allDishes: [dish], allTags: [])
XCTAssertTrue(violations.isEmpty, "A stale flag with no current conflict must not be listed")
}
/// And the opposite: a rule tightened after planning must be caught even
/// though no flag was ever set.
func testRuleEditedAfterPlanningIsCaught() {
let tag = Tag(name: "Pasta", color: "#FF0000", maxPerWeek: 1)
let a = Dish(name: "Espaguetis", tagIds: [tag.id])
let b = Dish(name: "Macarrones", tagIds: [tag.id])
let week = plan([
MealSlot(dayOfWeek: 0, mealType: "dinner", dishId: a.id),
MealSlot(dayOfWeek: 1, mealType: "dinner", dishId: b.id)
])
let violations = AutocompleteEngine.findViolations(plan: week, allDishes: [a, b], allTags: [tag])
XCTAssertEqual(violations.count, 2, "Both pasta meals exceed max 1/week")
XCTAssertTrue(violations.allSatisfy { !$0.reasons.isEmpty }, "Every listed violation must explain itself")
}
func testIgnoredSlotStaysHidden() {
let dish = Dish(name: "Tortilla")
let s1 = MealSlot(dayOfWeek: 0, mealType: "dinner", dishId: dish.id)
let s2 = MealSlot(dayOfWeek: 3, mealType: "dinner", dishId: dish.id)
s1.isRuleIgnored = true
let week = plan([s1, s2])
let violations = AutocompleteEngine.findViolations(plan: week, allDishes: [dish], allTags: [])
XCTAssertEqual(violations.map(\.slotId), [s2.id], "The dismissed slot must not come back")
}
}