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.
87 lines
4.1 KiB
Swift
87 lines
4.1 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|