Fix rebucketing, seed upgrades, watch pruning, end re-stamping; add model tests
Editing a workout's start date now removes the file at its old month bucket so the record no longer duplicates on the next reconcile. Seed reconcile re-checks the tombstone veto before overwriting an upgraded seed. The watch applies authoritative-empty pushes so remote deletes prune, and a re-saved finished workout keeps its original end time. Adds unit tests for the mappers, path bucketing, and status machine. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import IndieSync
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks the workout status state machine on `WorkoutDocument` — the single
|
||||
/// source of the status-from-logs rule every screen funnels through
|
||||
/// (`recomputeStatusFromLogs`, `endKeepingProgress`) plus the per-log
|
||||
/// `WorkoutLogDocument.transition(to:)` timestamp bookkeeping. Includes the
|
||||
/// regression for the "end re-stamped on every edit" bug: recomputing an
|
||||
/// already-completed workout must keep its original `end`, not jump it to now.
|
||||
struct WorkoutStatusMachineTests {
|
||||
|
||||
private static let start = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
private static let originalEnd = Date(timeIntervalSince1970: 1_700_003_600)
|
||||
|
||||
private func log(id: String, order: Int, status: WorkoutStatus) -> WorkoutLogDocument {
|
||||
WorkoutLogDocument(
|
||||
id: id, exerciseName: "Ex-\(id)", order: order, sets: 3, reps: 10, weight: 100,
|
||||
loadType: LoadType.weight.rawValue, durationSeconds: 0, currentStateIndex: 0,
|
||||
status: status.rawValue, notes: nil, date: Self.start
|
||||
)
|
||||
}
|
||||
|
||||
private func workout(
|
||||
status: WorkoutStatus, end: Date?, logs: [WorkoutLogDocument]
|
||||
) -> WorkoutDocument {
|
||||
WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion, id: "01WK", splitID: "S",
|
||||
splitName: "S", start: Self.start, end: end, status: status.rawValue,
|
||||
createdAt: Self.start, updatedAt: Self.start, logs: logs, metrics: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - recomputeStatusFromLogs
|
||||
|
||||
/// All logs resolved (a mix of completed and skipped) → the workout is completed and
|
||||
/// gets an `end` stamped on the transition.
|
||||
@Test func allResolvedCompletesAndStampsEnd() {
|
||||
var w = workout(status: .inProgress, end: nil, logs: [
|
||||
log(id: "a", order: 0, status: .completed),
|
||||
log(id: "b", order: 1, status: .skipped),
|
||||
log(id: "c", order: 2, status: .completed),
|
||||
])
|
||||
w.recomputeStatusFromLogs()
|
||||
#expect(w.status == WorkoutStatus.completed.rawValue)
|
||||
#expect(w.end != nil) // stamped on the transition into completed
|
||||
}
|
||||
|
||||
/// Any log started but not all resolved → in progress, `end` cleared.
|
||||
@Test func anyStartedIsInProgressWithNoEnd() {
|
||||
var w = workout(status: .completed, end: Self.originalEnd, logs: [
|
||||
log(id: "a", order: 0, status: .completed),
|
||||
log(id: "b", order: 1, status: .inProgress),
|
||||
log(id: "c", order: 2, status: .notStarted),
|
||||
])
|
||||
w.recomputeStatusFromLogs()
|
||||
#expect(w.status == WorkoutStatus.inProgress.rawValue)
|
||||
#expect(w.end == nil) // reopened — a stale end must clear
|
||||
}
|
||||
|
||||
/// No log started → not started, `end` cleared.
|
||||
@Test func noneStartedIsNotStarted() {
|
||||
var w = workout(status: .inProgress, end: Self.originalEnd, logs: [
|
||||
log(id: "a", order: 0, status: .notStarted),
|
||||
log(id: "b", order: 1, status: .notStarted),
|
||||
])
|
||||
w.recomputeStatusFromLogs()
|
||||
#expect(w.status == WorkoutStatus.notStarted.rawValue)
|
||||
#expect(w.end == nil)
|
||||
}
|
||||
|
||||
/// A workout with no logs is not "all resolved" (the guard excludes the empty set) →
|
||||
/// not started.
|
||||
@Test func emptyLogsIsNotStarted() {
|
||||
var w = workout(status: .inProgress, end: Self.originalEnd, logs: [])
|
||||
w.recomputeStatusFromLogs()
|
||||
#expect(w.status == WorkoutStatus.notStarted.rawValue)
|
||||
#expect(w.end == nil)
|
||||
}
|
||||
|
||||
/// REGRESSION (FIX-3): an already-completed workout that still has all logs resolved
|
||||
/// keeps its ORIGINAL `end` when recompute runs again (e.g. the user re-opens the
|
||||
/// finished workout to fix a typo). The old code re-stamped `end = Date()` every
|
||||
/// time, jerking a months-old workout's end forward to now.
|
||||
@Test func alreadyCompletedKeepsOriginalEnd() {
|
||||
var w = workout(status: .completed, end: Self.originalEnd, logs: [
|
||||
log(id: "a", order: 0, status: .completed),
|
||||
log(id: "b", order: 1, status: .skipped),
|
||||
])
|
||||
w.recomputeStatusFromLogs()
|
||||
#expect(w.status == WorkoutStatus.completed.rawValue)
|
||||
#expect(w.end == Self.originalEnd) // preserved, not re-stamped to now
|
||||
}
|
||||
|
||||
/// A workout marked completed but somehow missing an `end` (defensive: a legacy or
|
||||
/// partially-written file) gets one stamped — the preservation only applies when a
|
||||
/// real end already exists.
|
||||
@Test func completedWithNilEndGetsStamped() {
|
||||
var w = workout(status: .completed, end: nil, logs: [
|
||||
log(id: "a", order: 0, status: .completed),
|
||||
])
|
||||
w.recomputeStatusFromLogs()
|
||||
#expect(w.status == WorkoutStatus.completed.rawValue)
|
||||
#expect(w.end != nil)
|
||||
}
|
||||
|
||||
// MARK: - endKeepingProgress
|
||||
|
||||
/// Ending a run in progress skips every not-completed log, keeps completed ones, and
|
||||
/// resolves the workout to completed with an `end` and a bumped `updatedAt`.
|
||||
@Test func endKeepingProgressSkipsRemainderAndCompletes() {
|
||||
var w = workout(status: .inProgress, end: nil, logs: [
|
||||
log(id: "a", order: 0, status: .completed),
|
||||
log(id: "b", order: 1, status: .inProgress),
|
||||
log(id: "c", order: 2, status: .notStarted),
|
||||
])
|
||||
w.endKeepingProgress()
|
||||
|
||||
#expect(w.status == WorkoutStatus.completed.rawValue)
|
||||
#expect(w.end != nil)
|
||||
#expect(w.logs[0].status == WorkoutStatus.completed.rawValue) // kept
|
||||
#expect(w.logs[1].status == WorkoutStatus.skipped.rawValue) // was in progress
|
||||
#expect(w.logs[2].status == WorkoutStatus.skipped.rawValue) // was not started
|
||||
#expect(w.updatedAt > Self.start)
|
||||
}
|
||||
|
||||
// MARK: - WorkoutLogDocument.transition
|
||||
|
||||
@Test func transitionToInProgressStampsStartOnce() {
|
||||
var l = log(id: "a", order: 0, status: .notStarted)
|
||||
l.transition(to: .inProgress)
|
||||
let firstStart = l.startedAt
|
||||
#expect(firstStart != nil)
|
||||
#expect(l.completedAt == nil)
|
||||
|
||||
// Re-entering inProgress must not overwrite the original startedAt.
|
||||
l.transition(to: .inProgress)
|
||||
#expect(l.startedAt == firstStart)
|
||||
}
|
||||
|
||||
@Test func transitionToCompletedStampsCompletionOnceAndKeepsStart() {
|
||||
var l = log(id: "a", order: 0, status: .notStarted)
|
||||
l.transition(to: .inProgress)
|
||||
let started = l.startedAt
|
||||
l.transition(to: .completed)
|
||||
let firstCompleted = l.completedAt
|
||||
#expect(l.status == WorkoutStatus.completed.rawValue)
|
||||
#expect(firstCompleted != nil)
|
||||
#expect(l.startedAt == started) // start preserved through completion
|
||||
|
||||
// A second completed transition must not move completedAt.
|
||||
l.transition(to: .completed)
|
||||
#expect(l.completedAt == firstCompleted)
|
||||
}
|
||||
|
||||
@Test func transitionToNotStartedClearsBothTimestamps() {
|
||||
var l = log(id: "a", order: 0, status: .completed)
|
||||
l.transition(to: .inProgress)
|
||||
l.transition(to: .completed)
|
||||
l.transition(to: .notStarted)
|
||||
#expect(l.status == WorkoutStatus.notStarted.rawValue)
|
||||
#expect(l.startedAt == nil)
|
||||
#expect(l.completedAt == nil)
|
||||
}
|
||||
|
||||
/// Skipping clears any completion stamp but leaves `startedAt` (the exercise may
|
||||
/// have been partway through before the user skipped the rest).
|
||||
@Test func transitionToSkippedClearsCompletionKeepsStart() {
|
||||
var l = log(id: "a", order: 0, status: .notStarted)
|
||||
l.transition(to: .inProgress)
|
||||
l.transition(to: .completed)
|
||||
let started = l.startedAt
|
||||
l.transition(to: .skipped)
|
||||
#expect(l.status == WorkoutStatus.skipped.rawValue)
|
||||
#expect(l.completedAt == nil)
|
||||
#expect(l.startedAt == started)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user