import XCTest import SwiftData @testable import MealMood /// Regression suite for "the watch showed Wednesday's meal on Saturday". /// /// The snapshot the iPhone sends carries an `isToday` flag, which is true only /// at the moment it was built. The watch outlives that moment — it keeps the /// last snapshot in its app group — so "today" has to be resolved when the view /// renders, never read from the payload. final class WatchWeekPayloadTests: XCTestCase { private let calendar = Calendar(identifier: .gregorian) private func date(_ year: Int, _ month: Int, _ day: Int) -> Date { calendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12))! } private func makePayload(weekStart: Date, todayFlagOn flaggedDay: Int?) -> WatchWeekPayload { let days = (0...6).map { offset in WatchWeekPayload.Day( dayOfWeek: offset, title: "Day \(offset)", isToday: offset == flaggedDay, date: calendar.date(byAdding: .day, value: offset, to: weekStart)!, meals: [ WatchWeekPayload.Meal(type: "dinner", label: "Dinner", name: "Dish \(offset)") ] ) } return WatchWeekPayload( weekTitle: "Week", days: days, updatedAt: weekStart, emptyMealText: "Not planned", emptyDayText: "Nothing planned for today", staleText: "Open MealMood on your iPhone" ) } // MARK: - The reported bug func testSaturdayShowsSaturdayEvenIfTheSnapshotWasBuiltOnWednesday() { let monday = date(2026, 9, 7) // Snapshot built on Wednesday: that's the day it flagged as "today". let payload = makePayload(weekStart: monday, todayFlagOn: 2) let saturday = date(2026, 9, 12) let today = payload.currentDay(now: saturday, calendar: calendar) XCTAssertEqual(today?.dayOfWeek, 5, "Saturday must win over the frozen Wednesday flag") XCTAssertEqual(today?.meals.first?.name, "Dish 5") XCTAssertFalse(payload.isStale(now: saturday, calendar: calendar)) } func testSnapshotFromAnotherWeekHasNoToday() { let lastMonday = date(2026, 9, 7) let payload = makePayload(weekStart: lastMonday, todayFlagOn: 2) let nextSaturday = date(2026, 9, 19) XCTAssertNil(payload.currentDay(now: nextSaturday, calendar: calendar)) XCTAssertTrue(payload.isStale(now: nextSaturday, calendar: calendar), "showing last week's meals as today's is exactly what must not happen") } func testEachDayKnowsWhetherItIsToday() { let monday = date(2026, 9, 7) let payload = makePayload(weekStart: monday, todayFlagOn: 2) let thursday = date(2026, 9, 10) XCTAssertTrue(payload.days[3].isCurrentDay(now: thursday, calendar: calendar)) XCTAssertFalse(payload.days[2].isCurrentDay(now: thursday, calendar: calendar), "the day flagged when the snapshot was built is not today any more") } // MARK: - Empty states func testDayWithNoDishesIsEmpty() { let empty = WatchWeekPayload.Day( dayOfWeek: 1, title: "Tue", isToday: true, date: date(2026, 9, 8), meals: [ WatchWeekPayload.Meal(type: "lunch", label: "Lunch", name: nil), WatchWeekPayload.Meal(type: "dinner", label: "Dinner", name: nil) ] ) XCTAssertTrue(empty.isEmpty) let planned = WatchWeekPayload.Day( dayOfWeek: 1, title: "Tue", isToday: true, date: date(2026, 9, 8), meals: [ WatchWeekPayload.Meal(type: "lunch", label: "Lunch", name: nil), WatchWeekPayload.Meal(type: "dinner", label: "Dinner", name: "Tortilla") ] ) XCTAssertFalse(planned.isEmpty, "one planned meal is enough for the day not to be empty") } func testPayloadCarriesTheLocalizedEmptyTexts() { // The watch bundle has no strings of its own, so these have to travel. let payload = makePayload(weekStart: date(2026, 9, 7), todayFlagOn: 0) XCTAssertEqual(payload.emptyMealText, "Not planned") XCTAssertEqual(payload.emptyDayText, "Nothing planned for today") XCTAssertEqual(payload.staleText, "Open MealMood on your iPhone") } // MARK: - Compatibility with snapshots already on people's watches func testSnapshotWrittenBeforeTheFixStillDecodes() throws { // Shape written by 2.1.0: no `date`, no texts. Stamped inside the // current week, which is when its weekday offsets still mean something. let wednesday = date(2026, 9, 9) let json = """ { "weekTitle": "7 – 13 sep", "updatedAt": \(wednesday.timeIntervalSinceReferenceDate), "days": [ {"dayOfWeek": 0, "title": "Mon 7", "isToday": false, "meals": [{"type": "dinner", "label": "Dinner", "name": "Pizza"}]}, {"dayOfWeek": 2, "title": "Wed 9", "isToday": true, "meals": [{"type": "dinner", "label": "Dinner", "name": "Salmon"}]} ] } """ let payload = try XCTUnwrap(WatchWeekPayload.decode(Data(json.utf8))) XCTAssertEqual(payload.days.count, 2) XCTAssertNil(payload.days[0].date) XCTAssertNil(payload.emptyMealText, "old snapshots have no texts; the UI falls back") // Without dates it can only fall back to the weekday offset. XCTAssertEqual(payload.currentDay(now: wednesday, calendar: calendar)?.dayOfWeek, 2) } func testDatelessSnapshotFromAnotherWeekIsStale() throws { // The watch that reported the bug had one of these: no dates, built in a // previous week. Trusting its weekday offsets is what showed the wrong // day, so it now counts as stale instead. let lastWednesday = date(2026, 9, 2) let json = """ { "weekTitle": "31 ago – 6 sep", "updatedAt": \(lastWednesday.timeIntervalSinceReferenceDate), "days": [ {"dayOfWeek": 5, "title": "Sat 5", "isToday": false, "meals": [{"type": "dinner", "label": "Dinner", "name": "Old dinner"}]} ] } """ let payload = try XCTUnwrap(WatchWeekPayload.decode(Data(json.utf8))) let saturday = date(2026, 9, 12) XCTAssertNil(payload.currentDay(now: saturday, calendar: calendar)) XCTAssertTrue(payload.isStale(now: saturday, calendar: calendar)) } // MARK: - A week with no plan yet @MainActor func testWeekWithoutAPlanStillProducesASnapshot() throws { // Staying quiet here is what left the watch showing the previous // week: no plan meant no push at all, so the old snapshot survived. let container = try ModelContainer( for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self, ShoppingItem.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true) ) let context = ModelContext(container) let settings = AppSettings() settings.activeMealTypes = [.lunch, .dinner] settings.includeWeekends = true context.insert(settings) let payload = try XCTUnwrap( WatchSyncService.makePayload(plan: nil, dishes: [], settings: settings), "a missing plan must still produce the current week" ) XCTAssertEqual(payload.days.count, 7) XCTAssertNotNil(payload.currentDay(), "today must be in there") XCTAssertTrue(payload.currentDay()?.isEmpty ?? false, "and it must read as nothing planned") XCTAssertFalse(payload.isStale(), "an empty current week is not stale") _ = container } // MARK: - Days that are deliberately not planned @MainActor func testSundayWithWeekendsOffSaysSoAndPointsAtMonday() throws { // The reported case: weekends off, so Sunday isn't in the payload at // all. That is not "out of date" — it's a day nobody plans. let container = try ModelContainer( for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self, ShoppingItem.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true) ) let context = ModelContext(container) let settings = AppSettings() settings.activeMealTypes = [.dinner] settings.includeWeekends = false context.insert(settings) let sunday = date(2026, 9, 13) let thisMonday = sunday.startOfWeek() let nextMonday = thisMonday.addingDays(7) let dish = Dish(name: "Lentejas") context.insert(dish) let nextWeekPlan = WeekPlan(weekStartDate: nextMonday) let slot = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue) slot.dishId = dish.id slot.weekPlan = nextWeekPlan nextWeekPlan.slotList.append(slot) context.insert(nextWeekPlan) let payload = try XCTUnwrap( WatchSyncService.makePayload(plans: [nextWeekPlan], dishes: [dish], settings: settings, now: sunday) ) XCTAssertTrue(payload.coversCurrentWeek(now: sunday, calendar: calendar)) XCTAssertFalse(payload.isStale(now: sunday, calendar: calendar), "a Sunday you don't plan is not a stale snapshot") switch payload.todayState(now: sunday, calendar: calendar) { case .notPlannedToday(let next): XCTAssertEqual(next?.meals.first?.name, "Lentejas", "it should point at the next planned day, not show nothing") default: XCTFail("Sunday with weekends off must report as not planned") } _ = container } // MARK: - Moving between weeks @MainActor func testThePhoneSendsTheNeighbouringWeeks() throws { let container = try ModelContainer( for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self, ShoppingItem.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true) ) let context = ModelContext(container) let settings = AppSettings() settings.activeMealTypes = [.lunch, .dinner] settings.includeWeekends = true context.insert(settings) let now = date(2026, 9, 13) let payload = try XCTUnwrap( WatchSyncService.makePayload(plans: [], dishes: [], settings: settings, now: now) ) let weeks = try XCTUnwrap(payload.weeks) XCTAssertEqual(weeks.count, WatchSyncService.weekOffsets.count, "without several weeks the watch has nothing to page through") XCTAssertEqual(weeks.filter { $0.isCurrentWeek(now: now, calendar: calendar) }.count, 1) XCTAssertEqual(payload.weekStartDate, now.startOfWeek()) // Weeks arrive in order, so paging forward moves forward in time. XCTAssertEqual(weeks.map(\.weekStartDate), weeks.map(\.weekStartDate).sorted()) _ = container } func testWeekdayOffsetMapsMondayToZeroAndSundayToSix() { XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 7), calendar: calendar), 0) XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 12), calendar: calendar), 5) XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 13), calendar: calendar), 6) } }