Files
workouts/WorkoutsTests/ExerciseMotionTests.swift
T
rzen 669ecf1259 Surface machine settings in the exercise library and refine machine rigs
Library detail screens now lead with the user's recorded machine settings
(per-split when they disagree, empty-state card for machine-based entries)
and append the weight progression chart. Starter seeds mark machine
exercises with an empty machineSettings list so the settings UI lights up
before first use. The figure rig gains a frontal body profile for face-on
machines, props that can ride mid joints (knees/elbows), and an
alternating four-frame Bird Dog loop.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
2026-07-06 18:35:15 -04:00

89 lines
4.2 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")
// Bird Dog alternates sides, so all four limbs are in the working set and
// the loop is four key frames (lift right/left pair, then the opposite pair).
#expect(resources.motion.frames.count == 4)
#expect(resources.motion.working == ["arm_r", "leg_l", "arm_l", "leg_r"])
#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)
}
}
}