bc684a099c
Dos cosas que faltaban, las dos visibles en domingo con el fin de semana desactivado: El envio al reloj solo llevaba la semana en curso, asi que no habia forma de ver las semanas ya planificadas por delante. Ahora viajan cuatro (la anterior, la actual y las dos siguientes) y la vista de semana se desliza entre ellas, arrancando en la actual. Y un dia fuera del plan dejaba al reloj sin nada que enseñar: el domingo no existe en el payload si no planificas fines de semana, asi que la complicacion salia vacia y la app decia "abre MealMood en el iPhone", como si fuera un problema de sincronizacion. Ahora se distingue: si hoy no se planifica se dice, y se enseña el proximo dia que si tiene comidas — que es lo util en el reloj. De paso, las semanas se comparan con el calendario alineado a lunes. Con el del sistema, en las regiones donde la semana empieza en domingo, un domingo caia en otra semana que su propio lunes y un envio recien hecho parecia caducado. Refs #36, #37 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
269 lines
11 KiB
Swift
269 lines
11 KiB
Swift
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)
|
||
}
|
||
}
|