718eef16bf
- RuleViolation.reasons: el motor devuelve ahora QUE regla rompe cada asignacion (repetido en la semana, maximo por semana superado, no consecutivo, no mismo dia, solo comida/cena, solo entre semana o fin de semana) con su etiqueta y limite - Panel de avisos: bloque con cada regla rota en lenguaje claro + una sugerencia de que hacer, y boton nuevo "Cambiar plato" que abre el selector de ese hueco (antes solo se podia quitar o ignorar) - Estadisticas: matriz "Etiquetas por tipo de comida" (p.ej. cuantas cenas son de pescado) y "Platos por etiqueta" del catalogo - Tests: 4 casos de las razones de incumplimiento; harness que renderiza las secciones nuevas de stats a /tmp/stats_render.png - Strings en 6 idiomas Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
150 lines
6.0 KiB
Swift
150 lines
6.0 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")
|
|
}
|
|
}
|