Add pure helpers for routine ordering, copy naming, and a grouped exercise catalog
Groundwork for the Library tab: RoutineOrdering.changedOrders turns an .onMove gesture into the minimal set of order writes (normalizing legacy colliding orders on first drag), RoutineNaming.uniqueName picks the next free "X Copy" name for duplication, and ExerciseCatalog groups the bundled exercise library into curated category sections with name/category/target search. Plus ExerciseDocument.planSummary for read-only document rendering. All covered by unit tests, including a motion/info name-parity assertion. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks `ExerciseCatalog`'s section grouping and search — the curated,
|
||||
/// category-ordered view over the bundled exercise library. Every
|
||||
/// `ExerciseMotionLibrary.exerciseNames` entry must land in a real authored category,
|
||||
/// not the "Other" catch-all: a fall into "Other" here would mean a motion rig shipped
|
||||
/// without a matching `info.md` category — a real data gap worth surfacing, not
|
||||
/// something to silently bucket away.
|
||||
struct ExerciseCatalogTests {
|
||||
|
||||
@Test func everyBundledExerciseHasACuratedSection() {
|
||||
let otherSection = ExerciseCatalog.sections.first { $0.title == ExerciseCatalog.otherTitle }
|
||||
let uncategorized = otherSection?.names ?? []
|
||||
#expect(uncategorized.isEmpty,
|
||||
"expected every bundled exercise to have a curated category; found in Other: \(uncategorized)")
|
||||
|
||||
let allNames = Set(ExerciseCatalog.sections.flatMap(\.names))
|
||||
#expect(allNames == Set(ExerciseMotionLibrary.exerciseNames))
|
||||
}
|
||||
|
||||
@Test func sectionsCoverEveryNameExactlyOnce() {
|
||||
let allNames = ExerciseCatalog.sections.flatMap(\.names)
|
||||
#expect(allNames.count == Set(allNames).count, "an exercise appeared in more than one section")
|
||||
}
|
||||
|
||||
@Test func namesAreAlphabeticalWithinASection() {
|
||||
for section in ExerciseCatalog.sections {
|
||||
#expect(section.names == section.names.sorted(), "\(section.title) is not alphabetized")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func emptyQueryReturnsEverySection() {
|
||||
let all = ExerciseCatalog.sections(matching: "")
|
||||
#expect(all.flatMap(\.names).count == ExerciseCatalog.sections.flatMap(\.names).count)
|
||||
|
||||
let whitespaceOnly = ExerciseCatalog.sections(matching: " ")
|
||||
#expect(whitespaceOnly.flatMap(\.names).count == ExerciseCatalog.sections.flatMap(\.names).count)
|
||||
}
|
||||
|
||||
@Test func queryMatchesByExerciseName() {
|
||||
let results = ExerciseCatalog.sections(matching: "Chest Press")
|
||||
let names = results.flatMap(\.names)
|
||||
#expect(names.contains("Chest Press"))
|
||||
#expect(names.allSatisfy { $0.localizedCaseInsensitiveContains("Chest Press") })
|
||||
}
|
||||
|
||||
@Test func queryMatchesByCategory() {
|
||||
let results = ExerciseCatalog.sections(matching: "stretching")
|
||||
let names = Set(results.flatMap(\.names))
|
||||
#expect(!names.isEmpty)
|
||||
for name in names {
|
||||
let info = ExerciseInfoLibrary.info(for: name)
|
||||
#expect(info?.category?.localizedCaseInsensitiveContains("stretching") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func queryMatchesByTarget() throws {
|
||||
let info = try #require(ExerciseInfoLibrary.info(for: "Chest Press"))
|
||||
let target = try #require(info.targets.first)
|
||||
let results = ExerciseCatalog.sections(matching: target)
|
||||
#expect(results.flatMap(\.names).contains("Chest Press"))
|
||||
}
|
||||
|
||||
@Test func queryDropsSectionsWithNoMatches() {
|
||||
let results = ExerciseCatalog.sections(matching: "zzz-not-a-real-exercise-zzz")
|
||||
#expect(results.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks `RoutineNaming.uniqueName` — the "duplicate this routine" naming scheme.
|
||||
struct RoutineNamingTests {
|
||||
|
||||
@Test func firstCopyWhenNameIsFree() {
|
||||
#expect(RoutineNaming.uniqueName(base: "Upper Body", existing: []) == "Upper Body Copy")
|
||||
}
|
||||
|
||||
@Test func collisionAdvancesToCopyTwo() {
|
||||
#expect(RoutineNaming.uniqueName(
|
||||
base: "Upper Body", existing: ["Upper Body Copy"]
|
||||
) == "Upper Body Copy 2")
|
||||
}
|
||||
|
||||
@Test func multipleCollisionsAdvanceToCopyThree() {
|
||||
#expect(RoutineNaming.uniqueName(
|
||||
base: "Upper Body", existing: ["Upper Body Copy", "Upper Body Copy 2"]
|
||||
) == "Upper Body Copy 3")
|
||||
}
|
||||
|
||||
@Test func onlyLaterCollisionSkipsAhead() {
|
||||
// "Copy" itself is free, but "Copy 2" is taken — the first free slot is still
|
||||
// "Copy" since collisions are only checked, not assumed contiguous.
|
||||
#expect(RoutineNaming.uniqueName(
|
||||
base: "Upper Body", existing: ["Upper Body Copy 2"]
|
||||
) == "Upper Body Copy")
|
||||
}
|
||||
|
||||
@Test func matchIsCaseSensitive() {
|
||||
// A differently-cased existing name doesn't count as a collision.
|
||||
#expect(RoutineNaming.uniqueName(
|
||||
base: "Upper Body", existing: ["upper body copy"]
|
||||
) == "Upper Body Copy")
|
||||
}
|
||||
|
||||
@Test func unrelatedNamesDontCollide() {
|
||||
#expect(RoutineNaming.uniqueName(
|
||||
base: "Upper Body", existing: ["Lower Body", "Core"]
|
||||
) == "Upper Body Copy")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks `RoutineOrdering.changedOrders` — the pure `.onMove` → renumbered-order math
|
||||
/// shared by any list backed by a `SyncEngine` document write. Results are compared as
|
||||
/// `"id:order"` strings since tuple arrays aren't `Equatable`.
|
||||
struct RoutineOrderingTests {
|
||||
|
||||
private func describe(_ changes: [(id: String, order: Int)]) -> [String] {
|
||||
changes.map { "\($0.id):\($0.order)" }
|
||||
}
|
||||
|
||||
@Test func moveProducesMinimalChangedSet() {
|
||||
// A, B, C, D at orders 0...3; drag C (index 2) to the front. B and A shift by
|
||||
// one; D — already last — keeps its order and must NOT appear in the result.
|
||||
let changes = RoutineOrdering.changedOrders(
|
||||
ids: ["A", "B", "C", "D"],
|
||||
currentOrders: ["A": 0, "B": 1, "C": 2, "D": 3],
|
||||
from: IndexSet(integer: 2), to: 0
|
||||
)
|
||||
#expect(Set(describe(changes)) == Set(["C:0", "A:1", "B:2"]))
|
||||
}
|
||||
|
||||
@Test func noOpMoveYieldsEmptyChangeSet() {
|
||||
// Dropping a row back where it started (or an app-level tap that fires the
|
||||
// same source/destination) must produce zero document churn.
|
||||
let changes = RoutineOrdering.changedOrders(
|
||||
ids: ["A", "B", "C", "D"],
|
||||
currentOrders: ["A": 0, "B": 1, "C": 2, "D": 3],
|
||||
from: IndexSet(integer: 0), to: 0
|
||||
)
|
||||
#expect(changes.isEmpty)
|
||||
}
|
||||
|
||||
@Test func legacyDuplicateOrdersNormalizeOnFirstDrag() {
|
||||
// Legacy data where every row collided at order 0. Even a no-op array move
|
||||
// (A stays first) must renumber every row that isn't already at its correct
|
||||
// 0...n-1 position — the whole point of comparing against the stored value
|
||||
// rather than only the dragged row.
|
||||
let changes = RoutineOrdering.changedOrders(
|
||||
ids: ["A", "B", "C", "D"],
|
||||
currentOrders: ["A": 0, "B": 0, "C": 0, "D": 0],
|
||||
from: IndexSet(integer: 0), to: 0
|
||||
)
|
||||
#expect(Set(describe(changes)) == Set(["B:1", "C:2", "D:3"]))
|
||||
}
|
||||
|
||||
@Test func idMissingFromCurrentOrdersIsAlwaysIncluded() {
|
||||
// A newly-added row with no prior stored order can't be assumed unchanged.
|
||||
let changes = RoutineOrdering.changedOrders(
|
||||
ids: ["A", "B"],
|
||||
currentOrders: ["A": 0],
|
||||
from: IndexSet(integer: 0), to: 0
|
||||
)
|
||||
#expect(Set(describe(changes)) == Set(["B:1"]))
|
||||
}
|
||||
|
||||
@Test func moveToEndRenumbersOnlyDisplacedRows() {
|
||||
let changes = RoutineOrdering.changedOrders(
|
||||
ids: ["A", "B", "C", "D"],
|
||||
currentOrders: ["A": 0, "B": 1, "C": 2, "D": 3],
|
||||
from: IndexSet(integer: 0), to: 4
|
||||
)
|
||||
// A moves to the end; B, C, D each shift down one; nothing keeps its old order.
|
||||
#expect(Set(describe(changes)) == Set(["B:0", "C:1", "D:2", "A:3"]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user