import Foundation import Testing @testable import Workouts /// Locks the Swift motion solver to the Exercise Library's anatomical 3D reference /// (`Exercise Library/kinematics.py` + `render.py`): the bundled rig resources must /// decode, and the FK / IK / frame-geometry / tween math must reproduce the projected /// geometry the Python computes — captured in `Fixtures/figure-fixtures.json`. 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 (support both hands, lift one arm/leg pair, and back). #expect(resources.motion.frames.count == 4) #expect(resources.motion.working == ["arm_l", "leg_r", "arm_r", "leg_l"]) #expect(resources.profile.upperArm == 30) #expect(MotionTimeline(motion: resources.motion, profile: resources.profile) != nil) } @Test func exerciseWithoutBundledMotionLoadsNothing() { #expect(ExerciseMotionLibrary.resources(for: "Bench Press") == nil) #expect(FigureAnimation(exerciseName: "Bench Press") == nil) } /// Resolving Bird Dog's first key frame must reproduce the reference IK: both /// pinned hands land exactly on their canvas targets, the head projects to the /// reference point, and the far right arm's solved anatomical angles match. @Test func birdDogFrameZeroMatchesReference() throws { let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog")) let cam = resources.motion.camera?.yaw ?? 0 let frame = MotionSolver.normalize(resources.motion.frames[0]) let (resolved, geo) = MotionSolver.frameGeometry(frame, prof: resources.profile, cam: cam) let handR = try #require(geo.limbs[.armR]?.last) #expect(abs(handR.x - 111) < 1e-3) #expect(abs(handR.y - 154) < 1e-3) let handL = try #require(geo.limbs[.armL]?.last) #expect(abs(handL.x - 105) < 1e-3) #expect(abs(handL.y - 152) < 1e-3) #expect(abs(geo.headCenter.x - 86.195568) < 1e-4) #expect(abs(geo.headCenter.y - 95.140457) < 1e-4) // The right arm is the far member here, so its angles solve against the // offset target; the drawn hand still lands on the authored pin above. #expect(geo.shade[.armR] == .far) #expect(geo.shade[.armL] == .near) #expect(abs(resolved.shoulderR.flexion - 73.289190) < 1e-4) #expect(abs(resolved.elbowR.flexion - 17.443758) < 1e-4) #expect(geo.order == ["arm_r", "leg_r", "spine", "arm_l", "leg_l", "head"]) } /// Mid-tween of resolved frames 1→2: a hand pinned in BOTH frames stays planted /// exactly; a pin present in only one frame releases and swings off its target. @Test func tweenKeepsSharedPinsAndReleasesOthers() throws { let resources = try #require(ExerciseMotionLibrary.resources(for: "Bird Dog")) let cam = resources.motion.camera?.yaw ?? 0 let r0 = MotionSolver.frameGeometry(MotionSolver.normalize(resources.motion.frames[0]), prof: resources.profile, cam: cam).0 let r1 = MotionSolver.frameGeometry(MotionSolver.normalize(resources.motion.frames[1]), prof: resources.profile, cam: cam).0 let mid = MotionSolver.lerpFrames(r0, r1, MotionSolver.ease(0.5)) #expect(mid.pins["hand_r"] != nil) #expect(mid.pins["hand_l"] == nil) let (_, geo) = MotionSolver.frameGeometry(mid, prof: resources.profile, cam: cam) let plantedHand = try #require(geo.limbs[.armR]?.last) #expect(abs(plantedHand.x - 111) < 1e-3) #expect(abs(plantedHand.y - 154) < 1e-3) let releasedHand = try #require(geo.limbs[.armL]?.last) #expect(hypot(releasedHand.x - 105, releasedHand.y - 152) > 1) } /// Prop-free bodyweight motions slowly orbit the camera while looping; motions with /// equipment (view-locked scene shapes) or a hide list keep their authored view. @Test func orbitAppliesOnlyToPropFreeMotions() throws { let birdDog = try #require(FigureAnimation(exerciseName: "Bird Dog")) #expect(birdDog.orbits) // Same loop phase seconds apart: the orbit yaw differs, so the projected head // moves even though the pose is identical. let duration = birdDog.timeline.duration let head0 = birdDog.geometry(at: 0).headCenter let head1 = birdDog.geometry(at: duration * 2).headCenter #expect(hypot(head0.x - head1.x, head0.y - head1.y) > 1) let legPress = try #require(FigureAnimation(exerciseName: "Leg Press")) #expect(!legPress.orbits) let fixed0 = legPress.geometry(at: 0).headCenter let fixed1 = legPress.geometry(at: legPress.timeline.duration * 2).headCenter #expect(abs(fixed0.x - fixed1.x) < 1e-9 && abs(fixed0.y - fixed1.y) < 1e-9) } /// 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, profile: resources.profile)) #expect(timeline.duration > 0) } } /// The projected-geometry ground truth: for every exercise and key frame, the draw /// order and near/far shading must match exactly and every point land within 0.5 px /// of the reference, plus the mid-tween sample and (Bird Dog) two orbit-camera views. @Test func figureFixturesMatchReference() throws { let bundle = Bundle(for: FigureFixtureMarker.self) let url = try #require( bundle.url(forResource: "figure-fixtures", withExtension: "json"), "figure-fixtures.json must be bundled as a WorkoutsTests resource (see project.yml)") let fixtures = try JSONDecoder().decode(FigureFixtures.self, from: Data(contentsOf: url)) #expect(fixtures.exercises.count == 22) for exercise in fixtures.exercises { let resources = try #require(ExerciseMotionLibrary.resources(for: exercise.name), "no bundled motion for \(exercise.name)") let cam = resources.motion.camera?.yaw ?? 0 let profile = resources.profile let norms = resources.motion.frames.map { MotionSolver.normalize($0) } var resolved: [NormalizedFrame] = [] for (index, frame) in norms.enumerated() { let (resolvedFrame, geo) = MotionSolver.frameGeometry(frame, prof: profile, cam: cam) resolved.append(resolvedFrame) if index < exercise.frames.count { expectMatch(geo, exercise.frames[index], "\(exercise.name) frame \(index)") } } let mid = MotionSolver.lerpFrames(resolved[0], resolved[1], MotionSolver.ease(exercise.tween.t)) expectMatch(MotionSolver.frameGeometry(mid, prof: profile, cam: cam).1, exercise.tween.sample, "\(exercise.name) tween") for orbit in exercise.orbit ?? [] { expectMatch(MotionSolver.frameGeometry(norms[0], prof: profile, cam: orbit.yaw).1, orbit.sample, "\(exercise.name) orbit \(orbit.yaw)") } } } } /// Marker for locating the test bundle that carries `figure-fixtures.json`. private final class FigureFixtureMarker {} // MARK: - Fixture decoding + comparison private struct FigureFixtures: Decodable { let profile: String let exercises: [FixtureExercise] } private struct FixtureExercise: Decodable { let name: String let camera: Double let frames: [FixtureSample] let tween: FixtureTween let orbit: [FixtureOrbit]? } private struct FixtureTween: Decodable { let t: Double; let sample: FixtureSample } private struct FixtureOrbit: Decodable { let yaw: Double; let sample: FixtureSample } private struct FixtureSample: Decodable { let order: [String] let shade: [String: String] let spine: [[Double]] let head: [Double] let nose: [[Double]]? let armR: [[Double]] let armL: [[Double]] let legR: [[Double]] let legL: [[Double]] enum CodingKeys: String, CodingKey { case order, shade, spine, head, nose case armR = "arm_r", armL = "arm_l", legR = "leg_r", legL = "leg_l" } } private func expectClose(_ point: CGPoint, _ expected: [Double], _ label: String) { #expect(abs(Double(point.x) - expected[0]) < 0.5 && abs(Double(point.y) - expected[1]) < 0.5, "\(label): got (\(point.x), \(point.y)) expected \(expected)") } private func expectChain(_ points: [CGPoint]?, _ expected: [[Double]], _ label: String) { guard let points, points.count == expected.count else { Issue.record("\(label): chain length mismatch") return } for (index, (point, target)) in zip(points, expected).enumerated() { expectClose(point, target, "\(label)[\(index)]") } } private func expectMatch(_ geo: FigureGeometry, _ fixture: FixtureSample, _ label: String) { #expect(geo.order == fixture.order, "\(label) order") let shade = Dictionary(uniqueKeysWithValues: geo.shade.map { ($0.key.rawValue, $0.value == .near ? "near" : "far") }) #expect(shade == fixture.shade, "\(label) shade") expectChain([geo.spineStart, geo.spineControl, geo.spineEnd], fixture.spine, "\(label).spine") expectClose(geo.headCenter, fixture.head, "\(label).head") if let nose = fixture.nose { #expect(geo.noseStart != nil && geo.noseEnd != nil, "\(label): expected a nose tick") if let start = geo.noseStart, let end = geo.noseEnd { expectClose(start, nose[0], "\(label).nose.start") expectClose(end, nose[1], "\(label).nose.end") } } else { #expect(geo.noseStart == nil, "\(label): expected no nose tick") } expectChain(geo.limbs[.armR], fixture.armR, "\(label).arm_r") expectChain(geo.limbs[.armL], fixture.armL, "\(label).arm_l") expectChain(geo.limbs[.legR], fixture.legR, "\(label).leg_r") expectChain(geo.limbs[.legL], fixture.legL, "\(label).leg_l") }