Skip to content
Source
Sign inSign up

localAttachmentPreview.test.ts

150 lines5.5 KB

1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2​
3const mocks = vi.hoisted(() => ({
4 retain: vi.fn(),
5 share: vi.fn(),
6 exists: vi.fn(),
7}));
8​
9vi.mock("./composerAttachmentPreviewRetention", () => ({
10 retainComposerAttachmentFileForPreview: mocks.retain,
11}));
12vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share }));
13vi.mock("expo-file-system", () => ({
14 File: class {
15 constructor(readonly uri: string) {}
16 get exists(): boolean {
17 return mocks.exists(this.uri);
18 }
19 },
20 Paths: {
21 document: {
22 uri: "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/",
23 },
24 },
25}));
26​
27import { AtomRegistry, type AsyncResult } from "effect/reactivity";
28​
29import {
30 loadLocalAttachmentPreview,
31 localAttachmentPreviewAtom,
32 type LocalAttachmentPreviewState,
33 localAttachmentPreviewState,
34} from "./localAttachmentPreview";
35​
36const attachment = {
37 type: "file" as const,
38 id: "draft-video",
39 name: "clip.mov",
40 mimeType: "video/quicktime",
41 sizeBytes: 12,
42 fileUri:
43 "file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/33333333-3333-4333-8333-333333333333-clip.mov",
44};
45​
46beforeEach(() => {
47 mocks.retain.mockReset();
48 mocks.share.mockReset();
49 mocks.exists.mockReset();
50 mocks.retain.mockImplementation(() => vi.fn());
51 mocks.exists.mockReturnValue(true);
52 mocks.share.mockResolvedValue(undefined);
53});
54​
55describe("loadLocalAttachmentPreview", () => {
56 it("keeps an image MIME type when its filename has a video extension", async () => {
57 const image = {
58 ...attachment,
59 type: "image" as const,
60 name: "photo.mov",
61 mimeType: "image/png",
62 previewUri: attachment.fileUri,
63 };
64 const preview = await loadLocalAttachmentPreview(image, new AbortController().signal);
65 await preview!.share(new AbortController().signal);
66 expect(mocks.share).toHaveBeenCalledWith(
67 expect.objectContaining({
68 attachment: { name: "photo.mov", mimeType: "image/png" },
69 }),
70 );
71 preview!.dispose();
72 });
73 it("resolves the current iOS container and releases its playback lease once", async () => {
74 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
75 expect(preview?.uri).toContain("/22222222-2222-4222-8222-222222222222/Documents/");
76 expect(mocks.retain).toHaveBeenCalledWith(attachment);
77 const release = mocks.retain.mock.results[0]!.value;
78 expect(release).not.toHaveBeenCalled();
79 preview?.dispose();
80 preview?.dispose();
81 expect(release).toHaveBeenCalledTimes(1);
82 });
83​
84 it.each([undefined, "share-button"])(
85 "keeps a separate share lease after playback closes (source: %s)",
86 async (sourceIdentifier) => {
87 const shared = Promise.withResolvers<void>();
88 mocks.share.mockReturnValue(shared.promise);
89 const preview = await loadLocalAttachmentPreview(attachment, new AbortController().signal);
90 const share = preview!.share(new AbortController().signal, sourceIdentifier);
91 expect(mocks.retain).toHaveBeenCalledTimes(2);
92 const releasePlayback = mocks.retain.mock.results[0]!.value;
93 const releaseShare = mocks.retain.mock.results[1]!.value;
94 preview!.dispose();
95 expect(releasePlayback).toHaveBeenCalledTimes(1);
96 expect(releaseShare).not.toHaveBeenCalled();
97 shared.resolve();
98 await share;
99 expect(releaseShare).toHaveBeenCalledTimes(1);
100 },
101 );
102});
103​
104describe("localAttachmentPreviewAtom", () => {
105 const settled = (
106 registry: AtomRegistry.AtomRegistry,
107 target: typeof attachment,
108 ): { state: Promise<LocalAttachmentPreviewState>; unsubscribe: () => void } => {
109 const atom = localAttachmentPreviewAtom(target);
110 const { promise, resolve } = Promise.withResolvers<LocalAttachmentPreviewState>();
111 const onChange = (result: AsyncResult.AsyncResult<unknown, unknown>) => {
112 if (result._tag !== "Initial") resolve(localAttachmentPreviewState(registry.get(atom)));
113 };
114 const unsubscribe = registry.subscribe(atom, onChange, { immediate: true });
115 return { state: promise, unsubscribe };
116 };
117​
118 it("shares one lease per file and releases it when the last reader leaves", async () => {
119 const released = Promise.withResolvers<void>();
120 const release = vi.fn(() => released.resolve());
121 mocks.retain.mockReturnValueOnce(release);
122 const registry = AtomRegistry.make();
123 const first = settled(registry, attachment);
124 // A metadata update on the same draft file reads the same lease.
125 const renamed = settled(registry, { ...attachment, name: "renamed.mov" });
126 expect((await first.state).uri).toContain("/Documents/");
127 expect((await renamed.state).uri).toBe((await first.state).uri);
128 expect(mocks.retain).toHaveBeenCalledTimes(1);
129 first.unsubscribe();
130 expect(release).not.toHaveBeenCalled();
131 renamed.unsubscribe();
132 // The scope finalizer runs on the Effect scheduler; await the release itself.
133 await released.promise;
134 expect(release).toHaveBeenCalledTimes(1);
135 registry.dispose();
136 });
137​
138 it("reports a missing file as an error", async () => {
139 mocks.exists.mockReturnValue(false);
140 const registry = AtomRegistry.make();
141 const reader = settled(registry, { ...attachment, id: "missing" });
142 const state = await reader.state;
143 expect(state.uri).toBeNull();
144 expect(state.error?.message).toBe(
145 "This attachment is no longer available. Attach the file again.",
146 );
147 reader.unsubscribe();
148 registry.dispose();
149 });
150});