The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
295 lines
14 KiB
Swift
295 lines
14 KiB
Swift
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/<id>.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<Schedule>())
|
||
#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)
|
||
}
|
||
}
|