Files
workouts/WorkoutsTests/ScheduleDocumentTests.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
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
2026-07-11 07:53:01 -04:00

295 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}