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") } }