Skip to content
Source
Sign inSign up
Open History to see the last commit

ThreadMarkdownImage.test.tsx

400 lines15.4 KB

1import type { ReactNode } from "react";
2import { act } from "react";
3import { createRoot } from "react-dom/client";
4import { expect, it, vi } from "vite-plus/test";
5​
6type HandlerProps = Record<string, ((event: unknown) => void) | undefined>;
7type GestureHandlers = Record<string, (event: unknown, manager?: unknown) => void>;
8type Transform = [{ translateX: number }, { translateY: number }, { scale: number }];
9​
10const harness = vi.hoisted(() => ({
11 failImage: () => {},
12 loadImage: (_event: { source: { width: number; height: number } }) => {},
13 Passthrough: ({ children }: { children?: unknown }) => children,
14 pressable: {} as HandlerProps,
15 gestures: {} as Record<string, GestureHandlers>,
16 contentStyle: () => ({}) as { transform?: Transform },
17}));
18​
19vi.mock("react-native", () => {
20 const View = ({ children, style }: { children?: ReactNode; style?: unknown }) => (
21 <div data-style={JSON.stringify(style)}>{children}</div>
22 );
23 return {
24 Platform: { OS: "ios" },
25 Pressable: (props: HandlerProps & { children?: ReactNode; style?: unknown }) => {
26 harness.pressable = props;
27 return <View style={props.style}>{props.children}</View>;
28 },
29 StyleSheet: { absoluteFill: {} },
30 View,
31 };
32});
33vi.mock("react-native-gesture-handler", () => {
34 // Records each gesture's callbacks so a test can drive the worklets with native-shaped events.
35 const builder = (kind: string) => () => {
36 const handlers: GestureHandlers = {};
37 harness.gestures[kind] = handlers;
38 const gesture: object = new Proxy(
39 {},
40 {
41 get: (_target, name) => (callback: GestureHandlers[string]) => {
42 if (typeof name === "string" && name.startsWith("on")) handlers[name] = callback;
43 return gesture;
44 },
45 },
46 );
47 return gesture;
48 };
49 return {
50 Gesture: { Pinch: builder("pinch"), Pan: builder("pan"), Simultaneous: () => ({}) },
51 GestureDetector: ({ children }: { children?: ReactNode }) => children,
52 };
53});
54vi.mock("react-native-reanimated", async () => {
55 const { useState } = await import("react");
56 return {
57 default: { View: ({ children }: { children?: ReactNode }) => <div>{children}</div> },
58 runOnJS: (callback: () => void) => callback,
59 // Reads the UI-thread style on demand, as Reanimated would after a gesture frame.
60 useAnimatedStyle: (updater: () => { transform?: Transform }) => {
61 harness.contentStyle = updater;
62 return {};
63 },
64 useSharedValue: <T,>(initial: T) =>
65 useState(() => {
66 let value = initial;
67 return { get: () => value, set: (next: T) => void (value = next) };
68 })[0],
69 };
70});
71vi.mock("expo-image", () => ({
72 Image: (props: {
73 onError: () => void;
74 onLoad: (event: { source: { width: number; height: number } }) => void;
75 }) => {
76 harness.failImage = props.onError;
77 harness.loadImage = props.onLoad;
78 return <img alt="" />;
79 },
80}));
81vi.mock("expo/fetch", () => ({ fetch: vi.fn() }));
82vi.mock("react-native-svg", () => ({ parse: vi.fn(), SvgAst: () => null }));
83vi.mock("@t3tools/client-runtime/state/assets", () => ({ MISSING_FILE_ACCESS_MESSAGE: "" }));
84vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null }));
85vi.mock("../../components/ui/FilePreviewGallery", () => ({
86 useFilePreviewGallery: () => (source: unknown) => source,
87}));
88vi.mock("../../components/ui/AppText", () => ({
89 AppText: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
90}));
91vi.mock("../../components/MediaActionsMenu", () => ({ MediaActionsMenu: harness.Passthrough }));
92vi.mock("../../components/ui/NativePresentation", () => ({
93 PresentationSource: harness.Passthrough,
94}));
95vi.mock("../../state/mediaActions", () => ({ useMediaActions: () => ({ actions: [] }) }));
96vi.mock("../../state/assets", () => ({ useAssetUrlState: vi.fn() }));
97​
98import { RegistryContext } from "@effect/atom-react";
99import { AtomRegistry } from "effect/reactivity";
100import { fetch } from "expo/fetch";
101import { parse } from "react-native-svg";
102​
103import { MarkdownImageAvailableWidthContext, ThreadMarkdownImageView } from "./ThreadMarkdownImage";
104​
105it("keeps the frame size when the same image reports a different decoded width", async () => {
106 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
107 const container = document.createElement("div");
108 const root = createRoot(container);
109 const frameSize = () =>
110 [...container.querySelectorAll<HTMLElement>("div[data-style]")]
111 .map((element) => JSON.parse(element.dataset.style ?? "null") as Record<string, unknown>)
112 .find((style) => typeof style?.width === "number");
113 try {
114 await act(async () =>
115 root.render(
116 <MarkdownImageAvailableWidthContext value={345}>
117 <ThreadMarkdownImageView
118 uri="https://example.test/hero.png"
119 sourceKey="workspace:hero.png"
120 unavailable={false}
121 alt={null}
122 knownSize={{ width: 1000, height: 585 }}
123 onPressPreview={() => {}}
124 />
125 </MarkdownImageAvailableWidthContext>,
126 ),
127 );
128 // Android reports the bitmap downsampled for the current frame, so the same file
129 // alternates between widths (969 and 970 of 1000) as the frame changes size.
130 await act(async () => harness.loadImage({ source: { width: 970, height: 567 } }));
131 const first = frameSize();
132 expect(first).toBeDefined();
133​
134 await act(async () => harness.loadImage({ source: { width: 969, height: 567 } }));
135 expect(frameSize()).toEqual(first);
136 } finally {
137 await act(async () => root.unmount());
138 }
139});
140​
141it("retries a failed image once its URI comes back through null", async () => {
142 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
143 const container = document.createElement("div");
144 const root = createRoot(container);
145 const view = (uri: string | null) => (
146 <ThreadMarkdownImageView
147 uri={uri}
148 sourceKey="attachment:a"
149 unavailable={false}
150 alt={null}
151 onPressPreview={() => {}}
152 />
153 );
154 try {
155 await act(async () => root.render(view("https://example.test/a.png")));
156 await act(async () => harness.failImage());
157 expect(container.textContent).toContain("Image unavailable");
158​
159 await act(async () => root.render(view(null)));
160 await act(async () => root.render(view("https://example.test/a.png")));
161 expect(container.textContent).not.toContain("Image unavailable");
162 expect(container.querySelector("img")).not.toBeNull();
163 } finally {
164 await act(async () => root.unmount());
165 }
166});
167​
168it("fetches a failed SVG again on the next mount but keeps a parsed one", async () => {
169 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
170 // Microtask scheduling lets act() flush an unmounted atom's removal before the next render.
171 const registry = AtomRegistry.make({
172 scheduleTask: (task) => {
173 queueMicrotask(task);
174 return () => {};
175 },
176 });
177 const fetchSvg = vi.mocked(fetch);
178 vi.mocked(parse).mockReturnValue({ props: {} } as ReturnType<typeof parse>);
179 const container = document.createElement("div");
180 const root = createRoot(container);
181 const uri = "https://example.test/a.svg";
182 const view = (mounted: boolean) => (
183 <RegistryContext.Provider value={registry}>
184 {mounted ? (
185 <ThreadMarkdownImageView
186 uri={uri}
187 sourceKey="attachment:svg"
188 unavailable={false}
189 alt={null}
190 format="svg"
191 onPressPreview={() => {}}
192 />
193 ) : null}
194 </RegistryContext.Provider>
195 );
196 try {
197 fetchSvg.mockRejectedValueOnce(new Error("offline"));
198 await act(async () => root.render(view(true)));
199 await vi.waitFor(() => expect(container.textContent).toContain("Image unavailable"));
200​
201 await act(async () => root.render(view(false)));
202 fetchSvg.mockResolvedValue({ ok: true, status: 200, text: async () => "<svg />" } as never);
203 await act(async () => root.render(view(true)));
204 await vi.waitFor(() => expect(container.textContent).not.toContain("Image unavailable"));
205 expect(fetchSvg).toHaveBeenCalledTimes(2);
206​
207 await act(async () => root.render(view(false)));
208 await act(async () => root.render(view(true)));
209 expect(container.textContent).not.toContain("Image unavailable");
210 expect(fetchSvg).toHaveBeenCalledTimes(2);
211 } finally {
212 await act(async () => root.unmount());
213 registry.dispose();
214 }
215});
216​
217it("does not parse an obsolete SVG response after its last reader unmounts", async () => {
218 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
219 const registry = AtomRegistry.make({
220 scheduleTask: (task) => {
221 queueMicrotask(task);
222 return () => {};
223 },
224 });
225 const root = createRoot(document.createElement("div"));
226 const fetchSvg = vi.mocked(fetch);
227 const parseSvg = vi.mocked(parse);
228 fetchSvg.mockReset();
229 parseSvg.mockClear();
230 const body = Promise.withResolvers<string>();
231 fetchSvg.mockResolvedValue({ ok: true, status: 200, text: () => body.promise } as never);
232 try {
233 await act(async () =>
234 root.render(
235 <RegistryContext.Provider value={registry}>
236 <ThreadMarkdownImageView
237 uri="https://example.test/obsolete.svg"
238 sourceKey="attachment:obsolete"
239 unavailable={false}
240 alt={null}
241 format="svg"
242 onPressPreview={() => {}}
243 />
244 </RegistryContext.Provider>,
245 ),
246 );
247 expect(fetchSvg).toHaveBeenCalledOnce();
248 const signal = fetchSvg.mock.calls[0]![1]!.signal!;
249 await act(async () => root.render(null));
250 expect(signal.aborted).toBe(true);
251 await act(async () => body.resolve("<svg />"));
252 expect(parseSvg).not.toHaveBeenCalled();
253 } finally {
254 await act(async () => root.unmount());
255 registry.dispose();
256 }
257});
258​
259const zoom = () => {
260 const [{ translateX }, { translateY }, { scale }] = harness.contentStyle().transform!;
261 // Clamping at fit can yield -0, which is the same position.
262 return { scale, x: translateX + 0, y: translateY + 0 };
263};
264const pinch = (phase: "onStart" | "onUpdate", focalX: number, focalY: number, scale: number) =>
265 act(async () => harness.gestures.pinch![phase]!({ focalX, focalY, scale, numberOfPointers: 2 }));
266const endPinch = () => act(async () => harness.gestures.pinch!.onFinalize!({}));
267const layout = (width: number, height: number) =>
268 act(async () => harness.pressable.onLayout?.({ nativeEvent: { layout: { width, height } } }));
269const markdownImage = (sourceKey: string, onPressPreview: () => void = () => {}) => (
270 <MarkdownImageAvailableWidthContext value={345}>
271 <ThreadMarkdownImageView
272 uri={`https://example.test/${sourceKey}.png`}
273 sourceKey={sourceKey}
274 unavailable={false}
275 alt={null}
276 knownSize={{ width: 1000, height: 585 }}
277 onPressPreview={onPressPreview}
278 />
279 </MarkdownImageAvailableWidthContext>
280);
281​
282it("does not open the preview when a touch on the inline image pinches or pans it", async () => {
283 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
284 const root = createRoot(document.createElement("div"));
285 const onPressPreview = vi.fn();
286 const touch = (fingers: number) =>
287 act(async () =>
288 harness.pressable.onTouchStart?.({
289 nativeEvent: { touches: Array.from({ length: fingers }, () => ({})) },
290 }),
291 );
292 const release = () =>
293 act(async () => harness.pressable.onPress?.({ nativeEvent: { identifier: 1 } }));
294 try {
295 await act(async () => root.render(markdownImage("hero", onPressPreview)));
296 await touch(1);
297 await touch(2);
298 await release();
299 expect(onPressPreview).not.toHaveBeenCalled();
300​
301 await touch(1);
302 await act(async () => harness.gestures.pan!.onStart!({ translationX: 5, translationY: 0 }));
303 await release();
304 expect(onPressPreview).not.toHaveBeenCalled();
305​
306 await touch(1);
307 await release();
308 expect(onPressPreview).toHaveBeenCalledOnce();
309​
310 // VoiceOver activation has no touch, so an earlier pinch must not swallow it.
311 await touch(2);
312 await act(async () => harness.pressable.onPress?.({ nativeEvent: {} }));
313 expect(onPressPreview).toHaveBeenCalledTimes(2);
314 } finally {
315 await act(async () => root.unmount());
316 }
317});
318​
319it("zooms the inline image around the pinch and keeps it until the source or frame changes", async () => {
320 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
321 const root = createRoot(document.createElement("div"));
322 const touchPan = () => {
323 const manager = { fail: vi.fn() };
324 harness.gestures.pan!.onTouchesDown!({}, manager);
325 return manager;
326 };
327 try {
328 await act(async () => root.render(markdownImage("a")));
329 // The frame center is (172.5, 101); the pinch is off center.
330 await layout(345, 202);
331 await pinch("onStart", 300, 150, 1);
332 await pinch("onUpdate", 300, 150, 2);
333 expect(zoom()).toEqual({ scale: 2, x: -127.5, y: -49 });
334 await pinch("onUpdate", 280, 140, 2);
335 expect(zoom()).toEqual({ scale: 2, x: -147.5, y: -59 });
336 await act(async () =>
337 harness.gestures.pinch!.onUpdate!({ focalX: 90, focalY: 90, scale: 2, numberOfPointers: 1 }),
338 );
339 expect(zoom()).toEqual({ scale: 2, x: -147.5, y: -59 });
340 await endPinch();
341​
342 await act(async () => harness.loadImage({ source: { width: 1000, height: 585 } }));
343 await act(async () => root.render(markdownImage("a")));
344 await layout(345, 202);
345 expect(zoom()).toEqual({ scale: 2, x: -147.5, y: -59 });
346​
347 await pinch("onStart", 100, 50, 1);
348 await pinch("onUpdate", 100, 50, 1.5);
349 expect(zoom()).toEqual({ scale: 3, x: -185, y: -63 });
350 await pinch("onUpdate", 100, 50, 10);
351 expect(zoom().scale).toBe(6);
352 await endPinch();
353​
354 const zoomedPan = touchPan();
355 await act(async () => harness.gestures.pan!.onStart!({ translationX: 5, translationY: 0 }));
356 await act(async () =>
357 harness.gestures.pan!.onUpdate!({ translationX: 5000, translationY: -5000 }),
358 );
359 expect(zoomedPan.fail).not.toHaveBeenCalled();
360 expect(zoom()).toEqual({ scale: 6, x: 862.5, y: -505 });
361​
362 await pinch("onStart", 172, 100, 1);
363 await pinch("onUpdate", 172, 100, 0.01);
364 await endPinch();
365 expect(zoom()).toEqual({ scale: 1, x: 0, y: 0 });
366 expect(touchPan().fail).toHaveBeenCalledOnce();
367​
368 await pinch("onStart", 300, 150, 1);
369 await pinch("onUpdate", 300, 150, 2);
370 await endPinch();
371 await layout(300, 175);
372 expect(zoom()).toEqual({ scale: 1, x: 0, y: 0 });
373​
374 await pinch("onStart", 300, 150, 1);
375 await pinch("onUpdate", 300, 150, 2);
376 await endPinch();
377 await act(async () => root.render(markdownImage("b")));
378 expect(zoom()).toEqual({ scale: 1, x: 0, y: 0 });
379​
380 for (const panFirst of [true, false]) {
381 await act(async () => root.render(markdownImage(`handoff-${panFirst}`)));
382 await layout(300, 200);
383 await pinch("onStart", 150, 100, 1);
384 await pinch("onUpdate", 150, 100, 2);
385 await endPinch();
386 await act(async () => harness.gestures.pan!.onStart!({ translationX: 0, translationY: 0 }));
387 await pinch("onStart", 150, 100, 1);
388 const panUpdate = () =>
389 act(async () => harness.gestures.pan!.onUpdate!({ translationX: 10, translationY: 0 }));
390 if (panFirst) await panUpdate();
391 await pinch("onUpdate", 150, 100, 1.5);
392 if (!panFirst) await panUpdate();
393 await endPinch();
394 await act(async () => harness.gestures.pan!.onUpdate!({ translationX: 20, translationY: 0 }));
395 expect(zoom()).toEqual({ scale: 3, x: 10, y: 0 });
396 }
397 } finally {
398 await act(async () => root.unmount());
399 }
400});