reglas: el panel explica que se incumple y como arreglarlo; stats por etiqueta

- 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
This commit is contained in:
alexandrev-tibco
2026-09-10 21:06:42 +02:00
parent 9406c3f8e7
commit 718eef16bf
14 changed files with 545 additions and 11 deletions
@@ -81,3 +81,69 @@ final class AutocompleteEngineTests: XCTestCase {
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")
}
}
+54
View File
@@ -105,3 +105,57 @@ final class DateHelpersTests: XCTestCase {
}
}
/// Renders the stats screen with sample data to /tmp so the new sections can
/// be reviewed as an image.
final class StatsRenderTests: XCTestCase {
@MainActor
func testRenderStats() throws {
let tags = [
Tag(name: "Pasta", nameEN: "Pasta", color: "#E8A87C"),
Tag(name: "Pescado", nameEN: "Fish", color: "#7CB7E8"),
Tag(name: "Verdura", nameEN: "Veggie", color: "#8CD790"),
Tag(name: "Carne", nameEN: "Meat", color: "#E87C7C")
]
let dishes: [Dish] = [
Dish(name: "Espaguetis", tagIds: [tags[0].id]),
Dish(name: "Lasaña", tagIds: [tags[0].id, tags[3].id]),
Dish(name: "Merluza", tagIds: [tags[1].id]),
Dish(name: "Salmón", tagIds: [tags[1].id]),
Dish(name: "Ensalada", tagIds: [tags[2].id]),
Dish(name: "Pollo asado", tagIds: [tags[3].id]),
Dish(name: "Crema de calabaza", tagIds: [tags[2].id])
]
var plans: [WeekPlan] = []
for week in 0..<3 {
let plan = WeekPlan(weekStartDate: Date().startOfWeek().addingDays(-7 * week))
var i = week
for day in 0...4 {
for meal in ["lunch", "dinner"] {
let slot = MealSlot(dayOfWeek: day, mealType: meal, dishId: dishes[i % dishes.count].id)
slot.weekPlan = plan
plan.slotList.append(slot)
i += 1
}
}
plans.append(plan)
}
let view = StatsView(weekPlans: plans, allDishes: dishes, allTags: tags, language: .spanish)
// Render just the new sections: ImageRenderer can't draw a NavigationStack.
let content = VStack(alignment: .leading, spacing: 24) {
view.tagByMealSection
view.dishesPerTagSection
}
.padding(16)
.frame(width: 390)
.background(Color.mealMoodBackground)
let renderer = ImageRenderer(content: content)
renderer.scale = 2
let image = try XCTUnwrap(renderer.uiImage)
let data = try XCTUnwrap(image.pngData())
try data.write(to: URL(fileURLWithPath: "/tmp/stats_render.png"))
}
}