1import { expect, it, vi } from "vite-plus/test";
2import { createDeviceModelSlot, resolveDeviceModelId, type DeviceModelSource } from "./model.ts";
3
4const pro: DeviceModelSource = { id: "iphone-18-pro", url: "/pro.glb" };
5const max: DeviceModelSource = { id: "iphone-18-pro-max", url: "/max.glb" };
6
7function fixture() {
8 const pending: {
9 signal: AbortSignal;
10 resolve: (model: { dispose: () => void }) => void;
11 reject: (error: Error) => void;
12 }[] = [];
13 const install = vi.fn();
14 const load = vi.fn(
15 (_source: DeviceModelSource, signal: AbortSignal) =>
16 new Promise<{ dispose: () => void }>((resolve, reject) =>
17 pending.push({ signal, resolve, reject }),
18 ),
19 );
20 const slot = createDeviceModelSlot({ load, install });
21 return { slot, pending, install, load };
22}
23
24it("matches only exact supported hardware, including iPad size and generation", () => {
25 expect(resolveDeviceModelId("ios", "iPhone 18 Pro")).toBe(pro.id);
26 expect(resolveDeviceModelId("ios", "iPhone 18 Pro Max")).toBe(max.id);
27 expect(resolveDeviceModelId("ios", "iPhone Duo")).toBe("iphone-duo");
28 expect(resolveDeviceModelId("ios", "iPad Pro 13-inch (M5)")).toBe("ipad-pro-13-m5");
29 for (const name of [
30 "iPhone 17 Pro",
31 "iPhone 18",
32 "iPad Pro 11-inch (M5)",
33 "iPad Pro 13-inch (M4)",
34 "Pixel_10_Pro",
35 ]) {
36 expect(resolveDeviceModelId("ios", name)).toBeNull();
37 }
38 expect(resolveDeviceModelId("android", "iPhone 18 Pro")).toBeNull();
39});
40
41it("cancels superseded downloads and releases a late decoded model without installing it", async () => {
42 const { slot, pending, install, load } = fixture();
43 slot.set(pro);
44 slot.set({ ...pro });
45 expect(load).toHaveBeenCalledTimes(1);
46 slot.set(max);
47 expect(pending[0]!.signal.aborted).toBe(true);
48 const latest = { dispose: vi.fn() };
49 const stale = { dispose: vi.fn() };
50 pending[1]!.resolve(latest);
51 await Promise.resolve();
52 pending[0]!.resolve(stale);
53 await Promise.resolve();
54 expect(install).toHaveBeenCalledExactlyOnceWith(latest);
55 expect(stale.dispose).toHaveBeenCalledOnce();
56 slot.set(null);
57 expect(install).toHaveBeenLastCalledWith(null);
58 expect(latest.dispose).toHaveBeenCalledOnce();
59 slot.dispose();
60 expect(latest.dispose).toHaveBeenCalledOnce();
61});
62
63it("keeps the fallback on request failure and disposes results that finish after teardown", async () => {
64 const { slot, pending, install } = fixture();
65 slot.set(pro);
66 pending[0]!.reject(new Error("offline"));
67 await Promise.resolve();
68 expect(install).not.toHaveBeenCalled();
69 slot.set(max);
70 slot.dispose();
71 expect(pending[1]!.signal.aborted).toBe(true);
72 const late = { dispose: vi.fn() };
73 pending[1]!.resolve(late);
74 await Promise.resolve();
75 expect(late.dispose).toHaveBeenCalledOnce();
76 expect(install).not.toHaveBeenCalled();
77});
78
79it("returns the current scene to the fallback before releasing its imported resources", async () => {
80 const { slot, pending, install } = fixture();
81 slot.set(pro);
82 const model = { dispose: vi.fn(() => expect(install).toHaveBeenLastCalledWith(null)) };
83 pending[0]!.resolve(model);
84 await Promise.resolve();
85 slot.dispose();
86 slot.dispose();
87 expect(model.dispose).toHaveBeenCalledOnce();
88});
89
90it("retries a failed source while ignoring rejection of a superseded request", async () => {
91 const { slot, pending, install, load } = fixture();
92 slot.set(pro);
93 pending[0]!.reject(new Error("offline"));
94 await Promise.resolve();
95 slot.set({ ...pro });
96 expect(load).toHaveBeenCalledTimes(2);
97 slot.set(max);
98 pending[1]!.reject(new Error("cancelled"));
99 await Promise.resolve();
100 slot.set({ ...max });
101 expect(load).toHaveBeenCalledTimes(3);
102 const recovered = { dispose: vi.fn() };
103 pending[2]!.resolve(recovered);
104 await Promise.resolve();
105 expect(install).toHaveBeenCalledExactlyOnceWith(recovered);
106 slot.dispose();
107});
108
109it("releases an invalid model and restores the fallback if scene preparation rejects it", async () => {
110 const { slot, pending, install } = fixture();
111 install.mockImplementationOnce(() => {
112 throw new Error("invalid display");
113 });
114 slot.set(pro);
115 const model = { dispose: vi.fn() };
116 pending[0]!.resolve(model);
117 await Promise.resolve();
118 expect(model.dispose).toHaveBeenCalledOnce();
119 expect(install).toHaveBeenLastCalledWith(null);
120 slot.set({ ...pro });
121 const recovered = { dispose: vi.fn() };
122 pending[1]!.resolve(recovered);
123 await Promise.resolve();
124 expect(install).toHaveBeenLastCalledWith(recovered);
125 slot.dispose();
126});