1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const mocks = vi.hoisted(() => ({ createPlayer: vi.fn() }));
4vi.mock("expo-video", () => ({ createVideoPlayer: mocks.createPlayer }));
5
6let thumbnails: typeof import("./videoThumbnails");
7const frame = { width: 480, height: 270 };
8const player = () => ({
9 replaceAsync: vi.fn(async (): Promise<void> => {}),
10 generateThumbnailsAsync: vi.fn(async () => [frame]),
11 release: vi.fn(),
12});
13const source = () => ({ uri: "file:///clip.mp4", dispose: vi.fn() });
14
15beforeEach(async () => {
16 vi.resetModules();
17 mocks.createPlayer.mockReset().mockImplementation(player);
18 thumbnails = await import("./videoThumbnails");
19});
20
21afterEach(() => vi.useRealTimers());
22
23describe("video thumbnails", () => {
24 it("reuses a frame for duplicate requests and refreshed signed URLs", async () => {
25 const file = source();
26 const resolveSource = vi.fn(async () => file);
27 const signal = new AbortController().signal;
28 const results = await Promise.all([
29 thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal),
30 thumbnails.loadVideoThumbnail("env:clip", resolveSource, signal),
31 ]);
32 expect(results).toEqual([frame, frame]);
33 expect(resolveSource).toHaveBeenCalledTimes(1);
34 expect(mocks.createPlayer).toHaveBeenCalledTimes(1);
35 expect(file.dispose).toHaveBeenCalledTimes(1);
36 const refreshed = vi.fn(async () => ({ ...source(), uri: "https://host/new-token/clip.mp4" }));
37 expect(await thumbnails.loadVideoThumbnail("env:clip", refreshed, signal)).toBe(frame);
38 expect(refreshed).not.toHaveBeenCalled();
39 });
40
41 it("serializes decoding and skips queued requests that scroll out of view", async () => {
42 const started = Promise.withResolvers<void>();
43 const generated = Promise.withResolvers<(typeof frame)[]>();
44 const first = player();
45 first.generateThumbnailsAsync.mockImplementation(() => {
46 started.resolve();
47 return generated.promise;
48 });
49 mocks.createPlayer.mockReturnValueOnce(first);
50 const firstRequest = thumbnails.loadVideoThumbnail(
51 "first",
52 async () => source(),
53 new AbortController().signal,
54 );
55 await started.promise;
56 const removed = new AbortController();
57 const skipped = vi.fn(async () => source());
58 const queued = thumbnails.loadVideoThumbnail("removed", skipped, removed.signal);
59 const next = vi.fn(async () => source());
60 const nextRequest = thumbnails.loadVideoThumbnail("next", next, new AbortController().signal);
61 expect(next).not.toHaveBeenCalled();
62 removed.abort();
63 generated.resolve([frame]);
64 expect(await firstRequest).toBe(frame);
65 expect(await queued).toBeNull();
66 expect(await nextRequest).toBe(frame);
67 expect(skipped).not.toHaveBeenCalled();
68 expect(first.release).toHaveBeenCalledTimes(1);
69 });
70
71 it("releases an active canceled player and ignores late source loading", async () => {
72 const started = Promise.withResolvers<void>();
73 const replaced = Promise.withResolvers<void>();
74 const first = player();
75 first.replaceAsync.mockImplementation(() => {
76 started.resolve();
77 return replaced.promise;
78 });
79 mocks.createPlayer.mockReturnValueOnce(first);
80 const file = source();
81 const controller = new AbortController();
82 const request = thumbnails.loadVideoThumbnail("canceled", async () => file, controller.signal);
83 await started.promise;
84 controller.abort();
85 expect(await request).toBeNull();
86 expect(first.release).toHaveBeenCalledTimes(1);
87 expect(file.dispose).toHaveBeenCalledTimes(1);
88 replaced.resolve();
89 expect(
90 await thumbnails.loadVideoThumbnail(
91 "next",
92 async () => source(),
93 new AbortController().signal,
94 ),
95 ).toBe(frame);
96 expect(first.generateThumbnailsAsync).not.toHaveBeenCalled();
97 expect(thumbnails.cachedVideoThumbnail("canceled")).toBeNull();
98 });
99
100 it("releases failed extractions and permits a later retry", async () => {
101 const broken = player();
102 broken.generateThumbnailsAsync.mockRejectedValue(new Error("Invalid video"));
103 mocks.createPlayer.mockReturnValueOnce(broken);
104 const file = source();
105 expect(
106 await thumbnails.loadVideoThumbnail("retry", async () => file, new AbortController().signal),
107 ).toBeNull();
108 expect(broken.release).toHaveBeenCalledTimes(1);
109 expect(file.dispose).toHaveBeenCalledTimes(1);
110 expect(
111 await thumbnails.loadVideoThumbnail(
112 "retry",
113 async () => source(),
114 new AbortController().signal,
115 ),
116 ).toBe(frame);
117 });
118
119 it("does not let an unreachable source block the queue indefinitely", async () => {
120 vi.useFakeTimers();
121 const started = Promise.withResolvers<void>();
122 const first = player();
123 first.replaceAsync.mockImplementation(() => {
124 started.resolve();
125 return new Promise(() => {});
126 });
127 mocks.createPlayer.mockReturnValueOnce(first);
128 const file = source();
129 const request = thumbnails.loadVideoThumbnail(
130 "unreachable",
131 async () => file,
132 new AbortController().signal,
133 );
134 await started.promise;
135 await vi.advanceTimersByTimeAsync(15_000);
136 expect(await request).toBeNull();
137 expect(first.release).toHaveBeenCalledTimes(1);
138 expect(file.dispose).toHaveBeenCalledTimes(1);
139 expect(
140 await thumbnails.loadVideoThumbnail(
141 "reachable",
142 async () => source(),
143 new AbortController().signal,
144 ),
145 ).toBe(frame);
146 });
147
148 it("bounds the retained native images without invalidating frames still displayed", async () => {
149 for (let i = 0; i < 33; i++) {
150 await thumbnails.loadVideoThumbnail(
151 `clip:${i}`,
152 async () => source(),
153 new AbortController().signal,
154 );
155 }
156 expect(thumbnails.cachedVideoThumbnail("clip:0")).toBeNull();
157 expect(thumbnails.cachedVideoThumbnail("clip:32")).toBe(frame);
158 expect(mocks.createPlayer).toHaveBeenCalledTimes(33);
159 expect(
160 await thumbnails.loadVideoThumbnail(
161 "clip:0",
162 async () => source(),
163 new AbortController().signal,
164 ),
165 ).toBe(frame);
166 expect(mocks.createPlayer).toHaveBeenCalledTimes(34);
167 });
168});