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
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Schon zu Hause";
"premium_privacy_link" = "Datenschutzrichtlinie";
"premium_terms_link" = "Nutzungsbedingungen (EULA)";
"violation_reason_repeated" = "Dieses Gericht ist diese Woche schon eingeplant";
"violation_reason_max_per_week" = "Maximal %1$@: %2$d pro Woche — überschritten";
"violation_reason_no_consecutive" = "%@ darf nicht an aufeinanderfolgenden Tagen wiederholt werden";
"violation_reason_no_same_day" = "%@ ist an diesem Tag schon in einer anderen Mahlzeit";
"violation_reason_meal_only" = "%1$@ ist nur bei %2$@ erlaubt";
"violation_reason_day_scope" = "%1$@ ist nur %2$@ erlaubt";
"violation_scope_weekdays" = "unter der Woche";
"violation_scope_weekend" = "am Wochenende";
"violation_suggestion_repeated" = "Tausche es gegen ein anderes Gericht";
"violation_suggestion_swap" = "Tausche es gegen ein Gericht mit anderem Tag";
"violation_suggestion_move" = "Verschiebe es auf einen erlaubten Tag oder eine erlaubte Mahlzeit";
"violation_suggestion_generic" = "Gericht ändern oder Hinweis ignorieren";
"violations_replace" = "Gericht ändern";
"stats_tag_by_meal_title" = "Tags nach Mahlzeit";
"stats_dishes_per_tag_title" = "Gerichte pro Tag";
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Already at home";
"premium_privacy_link" = "Privacy Policy";
"premium_terms_link" = "Terms of Use (EULA)";
"violation_reason_repeated" = "This dish is already planned in another meal this week";
"violation_reason_max_per_week" = "Max %1$@: %2$d per week, and you are over it";
"violation_reason_no_consecutive" = "%@ cannot repeat on consecutive days";
"violation_reason_no_same_day" = "%@ is already in another meal on this day";
"violation_reason_meal_only" = "%1$@ is only allowed at %2$@";
"violation_reason_day_scope" = "%1$@ is only allowed %2$@";
"violation_scope_weekdays" = "on weekdays";
"violation_scope_weekend" = "on weekends";
"violation_suggestion_repeated" = "Swap it for another dish to vary the week";
"violation_suggestion_swap" = "Swap it for a dish with a different tag";
"violation_suggestion_move" = "Move it to a day or meal where it is allowed";
"violation_suggestion_generic" = "Change the dish or dismiss the warning";
"violations_replace" = "Change dish";
"stats_tag_by_meal_title" = "Tags by meal type";
"stats_dishes_per_tag_title" = "Dishes per tag";
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Ya en casa";
"premium_privacy_link" = "Política de privacidad";
"premium_terms_link" = "Términos de uso (EULA)";
"violation_reason_repeated" = "Este plato ya está en otra comida de la semana";
"violation_reason_max_per_week" = "Máximo %1$@: %2$d por semana, y ya lo superas";
"violation_reason_no_consecutive" = "%@ no puede repetirse en días seguidos";
"violation_reason_no_same_day" = "%@ ya está en otra comida de este mismo día";
"violation_reason_meal_only" = "%1$@ solo está permitido en %2$@";
"violation_reason_day_scope" = "%1$@ solo está permitido %2$@";
"violation_scope_weekdays" = "entre semana";
"violation_scope_weekend" = "en fin de semana";
"violation_suggestion_repeated" = "Cámbialo por otro plato para variar la semana";
"violation_suggestion_swap" = "Cámbialo por un plato de otra etiqueta";
"violation_suggestion_move" = "Muévelo a un día o comida donde sí esté permitido";
"violation_suggestion_generic" = "Cambia el plato o ignora el aviso";
"violations_replace" = "Cambiar plato";
"stats_tag_by_meal_title" = "Etiquetas por tipo de comida";
"stats_dishes_per_tag_title" = "Platos por etiqueta";
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Déjà à la maison";
"premium_privacy_link" = "Politique de confidentialité";
"premium_terms_link" = "Conditions d'utilisation (CLUF)";
"violation_reason_repeated" = "Ce plat est déjà prévu dans un autre repas cette semaine";
"violation_reason_max_per_week" = "Maximum %1$@ : %2$d par semaine, et vous le dépassez";
"violation_reason_no_consecutive" = "%@ ne peut pas se répéter deux jours de suite";
"violation_reason_no_same_day" = "%@ est déjà dans un autre repas ce jour-là";
"violation_reason_meal_only" = "%1$@ est autorisé uniquement au %2$@";
"violation_reason_day_scope" = "%1$@ est autorisé uniquement %2$@";
"violation_scope_weekdays" = "en semaine";
"violation_scope_weekend" = "le week-end";
"violation_suggestion_repeated" = "Remplacez-le par un autre plat pour varier";
"violation_suggestion_swap" = "Remplacez-le par un plat d'une autre étiquette";
"violation_suggestion_move" = "Déplacez-le vers un jour ou un repas autorisé";
"violation_suggestion_generic" = "Changez le plat ou ignorez l'alerte";
"violations_replace" = "Changer de plat";
"stats_tag_by_meal_title" = "Étiquettes par type de repas";
"stats_dishes_per_tag_title" = "Plats par étiquette";
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Già a casa";
"premium_privacy_link" = "Informativa sulla privacy";
"premium_terms_link" = "Termini di utilizzo (EULA)";
"violation_reason_repeated" = "Questo piatto è già previsto in un altro pasto della settimana";
"violation_reason_max_per_week" = "Massimo %1$@: %2$d a settimana, e lo hai superato";
"violation_reason_no_consecutive" = "%@ non può ripetersi in giorni consecutivi";
"violation_reason_no_same_day" = "%@ è già in un altro pasto di questo giorno";
"violation_reason_meal_only" = "%1$@ è consentito solo a %2$@";
"violation_reason_day_scope" = "%1$@ è consentito solo %2$@";
"violation_scope_weekdays" = "nei giorni feriali";
"violation_scope_weekend" = "nel fine settimana";
"violation_suggestion_repeated" = "Sostituiscilo con un altro piatto per variare";
"violation_suggestion_swap" = "Sostituiscilo con un piatto di un'altra etichetta";
"violation_suggestion_move" = "Spostalo in un giorno o pasto consentito";
"violation_suggestion_generic" = "Cambia il piatto o ignora l'avviso";
"violations_replace" = "Cambia piatto";
"stats_tag_by_meal_title" = "Etichette per tipo di pasto";
"stats_dishes_per_tag_title" = "Piatti per etichetta";
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Já em casa";
"premium_privacy_link" = "Política de privacidade";
"premium_terms_link" = "Termos de uso (EULA)";
"violation_reason_repeated" = "Este prato já está planejado em outra refeição desta semana";
"violation_reason_max_per_week" = "Máximo %1$@: %2$d por semana, e você já passou";
"violation_reason_no_consecutive" = "%@ não pode se repetir em dias seguidos";
"violation_reason_no_same_day" = "%@ já está em outra refeição deste mesmo dia";
"violation_reason_meal_only" = "%1$@ só é permitido no %2$@";
"violation_reason_day_scope" = "%1$@ só é permitido %2$@";
"violation_scope_weekdays" = "durante a semana";
"violation_scope_weekend" = "no fim de semana";
"violation_suggestion_repeated" = "Troque por outro prato para variar a semana";
"violation_suggestion_swap" = "Troque por um prato de outra etiqueta";
"violation_suggestion_move" = "Mova para um dia ou refeição permitidos";
"violation_suggestion_generic" = "Mude o prato ou ignore o aviso";
"violations_replace" = "Trocar prato";
"stats_tag_by_meal_title" = "Etiquetas por tipo de refeição";
"stats_dishes_per_tag_title" = "Pratos por etiqueta";
+93 -1
View File
@@ -13,6 +13,32 @@ struct AutocompleteEngine {
let mealType: String
let dishId: UUID
let dishName: String
/// Which rules this assignment breaks the panel turns these into a
/// plain-language explanation and a suggested fix.
let reasons: [Reason]
struct Reason: Hashable {
enum Kind: Hashable {
case maxPerWeek
case noConsecutive
case noDuplicateInDay
case mealTypeOnly
case dayRestriction
case repeatedInWeek
}
let kind: Kind
let tagId: UUID?
let limit: Int?
/// "lunch"/"dinner" for mealTypeOnly, "weekdays"/"weekend" for dayRestriction.
let restriction: String?
init(kind: Kind, tagId: UUID? = nil, limit: Int? = nil, restriction: String? = nil) {
self.kind = kind
self.tagId = tagId
self.limit = limit
self.restriction = restriction
}
}
}
// recentPlans: up to 12 previous weeks for period detection (weekly/bi-weekly/monthly cycles).
@@ -90,6 +116,7 @@ struct AutocompleteEngine {
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
return plan.slotList.compactMap { slot in
guard let dishId = slot.dishId,
let dish = dishMap[dishId],
@@ -100,11 +127,76 @@ struct AutocompleteEngine {
dayOfWeek: slot.dayOfWeek,
mealType: slot.mealType,
dishId: dishId,
dishName: dish.name
dishName: dish.name,
reasons: reasons(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
)
}
}
/// Every rule the dish breaks in this slot. Mirrors `violatesExplicitRules`
/// but collects instead of short-circuiting, so the UI can explain itself.
static func reasons(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> [RuleViolation.Reason] {
var found: [RuleViolation.Reason] = []
if plan.slotList.contains(where: { $0.id != slot.id && $0.dishId == dish.id }) {
found.append(.init(kind: .repeatedInWeek))
}
for tagId in dish.tagIds {
guard let tag = tagMap[tagId] else { continue }
if let maxPerWeek = tag.maxPerWeek {
var count = 0
for s in plan.slotList {
guard let did = s.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { count += 1 }
}
if count > maxPerWeek {
found.append(.init(kind: .maxPerWeek, tagId: tagId, limit: maxPerWeek))
}
}
if tag.noConsecutive {
for adjSlot in adjacentSlots(of: slot, in: plan) {
guard let did = adjSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) {
found.append(.init(kind: .noConsecutive, tagId: tagId))
break
}
}
}
if tag.noDuplicateInDay {
for sdSlot in plan.slotList where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) {
found.append(.init(kind: .noDuplicateInDay, tagId: tagId))
break
}
}
}
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
found.append(.init(kind: .mealTypeOnly, tagId: tagId, restriction: restriction))
}
if let dayRestriction = tag.dayRestriction {
let broken = (dayRestriction == "weekdays" && slot.dayOfWeek > 4) ||
(dayRestriction == "weekend" && slot.dayOfWeek < 5)
if broken {
found.append(.init(kind: .dayRestriction, tagId: tagId, restriction: dayRestriction))
}
}
}
return found
}
static func validateDrop(
dish: Dish,
slot: MealSlot,
+101 -7
View File
@@ -638,6 +638,13 @@ struct HomeView: View {
onFix: { slot in
viewModel.removeDish(from: slot, plan: plan, settings: settings)
},
onReplace: { slot in
// Straight to the picker for that slot so the user can swap
// the dish instead of just clearing it.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
selectedFilledSlotId = slot.id
}
},
onIgnore: { slot in
viewModel.acknowledgeViolation(slot: slot, plan: plan)
}
@@ -1309,6 +1316,7 @@ private struct RuleViolationsPanelSheet: View {
let tags: [Tag]
let settings: AppSettings
let onFix: (MealSlot) -> Void
let onReplace: (MealSlot) -> Void
let onIgnore: (MealSlot) -> Void
@Environment(\.dismiss) private var dismiss
@@ -1338,10 +1346,15 @@ private struct RuleViolationsPanelSheet: View {
ViolationRow(
violation: violation,
settings: settings,
tags: tags,
onFix: {
onFix(slot)
if violations.count <= 1 { dismiss() }
},
onReplace: {
onReplace(slot)
dismiss()
},
onIgnore: {
onIgnore(slot)
if violations.count <= 1 { dismiss() }
@@ -1369,17 +1382,60 @@ private struct RuleViolationsPanelSheet: View {
private struct ViolationRow: View {
let violation: AutocompleteEngine.RuleViolation
let settings: AppSettings
let tags: [Tag]
let onFix: () -> Void
let onReplace: () -> Void
let onIgnore: () -> Void
private var language: AppLanguage { settings.languageEnum.resolved() }
private var dayLabel: String {
let language = settings.languageEnum.resolved()
return localizedString(dayKey(for: violation.dayOfWeek), language: language)
localizedString(dayKey(for: violation.dayOfWeek), language: language)
}
private var mealLabel: String {
let language = settings.languageEnum.resolved()
return localizedString(violation.mealType, language: language)
localizedString(violation.mealType, language: language)
}
private func tagName(_ id: UUID?) -> String {
guard let id, let tag = tags.first(where: { $0.id == id }) else { return "" }
return tag.localizedName(language: language)
}
/// Plain-language "what rule is broken" for each reason.
private func explanation(_ reason: AutocompleteEngine.RuleViolation.Reason) -> String {
let tag = tagName(reason.tagId)
switch reason.kind {
case .repeatedInWeek:
return String(localized: "violation_reason_repeated")
case .maxPerWeek:
return String(format: String(localized: "violation_reason_max_per_week"), tag, reason.limit ?? 0)
case .noConsecutive:
return String(format: String(localized: "violation_reason_no_consecutive"), tag)
case .noDuplicateInDay:
return String(format: String(localized: "violation_reason_no_same_day"), tag)
case .mealTypeOnly:
let meal = localizedString(reason.restriction ?? "", language: language)
return String(format: String(localized: "violation_reason_meal_only"), tag, meal)
case .dayRestriction:
let key = reason.restriction == "weekend" ? "violation_scope_weekend" : "violation_scope_weekdays"
return String(format: String(localized: "violation_reason_day_scope"), tag, String(localized: String.LocalizationValue(key)))
}
}
/// What the user can do about it.
private var suggestion: String {
guard let first = violation.reasons.first else {
return String(localized: "violation_suggestion_generic")
}
switch first.kind {
case .repeatedInWeek:
return String(localized: "violation_suggestion_repeated")
case .maxPerWeek, .noConsecutive, .noDuplicateInDay:
return String(localized: "violation_suggestion_swap")
case .mealTypeOnly, .dayRestriction:
return String(localized: "violation_suggestion_move")
}
}
var body: some View {
@@ -1397,9 +1453,36 @@ private struct RuleViolationsPanelSheet: View {
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
HStack(spacing: 10) {
Button(action: onFix) {
Text("violations_fix")
if !violation.reasons.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(violation.reasons.enumerated()), id: \.offset) { _, reason in
HStack(alignment: .top, spacing: 6) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 11))
.foregroundColor(.mealMoodError)
Text(explanation(reason))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextPrimary)
}
}
HStack(alignment: .top, spacing: 6) {
Image(systemName: "lightbulb.fill")
.font(.system(size: 11))
.foregroundColor(.mealMoodWarning)
Text(suggestion)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.mealMoodWarning.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
HStack(spacing: 8) {
Button(action: onReplace) {
Text("violations_replace")
.font(.mealMoodSmall)
.foregroundColor(.white)
.padding(.horizontal, 14)
@@ -1409,6 +1492,17 @@ private struct RuleViolationsPanelSheet: View {
}
.buttonStyle(.plain)
Button(action: onFix) {
Text("violations_fix")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodCoral)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Color.mealMoodCoral.opacity(0.12))
.clipShape(Capsule())
}
.buttonStyle(.plain)
Button(action: onIgnore) {
Text("violations_ignore")
.font(.mealMoodSmall)
+138 -1
View File
@@ -15,6 +15,8 @@ struct StatsView: View {
summaryGrid
topDishesSection
tagBreakdownSection
tagByMealSection
dishesPerTagSection
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
@@ -190,7 +192,7 @@ struct StatsView: View {
.map { $0 }
}
private struct TagEntry { let tag: Tag; let count: Int }
struct TagEntry { let tag: Tag; let count: Int }
private var tagUsage: [TagEntry] {
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
@@ -212,6 +214,141 @@ struct StatsView: View {
}
}
// Internal (not private) so the render harness in the tests can draw these
// sections on their own ImageRenderer cannot draw the NavigationStack body.
extension StatsView {
/// Tag usage split by meal type "how many dinners are pasta?".
var tagByMealSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("stats_tag_by_meal_title")
.font(.mealMoodH3)
.foregroundColor(.mealMoodTextPrimary)
if tagMealMatrix.isEmpty {
Text("stats_no_data")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
} else {
VStack(spacing: 0) {
HStack {
Text("").frame(maxWidth: .infinity, alignment: .leading)
ForEach(usedMealTypes, id: \.self) { meal in
Text(LocalizedStringKey(meal.localizedKey))
.font(.mealMoodCaption.weight(.semibold))
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 62)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
ForEach(tagMealMatrix, id: \.tag.id) { row in
Divider().padding(.leading, 12)
HStack {
HStack(spacing: 6) {
TagDot(color: row.tag.color, size: 8)
Text(row.tag.localizedName(language: language))
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextPrimary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(usedMealTypes, id: \.self) { meal in
Text("\(row.counts[meal] ?? 0)")
.font(.mealMoodSmall.weight(.semibold))
.foregroundColor((row.counts[meal] ?? 0) > 0 ? .mealMoodTextPrimary : .mealMoodTextSecondary.opacity(0.5))
.frame(width: 62)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
}
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
/// How many dishes in the catalogue carry each tag.
var dishesPerTagSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("stats_dishes_per_tag_title")
.font(.mealMoodH3)
.foregroundColor(.mealMoodTextPrimary)
if dishesPerTag.isEmpty {
Text("stats_no_data")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
} else {
VStack(spacing: 10) {
ForEach(dishesPerTag, id: \.tag.id) { entry in
TagBarRow(
tagName: entry.tag.localizedName(language: language),
tagColor: entry.tag.color,
count: entry.count,
maxCount: dishesPerTag.first?.count ?? 1
)
}
}
.padding(14)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
var usedMealTypes: [MealType] {
let raw = Set(weekPlans.flatMap { $0.slotList.map(\.mealType) })
let types = MealType.allCases.filter { raw.contains($0.rawValue) }
return types.isEmpty ? [.lunch, .dinner] : types
}
struct TagMealRow { let tag: Tag; let counts: [MealType: Int]; let total: Int }
var tagMealMatrix: [TagMealRow] {
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
var counts: [UUID: [MealType: Int]] = [:]
for plan in weekPlans {
for slot in plan.slotList {
guard let meal = MealType(rawValue: slot.mealType) else { continue }
for dishId in [slot.dishId, slot.secondaryDishId].compactMap({ $0 }) {
guard let dish = dishMap[dishId] else { continue }
for tagId in dish.tagIds {
counts[tagId, default: [:]][meal, default: 0] += 1
}
}
}
}
return counts.compactMap { tagId, byMeal -> TagMealRow? in
guard let tag = allTags.first(where: { $0.id == tagId }) else { return nil }
return TagMealRow(tag: tag, counts: byMeal, total: byMeal.values.reduce(0, +))
}
.sorted { $0.total > $1.total }
.prefix(8)
.map { $0 }
}
var dishesPerTag: [TagEntry] {
var counts: [UUID: Int] = [:]
for dish in allDishes {
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
}
return counts
.compactMap { id, count in allTags.first(where: { $0.id == id }).map { TagEntry(tag: $0, count: count) } }
.sorted { $0.count > $1.count }
.prefix(8)
.map { $0 }
}
}
// MARK: - Sub-views
private struct StatCard: View {
@@ -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"))
}
}
@@ -110,3 +110,4 @@ final class NextWeekNavigationUITests: XCTestCase {
XCTAssertTrue(app.otherElements["home_root"].exists, "Home disappeared after navigating to next week")
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
<key>CFBundleShortVersionString</key>
<string>2.1.1</string>
<key>CFBundleVersion</key>
<string>85</string>
<string>86</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
+1 -1
View File
@@ -17,7 +17,7 @@
<key>CFBundleShortVersionString</key>
<string>2.1.1</string>
<key>CFBundleVersion</key>
<string>85</string>
<string>86</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>