Add the exercise reference library, animated exercise figures, and exercise categories
Exercise Library/ holds per-exercise reference docs (setup, cues, mistakes, progressions) with SVG visuals and a Python-rendered motion pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json rigs as animated stick figures on the exercise screen. Exercises gain a warm-up/main-circuit category, timed exercises display hold time via planSummary, and a completed exercise reopens to a check screen instead of its timers.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks the Swift motion solver to the Exercise Library's reference implementation
|
||||
/// (`Exercise Library/render.py`): the bundled rig resources must decode, and the
|
||||
/// FK / IK / tween math must reproduce values computed by the Python for the same
|
||||
/// data — the two renderers are meant to stay in lockstep.
|
||||
struct ExerciseMotionTests {
|
||||
|
||||
@Test func bundledBirdDogResourcesDecode() throws {
|
||||
let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
|
||||
#expect(resources.motion.name == "Bird Dog")
|
||||
#expect(resources.motion.frames.count == 2)
|
||||
#expect(resources.motion.working == ["arm_r", "leg_l"])
|
||||
#expect(resources.body.upperArm == 30)
|
||||
#expect(MotionTimeline(motion: resources.motion, body: resources.body) != nil)
|
||||
}
|
||||
|
||||
@Test func exerciseWithoutBundledMotionLoadsNothing() {
|
||||
#expect(ExerciseMotionLibrary.resources(for: "Bench Press") == nil)
|
||||
#expect(FigureAnimation(exerciseName: "Bench Press") == nil)
|
||||
}
|
||||
|
||||
/// Shortest-path angular interpolation (reference: `lerp_angle`).
|
||||
@Test func angleLerpTakesShortestPath() {
|
||||
#expect(MotionSolver.lerpAngle(170, -170, 0.5) == 180)
|
||||
#expect(MotionSolver.lerpAngle(-170, 170, 0.25) == -175)
|
||||
#expect(MotionSolver.lerpAngle(0, 90, 0.5) == 45)
|
||||
}
|
||||
|
||||
/// Normalizing Bird Dog's first key frame must reproduce the reference IK
|
||||
/// solution for the pinned support arm, and the pinned hand must land exactly
|
||||
/// on its target.
|
||||
@Test func normalizeMatchesReferenceIK() throws {
|
||||
let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
|
||||
let pose = MotionSolver.normalize(resources.motion.frames[0], body: resources.body)
|
||||
|
||||
let armR = try #require(pose.limbs[.armR])
|
||||
#expect(abs(armR[0] - (-82.28919)) < 1e-4)
|
||||
#expect(abs(armR[1] - (-99.732948)) < 1e-4)
|
||||
|
||||
let geo = MotionSolver.geometry(of: pose, body: resources.body, hide: [])
|
||||
let hand = try #require(geo.limbs[.armR]?.last)
|
||||
#expect(abs(hand.x - 105) < 1e-6)
|
||||
#expect(abs(hand.y - 152) < 1e-6)
|
||||
|
||||
#expect(abs(geo.headCenter.x - 86.195568) < 1e-4)
|
||||
#expect(abs(geo.headCenter.y - 95.140457) < 1e-4)
|
||||
}
|
||||
|
||||
/// Mid-tween (t = ease(0.5)): the hand pinned in BOTH key frames stays planted
|
||||
/// exactly; the pin present only in frame 1 releases; the lifting arm's angles
|
||||
/// match the reference interpolation.
|
||||
@Test func tweenKeepsSharedPinsAndReleasesOthers() throws {
|
||||
let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog"))
|
||||
let a = MotionSolver.normalize(resources.motion.frames[0], body: resources.body)
|
||||
let b = MotionSolver.normalize(resources.motion.frames[1], body: resources.body)
|
||||
let mid = MotionSolver.lerp(a, b, MotionSolver.ease(0.5))
|
||||
|
||||
#expect(mid.pins["hand_l"] != nil)
|
||||
#expect(mid.pins["hand_r"] == nil)
|
||||
|
||||
let geo = MotionSolver.geometry(of: mid, body: resources.body, hide: [])
|
||||
let plantedHand = try #require(geo.limbs[.armL]?.last)
|
||||
#expect(abs(plantedHand.x - 111) < 1e-6)
|
||||
#expect(abs(plantedHand.y - 154) < 1e-6)
|
||||
|
||||
let liftingArm = try #require(mid.limbs[.armR])
|
||||
#expect(abs(liftingArm[0] - (-140.644595)) < 1e-4)
|
||||
#expect(abs(liftingArm[1] - (-149.366474)) < 1e-4)
|
||||
}
|
||||
|
||||
/// Every exported motion in the bundle decodes and builds a playable timeline.
|
||||
@Test func allBundledMotionsBuildTimelines() throws {
|
||||
let urls = Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []
|
||||
let motionURLs = urls.filter { $0.lastPathComponent.hasSuffix(".motion.json") }
|
||||
#expect(!motionURLs.isEmpty)
|
||||
for url in motionURLs {
|
||||
let name = url.lastPathComponent.replacingOccurrences(of: ".motion.json", with: "")
|
||||
let resources = try #require(ExerciseMotionLibrary.resources(for: name))
|
||||
let timeline = try #require(MotionTimeline(motion: resources.motion, body: resources.body))
|
||||
#expect(timeline.duration > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ struct SplitDocumentCodableTests {
|
||||
loadType: 0,
|
||||
durationSeconds: 0,
|
||||
weightLastUpdated: Self.stableTimestamp,
|
||||
weightReminderWeeks: 4
|
||||
weightReminderWeeks: 4,
|
||||
category: ExerciseCategory.warmup.rawValue
|
||||
)
|
||||
let original = SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchemaVersion,
|
||||
@@ -42,4 +43,34 @@ struct SplitDocumentCodableTests {
|
||||
#expect(decoded == original)
|
||||
#expect(decoded.relativePath == "Splits/\(original.id).json")
|
||||
}
|
||||
|
||||
/// A pre-category exercise (no `category` key) must still decode — the field is
|
||||
/// optional and deliberately not schema-bumped, defaulting to the main circuit.
|
||||
@Test func decodesLegacyExerciseWithoutCategory() throws {
|
||||
let json = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "01HZZZZZZZZZZZZZZZZZZZZZZZ",
|
||||
"name": "Push Day",
|
||||
"color": "blue",
|
||||
"systemImage": "dumbbell.fill",
|
||||
"order": 0,
|
||||
"createdAt": "2023-11-14T22:13:20Z",
|
||||
"updatedAt": "2023-11-14T22:13:20Z",
|
||||
"exercises": [{
|
||||
"id": "01HZZZZZZZZZZZZZZZZZZZZZZY",
|
||||
"name": "Bench Press",
|
||||
"order": 0,
|
||||
"sets": 4,
|
||||
"reps": 10,
|
||||
"weight": 135,
|
||||
"loadType": 1,
|
||||
"durationSeconds": 0,
|
||||
"weightReminderWeeks": 4
|
||||
}]
|
||||
}
|
||||
"""
|
||||
let decoded = try DocumentCoder.decode(SplitDocument.self, from: Data(json.utf8))
|
||||
#expect(decoded.exercises.first?.category == nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user