Treat order as presentation in seed logic; add per-seed restore and routine duplication

Drag-to-reorder writes Routine.order through the document, which used to
read as a content edit: isPristine would fork a starter for a mere reorder,
and reconcile's semantic compare would clobber a reordered seed file back
to bundle order. Both now normalize order (and updatedAt) away — a fixed-
ULID file still never holds user content; ordering is bookkeeping.

SyncEngine gains restoreSeed(id:) — the per-seed analog of the bulk
restore, sharing one restoreSeedIfEligible core — and duplicate(routine:),
which copies any routine (starter or not) to a fresh ULID with fresh
exercise ids, a unique "… Copy" name, and last position in the list.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
2026-07-15 10:45:02 -04:00
parent 36dad21233
commit 16b61946d5
5 changed files with 178 additions and 28 deletions
+30
View File
@@ -93,6 +93,36 @@ struct SeedLibraryTests {
#expect(!SeedLibrary.isPristine(edited))
}
/// Dragging a starter to reorder the Library list writes a new `order` to it. Order
/// is presentation, not content, so a reorder must NOT fork the seed an order-only
/// change reads as pristine.
@Test func pristineIgnoresOrderOnlyChange() throws {
let seed = try #require(SeedLibrary.seeds.first)
var edited = seed.doc
edited.order += 5
#expect(SeedLibrary.isPristine(edited))
}
/// A reorder save also stamps `updatedAt`; order + updatedAt together must still read
/// as pristine so a drag never forks a starter.
@Test func pristineIgnoresOrderAndUpdatedAtChange() throws {
let seed = try #require(SeedLibrary.seeds.first)
var edited = seed.doc
edited.order += 3
edited.updatedAt = Date()
#expect(SeedLibrary.isPristine(edited))
}
/// Order tolerance must not mask a genuine content edit: a real change (here, a
/// different rest length) is still detected even when `order` also differs.
@Test func pristineDetectsContentChangeDespiteOrderChange() throws {
let seed = try #require(SeedLibrary.seeds.first)
var edited = seed.doc
edited.order += 2
edited.restSeconds = (seed.doc.restSeconds ?? 60) + 30
#expect(!SeedLibrary.isPristine(edited))
}
/// A non-seed id has no seed to compare against; `isPristine` defaults to
/// true since callers are expected to gate on `isSeed` before asking.
@Test func pristineDefaultsTrueForNonSeedID() throws {
@@ -85,6 +85,44 @@ struct SeedReconcilePlannerTests {
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.quarantined))
}
// MARK: - Order tolerance (user reordered their starters)
/// The user dragged this starter to a new position, writing a new `order` to its
/// fixed-ULID file. That is presentation, not content, so reconcile must read it as
/// up to date and never clobber the ordering back to the bundle's default.
@Test func orderOnlyDriftIsUpToDate() {
let bundle = seed() // order 0
var live = bundle
live.order = 7 // user reordered their starters
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.upToDate))
}
/// A reorder save also stamps `updatedAt`; order + updatedAt drift together must
/// still read as up to date.
@Test func orderAndUpdatedAtDriftIsUpToDate() {
let bundle = seed()
var live = bundle
live.order = 4
live.updatedAt = Date() // a reorder save bumps updatedAt too
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.upToDate))
}
/// Order tolerance must not mask a genuine content upgrade: when the bundle content
/// changed AND the user had reordered (order differs too), it still upgrades the
/// order normalization can't swallow the real content diff.
@Test func contentUpgradeWinsOverOrderTolerance() {
let bundle = seed(exercises: [exercise(name: "Bench Press"), exercise(name: "Overhead Press", order: 1)])
var live = seed(exercises: [exercise(name: "Bench Press")]) // bundle gained an exercise
live.order = 9 // and the user had reordered
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .upgrade)
}
// MARK: - No live file
/// No live file but a stub exists the user deleted this seed. The veto stands.