import Foundation import SwiftData import Testing import IndieSync @testable import Workouts /// Round-trips `ScheduleDocument` through `DocumentCoder` (the exact JSON codec every /// document in this app's iCloud container is written with), pins its on-disk path and /// the `isReadable` forward-gate, and checks the convenience accessors + the recurrence /// summary shared with the `Schedule` cache entity. Also exercises the document ↔ cache /// mapper both directions, mirroring `WorkoutDocumentMapperTests` for the schedule aggregate. struct ScheduleDocumentTests { /// Whole-second epochs so ISO-8601 round-trips without fractional-second loss. private static let created = Date(timeIntervalSince1970: 1_699_999_000) private static let updated = Date(timeIntervalSince1970: 1_700_004_000) /// A fixed one-off day for `.once` schedules. private static let onceDay = Date(timeIntervalSince1970: 1_752_105_600) private static let gregorianShortWeekdays = Calendar(identifier: .gregorian).shortWeekdaySymbols // MARK: - Codable /// A fully-populated `.fixedDays` schedule round-trips byte-for-byte through the codec, /// keeps its `Schedules/.json` path, and stays readable at the current version. @Test func encodeDecodeRoundTripFixedDays() throws { let original = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, id: ULID.make(), routineID: "01ROUTINEAAAAAAAAAAAAAAAAAA", routineName: "Push Day", goal: GoalKind.strength.rawValue, recurrence: ScheduleRecurrence.fixedDays.rawValue, weekdays: [2, 5], timesPerWeek: nil, order: 3, createdAt: Self.created, updatedAt: Self.updated ) let data = try DocumentCoder.encode(original) let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data) #expect(decoded == original) #expect(decoded.schemaVersion == 1) #expect(decoded.relativePath == "Schedules/\(original.id).json") #expect(decoded.isReadable) #expect(decoded.goalKind == .strength) #expect(decoded.recurrenceEnum == .fixedDays) #expect(decoded.timesPerWeek == nil) } /// The nil-heavy cases round-trip too: a `.daily` schedule with no goal, no weekdays, /// and no frequency count. Absent optionals decode back to nil (not phantom values). @Test func encodeDecodeRoundTripDailyWithNils() throws { let original = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, id: ULID.make(), routineID: "01ROUTINEBBBBBBBBBBBBBBBBBB", routineName: "Recovery", goal: nil, recurrence: ScheduleRecurrence.daily.rawValue, weekdays: nil, timesPerWeek: nil, order: 0, createdAt: Self.created, updatedAt: Self.updated ) let data = try DocumentCoder.encode(original) let json = String(decoding: data, as: UTF8.self) // nil optionals are omitted from the JSON entirely. ("date" is checked as a // quoted key — "updatedAt" contains it as a substring.) #expect(!json.contains("goal")) #expect(!json.contains("weekdays")) #expect(!json.contains("timesPerWeek")) #expect(!json.contains("\"date\"")) let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data) #expect(decoded == original) #expect(decoded.goal == nil) #expect(decoded.goalKind == nil) #expect(decoded.weekdays == nil) #expect(decoded.timesPerWeek == nil) #expect(decoded.date == nil) } /// A `.once` schedule carries its one-off day and drops the recurring-only fields. @Test func encodeDecodeRoundTripOnce() throws { let original = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, id: ULID.make(), routineID: "R", routineName: "Trail Run", goal: GoalKind.cardio.rawValue, recurrence: ScheduleRecurrence.once.rawValue, weekdays: nil, timesPerWeek: nil, date: Self.onceDay, order: 2, createdAt: Self.created, updatedAt: Self.updated ) let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original)) #expect(decoded == original) #expect(decoded.recurrenceEnum == .once) #expect(decoded.date == Self.onceDay) #expect(decoded.weekdays == nil) #expect(decoded.timesPerWeek == nil) } /// A `.frequency` schedule carries its count and drops weekdays. @Test func encodeDecodeRoundTripFrequency() throws { let original = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, id: ULID.make(), routineID: "R", routineName: "Cardio", goal: GoalKind.cardio.rawValue, recurrence: ScheduleRecurrence.frequency.rawValue, weekdays: nil, timesPerWeek: 3, order: 1, createdAt: Self.created, updatedAt: Self.updated ) let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: try DocumentCoder.encode(original)) #expect(decoded == original) #expect(decoded.recurrenceEnum == .frequency) #expect(decoded.timesPerWeek == 3) } // MARK: - Forward-compatibility gate /// A schedule written by a newer app version (schemaVersion + 1) fails the `isReadable` /// gate, so the sync layer quarantines rather than downgrades it; at (and below) the /// current version it reads. @Test func forwardGateQuarantinesNewerVersions() { func doc(schema: Int) -> ScheduleDocument { ScheduleDocument(schemaVersion: schema, id: "01X", routineID: "R", routineName: "N", goal: nil, recurrence: ScheduleRecurrence.daily.rawValue, weekdays: nil, timesPerWeek: nil, order: 0, createdAt: Self.created, updatedAt: Self.updated) } #expect(doc(schema: ScheduleDocument.currentSchemaVersion).isReadable) #expect(doc(schema: ScheduleDocument.currentSchemaVersion - 1).isReadable) #expect(!doc(schema: ScheduleDocument.currentSchemaVersion + 1).isReadable) } // MARK: - Recurrence summary /// The recurrence summary formats each recurrence kind and shares its logic across the /// document and the cache entity. Weekday names are pulled from a fixed Gregorian /// calendar (1 = Sun … 7 = Sat) so the mapping is locale-order-stable. @Test func recurrenceSummaryFormatsEachKind() { let sym = Self.gregorianShortWeekdays // Once — the one-off day, medium-date formatted (routing check; the format // itself is `Date.formatDate()`'s). Degenerate nil date → the display name. #expect(summary(.once, weekdays: nil, timesPerWeek: nil, date: Self.onceDay) == Self.onceDay.formatDate()) #expect(summary(.once, weekdays: nil, timesPerWeek: nil) == "Once") // Daily. #expect(summary(.daily, weekdays: nil, timesPerWeek: nil) == "Daily") // Fixed days — sorted, joined with "&"; weekday 2 = Mon, 5 = Thu. #expect(summary(.fixedDays, weekdays: [5, 2], timesPerWeek: nil) == "\(sym[1]) & \(sym[4])") // Single day → just the name. #expect(summary(.fixedDays, weekdays: [2], timesPerWeek: nil) == sym[1]) // Three days → Oxford-style "A, B & C". #expect(summary(.fixedDays, weekdays: [2, 4, 6], timesPerWeek: nil) == "\(sym[1]), \(sym[3]) & \(sym[5])") // Degenerate (no weekdays) → falls back to the recurrence display name. #expect(summary(.fixedDays, weekdays: [], timesPerWeek: nil) == "Fixed Days") #expect(summary(.fixedDays, weekdays: nil, timesPerWeek: nil) == "Fixed Days") // Out-of-range weekday numbers are ignored. #expect(summary(.fixedDays, weekdays: [0, 2, 9], timesPerWeek: nil) == sym[1]) // Frequency. #expect(summary(.frequency, weekdays: nil, timesPerWeek: 2) == "2× per week") #expect(summary(.frequency, weekdays: nil, timesPerWeek: nil) == "0× per week") } private func summary(_ r: ScheduleRecurrence, weekdays: [Int]?, timesPerWeek: Int?, date: Date? = nil) -> String { let doc = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, id: "X", routineID: "R", routineName: "N", goal: nil, recurrence: r.rawValue, weekdays: weekdays, timesPerWeek: timesPerWeek, date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated ) // The entity must format identically to the document — this is the shared-logic check. let entity = Schedule(id: "X", routineID: "R", routineName: "N", goalRaw: nil, recurrenceRaw: r.rawValue, weekdays: weekdays, timesPerWeek: timesPerWeek, date: date, order: 0, createdAt: Self.created, updatedAt: Self.updated, jsonRelativePath: "") #expect(doc.recurrenceSummary == entity.recurrenceSummary) return doc.recurrenceSummary } // MARK: - Mapper (document ↔ cache) @MainActor private func makeContext() throws -> ModelContext { let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self, Schedule.self]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) return ModelContext(try ModelContainer(for: schema, configurations: [config])) } /// A document pushed through `upsertSchedule` (document → cache) and rebuilt via /// `ScheduleDocument(from:)` (cache → document) preserves every field in both directions. @MainActor @Test func mapperRoundTripPreservesEveryField() throws { let context = try makeContext() let original = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, id: "01SCHEDULEROUNDTRIP0000001", routineID: "01ROUTINEXYZ00000000000000", routineName: "Leg Day", goal: GoalKind.mobility.rawValue, recurrence: ScheduleRecurrence.fixedDays.rawValue, weekdays: [2, 4, 6], timesPerWeek: nil, order: 7, createdAt: Self.created, updatedAt: Self.updated ) CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context) let entity = try #require(CacheMapper.fetchSchedule(id: original.id, in: context)) // Entity mirrors the document field-for-field. #expect(entity.routineID == original.routineID) #expect(entity.routineName == original.routineName) #expect(entity.goalRaw == original.goal) #expect(entity.goalKind == .mobility) #expect(entity.recurrenceRaw == original.recurrence) #expect(entity.recurrenceEnum == .fixedDays) #expect(entity.weekdays == [2, 4, 6]) #expect(entity.timesPerWeek == nil) #expect(entity.order == 7) #expect(entity.jsonRelativePath == "Schedules/\(original.id).json") // Cache → document round-trips back to the original. let rebuilt = ScheduleDocument(from: entity) #expect(rebuilt == original) } /// The one-off day survives document → cache (`upsertSchedule`) → document — both on /// first insert and when re-upserting clears it back to nil. @MainActor @Test func mapperPreservesOnceDate() throws { let context = try makeContext() let original = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, id: "01SCHEDULEONCE000000000001", routineID: "R", routineName: "Trail Run", goal: nil, recurrence: ScheduleRecurrence.once.rawValue, weekdays: nil, timesPerWeek: nil, date: Self.onceDay, order: 0, createdAt: Self.created, updatedAt: Self.updated ) CacheMapper.upsertSchedule(original, relativePath: original.relativePath, into: context) let entity = try #require(CacheMapper.fetchSchedule(id: original.id, in: context)) #expect(entity.date == Self.onceDay) #expect(entity.recurrenceEnum == .once) #expect(ScheduleDocument(from: entity) == original) // Editing it into a recurring schedule drops the day on re-upsert. var second = original second.recurrence = ScheduleRecurrence.daily.rawValue second.date = nil CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context) #expect(entity.date == nil) #expect(ScheduleDocument(from: entity) == second) } /// Re-upserting a changed document updates the existing entity in place (matched by /// id) rather than inserting a duplicate — the observer's re-import shape. @MainActor @Test func reUpsertUpdatesInPlace() throws { let context = try makeContext() let first = ScheduleDocument( schemaVersion: ScheduleDocument.currentSchemaVersion, id: "01SCHEDULEREUPSERT0000001", routineID: "R", routineName: "Old Name", goal: nil, recurrence: ScheduleRecurrence.daily.rawValue, weekdays: nil, timesPerWeek: nil, order: 0, createdAt: Self.created, updatedAt: Self.created ) CacheMapper.upsertSchedule(first, relativePath: first.relativePath, into: context) try context.save() var second = first second.routineName = "New Name" second.goal = GoalKind.strength.rawValue second.recurrence = ScheduleRecurrence.frequency.rawValue second.timesPerWeek = 4 second.updatedAt = Self.updated CacheMapper.upsertSchedule(second, relativePath: second.relativePath, into: context) try context.save() let all = try context.fetch(FetchDescriptor()) #expect(all.count == 1) // updated in place, not duplicated let entity = try #require(all.first) #expect(entity.routineName == "New Name") #expect(entity.goalKind == .strength) #expect(entity.recurrenceEnum == .frequency) #expect(entity.timesPerWeek == 4) #expect(ScheduleDocument(from: entity) == second) } }