1import { expect, it, vi } from "vite-plus/test";
2import { Quaternion, Vector3 } from "three";
3import { createDeviceMotion, rotationVector } from "./deviceMotion.ts";
4import { nearestDeviceView } from "./deviceViewSnap.ts";
5
6const 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);
13const rotation = (x: number, y: number, z = 0) =>
14 new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), Math.hypot(x, y, z));
15
16it("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
29it("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
47it("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
69it("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
96it("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
122it("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
149it("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
162it("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});