| 1 | import { expect, it, vi } from "vite-plus/test"; |
| 2 | import { Quaternion, Vector3 } from "three"; |
| 3 | import { createDeviceMotion, rotationVector } from "./deviceMotion.ts"; |
| 4 | import { nearestDeviceView } from "./deviceViewSnap.ts"; |
| 5 | |
| 6 | const snaps = ["portrait", "landscape_left", "portrait_upside_down", "landscape_right"].map( |
| 7 | (orientation, index) => ({ |
| 8 | orientation, |
| 9 | rotation: new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), (-index * Math.PI) / 2), |
| 10 | yawLimit: Math.PI / 3, |
| 11 | }), |
| 12 | ); |
| 13 | const rotation = (x: number, y: number, z = 0) => |
| 14 | new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), Math.hypot(x, y, z)); |
| 15 | |
| 16 | it("selects the nearest quarter turn, retains a nearby yaw, and treats quaternion signs identically", () => { |
| 17 | const side = new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), -Math.PI / 2); |
| 18 | const released = side.clone().premultiply(rotation(0, 0.4)); |
| 19 | const nearest = nearestDeviceView(released, snaps)!; |
| 20 | expect(nearest.orientation).toBe("landscape_left"); |
| 21 | expect(nearest.rotation.angleTo(released)).toBeLessThan(1e-6); |
| 22 | const negative = released.clone().set(-released.x, -released.y, -released.z, -released.w); |
| 23 | expect(nearestDeviceView(negative, snaps)!.rotation.angleTo(nearest.rotation)).toBeLessThan(1e-6); |
| 24 | expect( |
| 25 | nearestDeviceView(rotation(0, 3), snaps)!.rotation.angleTo(new Quaternion()), |
| 26 | ).toBeLessThanOrEqual(Math.PI / 3 + 1e-6); |
| 27 | }); |
| 28 | |
| 29 | it("springs toward a cumulative drag without teleporting and keeps the same target across event partitions", () => { |
| 30 | const run = (parts: number) => { |
| 31 | const choose = vi.fn((q: Quaternion) => q); |
| 32 | const motion = createDeviceMotion({ choose }); |
| 33 | motion.dragActive(true, 0); |
| 34 | for (let part = 0; part < parts; part++) motion.orbit(160 / parts, 80 / parts, 0); |
| 35 | expect(motion.rotation.angleTo(new Quaternion())).toBeLessThan(1e-6); |
| 36 | for (let time = 8; time <= 400; time += 8) motion.advance(time); |
| 37 | expect(motion.rotation.angleTo(new Quaternion())).toBeGreaterThan(0.5); |
| 38 | motion.dragActive(false, 400); |
| 39 | for (let time = 408; time <= 2400; time += 8) motion.advance(time); |
| 40 | expect(choose).toHaveBeenCalledOnce(); |
| 41 | expect(motion.needsFrame()).toBe(false); |
| 42 | return motion.rotation; |
| 43 | }; |
| 44 | expect(run(1).angleTo(run(20))).toBeLessThan(1e-6); |
| 45 | }); |
| 46 | |
| 47 | it("selects the predicted nearest view once and preserves velocity when interrupted", () => { |
| 48 | const choose = vi.fn((q: Quaternion) => nearestDeviceView(q, snaps)!.rotation); |
| 49 | const motion = createDeviceMotion({ choose }); |
| 50 | motion.dragActive(true, 0); |
| 51 | motion.orbit(0, 240, 0); |
| 52 | motion.orbit(0, 20, 20); |
| 53 | motion.advance(40); |
| 54 | motion.dragActive(false, 40); |
| 55 | const before = motion.rotation.clone(); |
| 56 | motion.advance(80); |
| 57 | expect(motion.rotation.angleTo(before)).toBeGreaterThan(0.01); |
| 58 | const interrupted = motion.rotation.clone(); |
| 59 | motion.dragActive(true, 80); |
| 60 | expect(motion.rotation.angleTo(interrupted)).toBeLessThan(1e-6); |
| 61 | motion.orbit(30, -30, 80); |
| 62 | motion.advance(96); |
| 63 | motion.dragActive(false, 100); |
| 64 | for (let time = 116; time <= 2500; time += 16) motion.advance(time); |
| 65 | expect(choose).toHaveBeenCalledTimes(2); |
| 66 | expect(motion.needsFrame()).toBe(false); |
| 67 | }); |
| 68 | |
| 69 | it("lets a hard flick coast through multiple turns before settling on a visible view", () => { |
| 70 | const choose = vi.fn(() => new Quaternion()); |
| 71 | const motion = createDeviceMotion({ choose }); |
| 72 | motion.dragActive(true, 0); |
| 73 | motion.orbit(0, 80, 16); |
| 74 | motion.advance(16); |
| 75 | motion.orbit(0, 80, 32); |
| 76 | motion.advance(32); |
| 77 | motion.dragActive(false, 32); |
| 78 | |
| 79 | let previous = motion.rotation.clone(); |
| 80 | let travel = 0; |
| 81 | let lateSpeed = 0; |
| 82 | for (let time = 40; time <= 8500; time += 8) { |
| 83 | motion.advance(time); |
| 84 | const distance = motion.rotation.angleTo(previous); |
| 85 | travel += distance; |
| 86 | if (time >= 2500) lateSpeed = Math.max(lateSpeed, distance / 0.008); |
| 87 | previous = motion.rotation.clone(); |
| 88 | } |
| 89 | expect(travel).toBeGreaterThan(4 * Math.PI); |
| 90 | expect(lateSpeed).toBeLessThan(2.5); |
| 91 | expect(choose).toHaveBeenCalledOnce(); |
| 92 | expect(motion.rotation.angleTo(new Quaternion())).toBeLessThan(1e-6); |
| 93 | expect(motion.needsFrame()).toBe(false); |
| 94 | }); |
| 95 | |
| 96 | it("release uses elapsed time equally at different frame rates and a captured contact freezes projection", () => { |
| 97 | const run = (step: number) => { |
| 98 | const motion = createDeviceMotion({ choose: () => new Quaternion() }); |
| 99 | motion.dragActive(true, 0); |
| 100 | motion.orbit(160, 80, 0); |
| 101 | motion.advance(50); |
| 102 | motion.dragActive(false, 50); |
| 103 | for (let time = 50; time < 450; time += step) motion.advance(time); |
| 104 | motion.advance(450); |
| 105 | return motion; |
| 106 | }; |
| 107 | const fast = run(8), |
| 108 | slow = run(33), |
| 109 | throttled = run(1000); |
| 110 | expect(fast.rotation.angleTo(slow.rotation)).toBeLessThan(1e-6); |
| 111 | expect(fast.rotation.angleTo(throttled.rotation)).toBeLessThan(1e-6); |
| 112 | fast.hold(true, 450); |
| 113 | const contact = fast.rotation.clone(); |
| 114 | fast.orbit(100, 100, 500); |
| 115 | fast.advance(10000); |
| 116 | expect(fast.rotation.angleTo(contact)).toBeLessThan(1e-6); |
| 117 | fast.hold(false, 10000); |
| 118 | fast.advance(12000); |
| 119 | expect(fast.needsFrame()).toBe(false); |
| 120 | }); |
| 121 | |
| 122 | it("holds a trackpad orbit through a pause and snaps only when the gesture ends", () => { |
| 123 | const choose = vi.fn(() => new Quaternion()); |
| 124 | const motion = createDeviceMotion({ choose }); |
| 125 | motion.orbit(NaN, 0, 0); |
| 126 | expect(motion.needsFrame()).toBe(false); |
| 127 | motion.orbit(200, 100, 0); |
| 128 | motion.advance(100); |
| 129 | expect(choose).not.toHaveBeenCalled(); |
| 130 | expect(motion.rotation.angleTo(new Quaternion())).toBeGreaterThan(0); |
| 131 | motion.advance(140, true); |
| 132 | expect(choose).not.toHaveBeenCalled(); |
| 133 | const held = motion.rotation.clone(); |
| 134 | expect(held.angleTo(new Quaternion())).toBeGreaterThan(0.5); |
| 135 | expect(motion.needsFrame()).toBe(false); |
| 136 | motion.orbit(80, 0, 200); |
| 137 | motion.advance(340, true); |
| 138 | expect(motion.rotation.angleTo(held)).toBeGreaterThan(0.1); |
| 139 | expect(choose).not.toHaveBeenCalled(); |
| 140 | expect(motion.needsFrame()).toBe(false); |
| 141 | motion.dragActive(false, 340); |
| 142 | motion.advance(2500); |
| 143 | expect(choose).toHaveBeenCalledOnce(); |
| 144 | expect(motion.rotation.angleTo(new Quaternion())).toBeLessThan(1e-6); |
| 145 | expect(motion.needsFrame()).toBe(false); |
| 146 | expect(rotationVector(rotation(0, Math.PI)).length()).toBeCloseTo(Math.PI); |
| 147 | }); |
| 148 | |
| 149 | it("keeps release angular speed bounded even across a back-facing half turn", () => { |
| 150 | const motion = createDeviceMotion({ choose: () => rotation(0, Math.PI) }); |
| 151 | motion.setPose(rotation(0, Math.PI), 0); |
| 152 | let previous = motion.rotation.clone(); |
| 153 | for (let time = 8; time <= 1600; time += 8) { |
| 154 | motion.advance(time); |
| 155 | expect(motion.rotation.angleTo(previous)).toBeLessThanOrEqual(9 * 0.008 + 1e-5); |
| 156 | previous = motion.rotation.clone(); |
| 157 | } |
| 158 | expect(motion.rotation.angleTo(rotation(0, Math.PI))).toBeLessThan(1e-6); |
| 159 | expect(motion.needsFrame()).toBe(false); |
| 160 | }); |
| 161 | |
| 162 | it("a click without dragging resumes the interrupted resting view instead of stranding the device", () => { |
| 163 | const choose = vi.fn(() => new Quaternion()); |
| 164 | const motion = createDeviceMotion({ choose }); |
| 165 | const rest = rotation(0, 0.8); |
| 166 | motion.setPose(rest, 0); |
| 167 | motion.advance(50); |
| 168 | motion.dragActive(true, 50); |
| 169 | motion.advance(60); |
| 170 | motion.dragActive(false, 60); |
| 171 | motion.advance(2100); |
| 172 | expect(motion.rotation.angleTo(rest)).toBeLessThan(1e-6); |
| 173 | expect(choose).not.toHaveBeenCalled(); |
| 174 | expect(motion.needsFrame()).toBe(false); |
| 175 | }); |