1import type { PerspectiveCamera, Scene, Object3D } from "three";
2import { afterEach, expect, it, vi } from "vite-plus/test";
3
4const gpu = vi.hoisted(() => ({
5 instances: [] as {
6 blank: boolean;
7 allocations: number;
8 disposed: boolean;
9 size: { width: number; height: number; pixelRatio: number };
10 frames: {
11 scene: Scene;
12 phone: Object3D | undefined;
13 rotation: Quaternion | undefined;
14 displayAngle: number | undefined;
15 yaw: number | undefined;
16 cameraZ: number;
17 }[];
18 }[],
19}));
20
21vi.mock("three", async () => {
22 const actual = await vi.importActual<typeof import("three")>("three");
23 return {
24 ...actual,
25 WebGLRenderer: class {
26 outputColorSpace = "";
27 state: (typeof gpu.instances)[number] = {
28 blank: true,
29 allocations: 0,
30 disposed: false,
31 size: { width: 0, height: 0, pixelRatio: 1 },
32 frames: [],
33 };
34 constructor() {
35 gpu.instances.push(this.state);
36 }
37 setDrawingBufferSize(width: number, height: number, pixelRatio: number) {
38 this.state.size = { width, height, pixelRatio };
39 this.state.allocations++;
40 this.state.blank = true;
41 }
42 setSize(width: number, height: number) {
43 this.setDrawingBufferSize(width, height, this.state.size.pixelRatio);
44 }
45 setPixelRatio(pixelRatio: number) {
46 this.setDrawingBufferSize(this.state.size.width, this.state.size.height, pixelRatio);
47 }
48 render(scene: Scene, camera: PerspectiveCamera) {
49 const phone = scene.children.find((child) => child.type === "Group");
50 this.state.frames.push({
51 scene,
52 phone,
53 rotation: phone?.quaternion.clone(),
54 displayAngle: phone?.children[0]?.rotation.z,
55 yaw: phone?.rotation.y,
56 cameraZ: camera.position.z,
57 });
58 this.state.blank = false;
59 }
60 dispose() {
61 this.state.disposed = true;
62 }
63 forceContextLoss() {}
64 },
65 };
66});
67
68const models = vi.hoisted(() => ({
69 pending: [] as {
70 signal: AbortSignal;
71 resolve: (model: { asset: import("three").Group; dispose: () => void }) => void;
72 }[],
73}));
74vi.mock("./modelScene.ts", async () => {
75 const actual = await vi.importActual<typeof import("./modelScene.ts")>("./modelScene.ts");
76 return {
77 ...actual,
78 loadDeviceModel: (_source: unknown, signal: AbortSignal) =>
79 new Promise((resolve) => models.pending.push({ signal, resolve })),
80 };
81});
82
83import {
84 Box3,
85 BoxGeometry,
86 Group,
87 Mesh,
88 MeshBasicMaterial,
89 PlaneGeometry,
90 Quaternion,
91 Vector3,
92} from "three";
93import { disposeDeviceModel } from "./modelScene.ts";
94import { createPhoneViewer } from "./phoneViewer.ts";
95import {
96 ANDROID_PHONE_SHAPE,
97 IOS_TABLET_SHAPE,
98 resolveDeviceShape,
99 type DeviceShapeProfile,
100} from "./shapeProfile.ts";
101
102afterEach(() => {
103 vi.restoreAllMocks();
104 vi.unstubAllGlobals();
105 gpu.instances.length = 0;
106 models.pending.length = 0;
107});
108
109function fixture(profile?: DeviceShapeProfile) {
110 const pending = new Map<number, FrameRequestCallback>();
111 let id = 0;
112 let now = 0;
113 vi.spyOn(performance, "now").mockImplementation(() => now);
114 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
115 pending.set(++id, callback);
116 return id;
117 });
118 vi.stubGlobal("cancelAnimationFrame", (id: number) => pending.delete(id));
119 const canvas = Object.assign(new EventTarget(), { width: 0, height: 0 }) as HTMLCanvasElement;
120 const source = { width: 1206, height: 2622 } as HTMLCanvasElement;
121 const onUnavailable = vi.fn();
122 const onFramingAspect = vi.fn();
123 const viewer = createPhoneViewer({
124 canvas,
125 source,
126 onUnavailable,
127 onFramingAspect,
128 ...(profile ? { profile } : {}),
129 });
130 const draw = (time = now) => {
131 now = time;
132 const callbacks = [...pending.values()];
133 pending.clear();
134 callbacks.forEach((callback) => callback(0));
135 };
136 viewer.resize(400, 700, 2);
137 draw();
138 return {
139 viewer,
140 draw,
141 pending,
142 source,
143 onUnavailable,
144 onFramingAspect,
145 state: gpu.instances[0]!,
146 };
147}
148
149it("retains the drawn phone until resize and redraw commit together, without replacing scene or pose", () => {
150 const { viewer, draw, pending, source, state, onUnavailable } = fixture();
151 viewer.orbit(0.08, 0.04);
152 draw();
153 const previous = state.frames.at(-1)!;
154 const allocations = state.allocations;
155 viewer.resize(500, 700, 2);
156 viewer.resize(450, 700, 2);
157 viewer.frameUpdated();
158 // ResizeObserver runs after rAF. A cleared buffer here would reach the browser's next paint.
159 expect(state.blank).toBe(false);
160 expect(state.allocations).toBe(allocations);
161 expect(pending.size).toBe(1);
162 draw();
163 expect(state.size).toEqual({ width: 450, height: 700, pixelRatio: 2 });
164 expect(state.blank).toBe(false);
165 expect(state.allocations).toBe(allocations + 1);
166 expect(state.frames.at(-1)?.scene).toBe(previous.scene);
167 expect(state.frames.at(-1)?.phone).toBe(previous.phone);
168 expect(state.frames.at(-1)?.yaw).toBe(previous.yaw);
169 viewer.resize(400, 700, 2);
170 draw();
171 expect(state.frames.at(-1)?.cameraZ).toBeCloseTo(previous.cameraZ);
172 expect(source).toMatchObject({ width: 1206, height: 2622 });
173 expect(gpu.instances).toHaveLength(1);
174 expect(onUnavailable).not.toHaveBeenCalled();
175 viewer.dispose();
176});
177
178it("ignores redundant and invalid sizes and combines a DPR change into one allocation and draw", () => {
179 const { viewer, draw, pending, state } = fixture();
180 const allocations = state.allocations;
181 viewer.resize(400, 700, 2);
182 viewer.resize(400, 700, 5);
183 viewer.resize(0, 700, 2);
184 viewer.resize(NaN, 700, 2);
185 expect(pending.size).toBe(0);
186 viewer.resize(400, 700, 1);
187 expect(state.blank).toBe(false);
188 draw();
189 expect(state.size.pixelRatio).toBe(1);
190 expect(state.allocations).toBe(allocations + 1);
191 viewer.resize(450, 700, 1);
192 viewer.dispose();
193 draw();
194 expect(state.allocations).toBe(allocations + 1);
195 expect(state.disposed).toBe(true);
196});
197
198it("changes device shape without replacing the renderer, decoded source or pose", () => {
199 const { viewer, draw, source, state } = fixture();
200 viewer.orbit(0.1, 0.05);
201 draw();
202 const previous = state.frames.at(-1)!;
203 const allocations = state.allocations;
204 viewer.setScreen(null, IOS_TABLET_SHAPE);
205 draw();
206 expect(state.frames.at(-1)?.phone).not.toBe(previous.phone);
207 expect(state.frames.at(-1)?.yaw).toBe(previous.yaw);
208 expect(state.frames.at(-1)?.scene).toBe(previous.scene);
209 expect(state.allocations).toBe(allocations);
210 expect(source.width).toBe(1206);
211 expect(gpu.instances).toHaveLength(1);
212 const tablet = state.frames.at(-1)?.phone;
213 viewer.frameUpdated();
214 draw();
215 expect(state.frames.at(-1)?.phone).toBe(tablet);
216 viewer.dispose();
217});
218
219it("keeps the Android viewer while the resized framebuffer turns between fold postures", () => {
220 const openProfile = resolveDeviceShape({ platform: "android", portraitAspect: 0.96 });
221 const { viewer, draw, source, state } = fixture(openProfile);
222 const scene = state.frames.at(-1)!.scene;
223 source.width = 2076;
224 source.height = 2152;
225 viewer.setScreen({ width: 2076, height: 2152, orientation: "landscape_left" });
226 viewer.frameUpdated();
227 draw(0);
228 expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(0);
229 draw(225);
230 expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 4);
231 draw(450);
232 expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 2);
233 expect(state.frames.at(-1)!.scene).toBe(scene);
234 expect(gpu.instances).toHaveLength(1);
235
236 source.width = 1080;
237 source.height = 2424;
238 viewer.setScreen({ width: 1080, height: 2424, orientation: "portrait" }, ANDROID_PHONE_SHAPE);
239 viewer.frameUpdated();
240 draw(450);
241 expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 2);
242 draw(675);
243 expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 4);
244 draw(900);
245 expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(0);
246 expect(state.frames.at(-1)!.scene).toBe(scene);
247 viewer.dispose();
248});
249
250it("animates the Android hinge on the same scene through an encoder resize", () => {
251 const openProfile = resolveDeviceShape({ platform: "android", portraitAspect: 0.96 });
252 const { viewer, draw, source, state } = fixture(openProfile);
253 viewer.setFoldAngle(180);
254 draw(0);
255 const shell = state.frames.at(-1)!.phone!;
256 const moving = shell.children[0]!.children[0]!;
257 viewer.setFoldAngle(0);
258 draw(425);
259 expect(moving.rotation.y).toBeCloseTo(Math.PI / 2);
260 source.width = 1080;
261 source.height = 2424;
262 viewer.setScreen({ width: 1080, height: 2424, orientation: "portrait" }, ANDROID_PHONE_SHAPE);
263 viewer.frameUpdated();
264 draw(850);
265 expect(moving.rotation.y).toBeCloseTo(Math.PI);
266 expect(state.frames.at(-1)!.phone).toBe(shell);
267 expect(gpu.instances).toHaveLength(1);
268 viewer.dispose();
269});
270
271it("resizes the fold body for a landscape inner display and keeps it through the cover frame", () => {
272 const openProfile = resolveDeviceShape({ platform: "android", portraitAspect: 0.83 });
273 const { viewer, draw, source, state } = fixture(openProfile);
274 viewer.setFoldAngle(180);
275 draw(0);
276 const portraitWidth = new Box3()
277 .setFromObject(state.frames.at(-1)!.phone!)
278 .getSize(new Vector3()).x;
279 source.width = 2208;
280 source.height = 1840;
281 viewer.setScreen({ width: 2208, height: 1840, orientation: "portrait" });
282 viewer.frameUpdated();
283 draw(10);
284 const landscape = state.frames.at(-1)!.phone!;
285 const landscapeWidth = new Box3().setFromObject(landscape).getSize(new Vector3()).x;
286 expect(landscapeWidth / portraitWidth).toBeGreaterThan(1.15);
287 source.width = 1080;
288 source.height = 2092;
289 viewer.setScreen({ width: 1080, height: 2092, orientation: "portrait" }, ANDROID_PHONE_SHAPE);
290 viewer.frameUpdated();
291 draw(20);
292 expect(state.frames.at(-1)!.phone).toBe(landscape);
293 expect(gpu.instances).toHaveLength(1);
294 viewer.dispose();
295});
296
297it("keeps the fold body through a rotated cover frame and learns the inner shape before fold mode", () => {
298 const { viewer, draw, source, state } = fixture(ANDROID_PHONE_SHAPE);
299 source.width = 2208;
300 source.height = 1840;
301 viewer.setScreen({ width: 2208, height: 1840, orientation: "portrait" });
302 viewer.frameUpdated();
303 draw(0);
304 viewer.setFoldAngle(180);
305 draw(10);
306 const landscape = state.frames.at(-1)!.phone!;
307 const width = new Box3().setFromObject(landscape).getSize(new Vector3()).x;
308 expect(width / new Box3().setFromObject(landscape).getSize(new Vector3()).y).toBeGreaterThan(1.1);
309 source.width = 2092;
310 source.height = 1080;
311 viewer.setScreen({ width: 2092, height: 1080, orientation: "landscape_left" });
312 viewer.frameUpdated();
313 draw(20);
314 expect(state.frames.at(-1)!.phone).toBe(landscape);
315 viewer.dispose();
316});
317
318it("retargets an unfinished hinge turn from its visible angle", () => {
319 const { viewer, draw, state } = fixture(ANDROID_PHONE_SHAPE);
320 viewer.setFoldAngle(180);
321 draw(0);
322 const moving = state.frames.at(-1)!.phone!.children[0]!.children[0]!;
323 viewer.setFoldAngle(0);
324 draw(200);
325 const visibleAngle = moving.rotation.y;
326 viewer.setFoldAngle(180);
327 draw(200);
328 expect(moving.rotation.y).toBeCloseTo(visibleAngle);
329 draw(1050);
330 expect(moving.rotation.y).toBeCloseTo(0);
331 viewer.dispose();
332});
333
334it("stops a hinge turn when a loaded model replaces the fold scene", async () => {
335 const { viewer, draw, pending } = fixture(ANDROID_PHONE_SHAPE);
336 viewer.setFoldAngle(180);
337 draw(0);
338 viewer.setFoldAngle(0);
339 viewer.setModel({ id: "iphone-18-pro", url: "/fold.glb" });
340 const asset = new Group();
341 const body = new Mesh(new BoxGeometry(1, 2, 0.1), new MeshBasicMaterial());
342 const display = new Mesh(new PlaneGeometry(0.9, 1.9), new MeshBasicMaterial());
343 display.name = "device-screen";
344 asset.add(body, display);
345 models.pending[0]!.resolve({ asset, dispose: () => disposeDeviceModel(asset) });
346 await Promise.resolve();
347 draw(200);
348 draw(1050);
349 expect(pending.size).toBe(0);
350 viewer.dispose();
351});
352
353it("keeps a loaded model when the fold angle changes and releases it once", async () => {
354 const { viewer, draw, state } = fixture(ANDROID_PHONE_SHAPE);
355 viewer.setModel({ id: "iphone-18-pro", url: "/pro.glb" });
356 const asset = new Group();
357 const body = new Mesh(new BoxGeometry(1.15, 2.3, 0.1), new MeshBasicMaterial());
358 body.position.z = -0.02;
359 const display = new Mesh(new PlaneGeometry(1, 2.2), new MeshBasicMaterial());
360 display.geometry.translate(0, 0, 0.043);
361 display.name = "device-screen";
362 asset.add(body, display);
363 const dispose = vi.fn(() => disposeDeviceModel(asset));
364 models.pending[0]!.resolve({ asset, dispose });
365 await Promise.resolve();
366 draw();
367 const loaded = state.frames.at(-1)!.phone;
368 expect(loaded?.getObjectByName("device-screen")).toBe(display);
369 viewer.setFoldAngle(180);
370 viewer.setFoldAngle(0);
371 draw();
372 expect(state.frames.at(-1)!.phone).toBe(loaded);
373 viewer.dispose();
374 expect(dispose).toHaveBeenCalledOnce();
375});
376
377it("retains the loaded model and pose through rotation and framebuffer resolution changes, then releases it once", async () => {
378 const { viewer, draw, source, state } = fixture();
379 viewer.orbit(0.08, 0.04);
380 draw();
381 const yaw = state.frames.at(-1)!.yaw;
382 viewer.setModel({ id: "iphone-18-pro", url: "/pro.glb" });
383 const asset = new Group();
384 const body = new Mesh(new BoxGeometry(1.15, 2.3, 0.1), new MeshBasicMaterial());
385 body.position.z = -0.02;
386 const display = new Mesh(new PlaneGeometry(1, 2.2), new MeshBasicMaterial());
387 display.geometry.translate(0, 0, 0.043);
388 display.name = "device-screen";
389 asset.add(body, display);
390 const release = vi.spyOn(body.geometry, "dispose");
391 const dispose = vi.fn(() => disposeDeviceModel(asset));
392 models.pending[0]!.resolve({ asset, dispose });
393 await Promise.resolve();
394 draw();
395 const loaded = state.frames.at(-1)!.phone;
396 expect(loaded?.getObjectByName("device-screen")).toBe(display);
397 expect(state.frames.at(-1)!.yaw).toBe(yaw);
398 source.width = 2622;
399 source.height = 1206;
400 viewer.setScreen({ width: 2622, height: 1206, orientation: "landscape_left" });
401 viewer.frameUpdated();
402 viewer.resize(700, 400, 1);
403 draw();
404 expect(state.frames.at(-1)!.phone).toBe(loaded);
405 expect(state.frames.at(-1)!.yaw).toBe(yaw);
406 expect(display.material.map?.image).toBe(source);
407 expect(release).not.toHaveBeenCalled();
408 expect(gpu.instances).toHaveLength(1);
409 viewer.dispose();
410 viewer.dispose();
411 expect(dispose).toHaveBeenCalledOnce();
412 expect(release).toHaveBeenCalledOnce();
413});
414
415it("attaches and detaches an accessory without replacing the device, stream texture, renderer or pose", async () => {
416 const { viewer, draw, source, state, onFramingAspect } = fixture();
417 viewer.setModel({ id: "ipad-pro-13-m5", url: "/ipad.glb" });
418 const asset = new Group();
419 const body = new Mesh(new BoxGeometry(1.8, 2.3, 0.1), new MeshBasicMaterial());
420 const display = new Mesh(new PlaneGeometry(1.7, 2.2), new MeshBasicMaterial());
421 display.geometry.translate(0, 0, 0.043);
422 display.name = "device-screen";
423 asset.add(body, display);
424 const releaseBody = vi.fn(() => disposeDeviceModel(asset));
425 models.pending[0]!.resolve({ asset, dispose: releaseBody });
426 await Promise.resolve();
427 viewer.orbit(0.1, 0.05);
428 draw();
429 const before = state.frames.at(-1)!;
430 const bareAspect = onFramingAspect.mock.lastCall![0];
431 const keyboard = new Group();
432 const deck = new Mesh(new BoxGeometry(2, 1, 1), new MeshBasicMaterial());
433 deck.position.set(1, 0, 0.5);
434 keyboard.add(deck);
435 const releaseKeyboard = vi.fn(() => disposeDeviceModel(keyboard));
436 viewer.setAccessory({
437 id: "ipad-pro-13-m5-magic-keyboard",
438 modelId: "ipad-pro-13-m5",
439 url: "/keyboard.glb",
440 });
441 models.pending[1]!.resolve({ asset: keyboard, dispose: releaseKeyboard });
442 await Promise.resolve();
443 draw();
444 expect(state.frames.at(-1)!.phone).toBe(before.phone);
445 expect(keyboard.parent).toBe(asset.parent);
446 expect(state.frames.at(-1)!.yaw).toBe(before.yaw);
447 expect(display.material.map?.image).toBe(source);
448 expect(state.frames.at(-1)!.cameraZ).toBeGreaterThan(before.cameraZ);
449 expect(onFramingAspect.mock.lastCall![0]).toBeGreaterThan(bareAspect);
450 const calls = onFramingAspect.mock.calls.length;
451 viewer.orbit(0.1, 0.05);
452 viewer.frameUpdated();
453 draw();
454 expect(onFramingAspect).toHaveBeenCalledTimes(calls);
455 viewer.setAccessory(null);
456 draw();
457 expect(keyboard.parent).toBeNull();
458 expect(onFramingAspect.mock.lastCall![0]).toBeCloseTo(bareAspect);
459 expect(releaseKeyboard).toHaveBeenCalledOnce();
460 expect(releaseBody).not.toHaveBeenCalled();
461 expect(state.frames.at(-1)!.phone).toBe(before.phone);
462 expect(state.frames.at(-1)!.cameraZ).toBeCloseTo(before.cameraZ);
463 expect(gpu.instances).toHaveLength(1);
464 viewer.dispose();
465 expect(releaseBody).toHaveBeenCalledOnce();
466 expect(releaseKeyboard).toHaveBeenCalledOnce();
467});
468
469it("rejects incompatible accessories and releases an accessory that finishes after detaching", async () => {
470 const { viewer } = fixture();
471 const keyboard = {
472 id: "ipad-pro-13-m5-magic-keyboard",
473 modelId: "ipad-pro-13-m5",
474 url: "/keyboard.glb",
475 } as const;
476 viewer.setAccessory(keyboard);
477 expect(models.pending).toHaveLength(0);
478 viewer.setModel({ id: "ipad-pro-13-m5", url: "/ipad.glb" });
479 viewer.setAccessory(keyboard);
480 viewer.setAccessory(null);
481 expect(models.pending[1]!.signal.aborted).toBe(true);
482 const dispose = vi.fn();
483 const asset = new Group();
484 models.pending[1]!.resolve({ asset, dispose });
485 await Promise.resolve();
486 expect(asset.parent).toBeNull();
487 expect(dispose).toHaveBeenCalledOnce();
488 viewer.dispose();
489});
490
491it("springs an ordinary device back toward its screen, freezes captured input, and stops rendering at rest", () => {
492 const { viewer, draw, pending, state } = fixture();
493 const initialYaw = state.frames.at(-1)!.yaw!;
494 viewer.setInteractionActive(true, "orbit");
495 viewer.orbit(0.8, 0.4);
496 draw(80);
497 const dragged = state.frames.at(-1)!;
498 expect(Math.abs(dragged.yaw! - initialYaw)).toBeGreaterThan(0.05);
499 viewer.setInteractionActive(false, "orbit");
500 draw(160);
501 viewer.setInteractionActive(true, "touch");
502 draw(180);
503 const captured = state.frames.at(-1)!;
504 viewer.orbit(1, 1);
505 viewer.frameUpdated();
506 draw(1000);
507 expect(state.frames.at(-1)!.yaw).toBe(captured.yaw);
508 expect(state.frames.at(-1)!.cameraZ).toBe(captured.cameraZ);
509 expect(pending.size).toBe(0);
510 viewer.setInteractionActive(false, "touch");
511 for (let time = 1016; time <= 4000 && pending.size; time += 16) draw(time);
512 expect(pending.size).toBe(0);
513 expect(Math.abs(state.frames.at(-1)!.yaw!)).toBeLessThanOrEqual(Math.PI / 3 + 1e-6);
514 expect(state.frames.at(-1)!.phone).toBe(dragged.phone);
515 viewer.dispose();
516});
517
518it("resets the device to a square front view after orbiting", () => {
519 const { viewer, draw, pending, state } = fixture();
520 const front = state.frames.at(-1)!.rotation!;
521 expect(front.angleTo(new Quaternion())).toBeLessThan(1e-6);
522 viewer.orbit(0.8, 0.4);
523 draw(80);
524 expect(state.frames.at(-1)!.rotation!.angleTo(front)).toBeGreaterThan(0.05);
525 viewer.resetPose();
526 for (let time = 96; time <= 3000 && pending.size; time += 16) draw(time);
527 expect(pending.size).toBe(0);
528 expect(state.frames.at(-1)!.rotation!.angleTo(new Quaternion())).toBeLessThan(1e-6);
529 viewer.dispose();
530});