Open History to see the last commit
1import { afterEach, expect, it, vi } from "vite-plus/test";
2import type { Scene, PerspectiveCamera, Box3, Quaternion } from "three";
3
4const gpu = vi.hoisted(() => ({
5 instances: [] as {
6 blank: boolean;
7 allocations: number;
8 frames: Scene[];
9 views: { camera: PerspectiveCamera; bounds: Box3; quaternion: Quaternion }[];
10 dispose: ReturnType<typeof vi.fn>;
11 forceContextLoss: ReturnType<typeof vi.fn>;
12 }[],
13 environmentDispose: vi.fn(),
14}));
15vi.mock("three", async () => {
16 const actual = await vi.importActual<typeof import("three")>("three");
17 return {
18 ...actual,
19 WebGLRenderer: class {
20 state = {
21 blank: true,
22 allocations: 0,
23 frames: [] as Scene[],
24 views: [] as { camera: PerspectiveCamera; bounds: Box3; quaternion: Quaternion }[],
25 dispose: vi.fn(),
26 forceContextLoss: vi.fn(),
27 };
28 constructor() {
29 gpu.instances.push(this.state);
30 }
31 setDrawingBufferSize() {
32 this.state.blank = true;
33 this.state.allocations++;
34 }
35 render(scene: Scene, camera: PerspectiveCamera) {
36 this.state.blank = false;
37 this.state.frames.push(scene);
38 const root = scene.children.find((child) => child instanceof actual.Group)!;
39 root.updateMatrixWorld(true);
40 camera.updateMatrixWorld(true);
41 this.state.views.push({
42 camera: camera.clone(),
43 bounds: new actual.Box3().setFromObject(root),
44 quaternion: root.quaternion.clone(),
45 });
46 }
47 dispose() {
48 this.state.dispose();
49 }
50 forceContextLoss() {
51 this.state.forceContextLoss();
52 }
53 },
54 PMREMGenerator: class {
55 fromScene() {
56 return { texture: new actual.Texture(), dispose: gpu.environmentDispose };
57 }
58 dispose() {}
59 },
60 };
61});
62const models = vi.hoisted(() => ({
63 resolve: (_model: { asset: import("three").Group; dispose: () => void }) => {},
64 signal: null as AbortSignal | null,
65}));
66vi.mock("./modelScene.ts", () => ({
67 loadDeviceModel: (_source: unknown, signal: AbortSignal) => {
68 models.signal = signal;
69 return new Promise((resolve) => {
70 models.resolve = resolve;
71 });
72 },
73}));
74import { Group, Mesh, MeshBasicMaterial, PlaneGeometry } from "three";
75import { createDuoViewer } from "./duoViewer.ts";
76
77afterEach(() => {
78 vi.useRealTimers();
79 vi.unstubAllGlobals();
80 gpu.instances.length = 0;
81 gpu.environmentDispose.mockClear();
82});
83
84function fixture(
85 reduced = true,
86 onOrientationRequested = vi.fn(),
87 onPanelRequested?: (panel: 1 | 3) => void,
88) {
89 const pending = new Map<number, FrameRequestCallback>();
90 let id = 0;
91 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
92 pending.set(++id, callback);
93 return id;
94 });
95 vi.stubGlobal("cancelAnimationFrame", (id: number) => pending.delete(id));
96 vi.stubGlobal("matchMedia", () => ({ matches: reduced }));
97 const drawImage = vi.fn();
98 vi.stubGlobal("document", {
99 createElement: () => ({
100 width: 0,
101 height: 0,
102 getContext: () => ({
103 fillRect() {},
104 save() {},
105 restore() {},
106 translate() {},
107 rotate() {},
108 drawImage,
109 getImageData: () => ({ data: new Uint8ClampedArray(8 * 8 * 4).fill(255) }),
110 }),
111 }),
112 });
113 const canvas = new EventTarget() as HTMLCanvasElement;
114 const viewer = createDuoViewer({
115 canvas,
116 sources: {
117 1: { width: 1398, height: 2034 } as HTMLCanvasElement,
118 3: { width: 2007, height: 2853 } as HTMLCanvasElement,
119 },
120 model: { id: "iphone-duo", url: "/duo.glb" },
121 onUnavailable: vi.fn(),
122 onOrientationRequested,
123 ...(onPanelRequested ? { onPanelRequested } : {}),
124 });
125 const draw = () => {
126 const callbacks = [...pending.values()];
127 pending.clear();
128 callbacks.forEach((callback) => callback(0));
129 };
130 return { viewer, draw, pending, state: gpu.instances[0]!, onOrientationRequested, drawImage };
131}
132
133function asset() {
134 const group = new Group();
135 for (const [name, names] of [
136 ["left-half", ["cover-display", "inner-display-left"]],
137 ["right-half", ["inner-display-right"]],
138 ] as const) {
139 const leaf = new Group();
140 leaf.name = name;
141 for (const name of names) {
142 const mesh = new Mesh(new PlaneGeometry(1, 2), new MeshBasicMaterial());
143 if (name === "cover-display") mesh.geometry.rotateY(Math.PI);
144 mesh.geometry.translate(
145 name === "inner-display-right" ? 0.5 : -0.5,
146 0,
147 name === "cover-display" ? -0.05 : 0.05,
148 );
149 mesh.name = name;
150 leaf.add(mesh);
151 }
152 group.add(leaf);
153 }
154 return group;
155}
156
157it("cancels loading and disposes a late model after unmount without installing or rendering it", async () => {
158 const { viewer, draw, state } = fixture();
159 viewer.resize(400, 700, 2);
160 viewer.dispose();
161 expect(models.signal?.aborted).toBe(true);
162 const dispose = vi.fn();
163 models.resolve({ asset: asset(), dispose });
164 await Promise.resolve();
165 draw();
166 expect(dispose).toHaveBeenCalledOnce();
167 expect(state.frames).toHaveLength(0);
168});
169
170it("uses the elected primary frame during handoff and never lets a stale fixed-panel feed overwrite it", async () => {
171 const { viewer, drawImage } = fixture();
172 const cover = {
173 width: 1398,
174 height: 2034,
175 orientation: "portrait" as const,
176 screenId: 1,
177 hingeAngle: 90,
178 };
179 viewer.setScreen(cover);
180 const primary = { width: 1398, height: 2034 } as HTMLCanvasElement;
181 viewer.frameUpdated(1, primary);
182 expect(drawImage.mock.calls.at(-1)?.[0]).toBe(primary);
183 const uploads = drawImage.mock.calls.length;
184 viewer.frameUpdated(1);
185 expect(drawImage).toHaveBeenCalledTimes(uploads);
186 viewer.frameUpdated(3, primary);
187 expect(drawImage).toHaveBeenCalledTimes(uploads);
188 viewer.setScreen({ ...cover, width: 2007, height: 2853, screenId: 3 });
189 const inner = { width: 2007, height: 2853 } as HTMLCanvasElement;
190 viewer.frameUpdated(3, inner);
191 expect(drawImage.mock.calls.at(-1)?.[0]).toBe(inner);
192 viewer.setScreen(cover);
193 viewer.frameUpdated(1);
194 expect(drawImage.mock.calls.length).toBeGreaterThan(uploads + 1);
195 viewer.dispose();
196});