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

useComposerAttachmentIntake.browser.test.tsx

125 lines4.6 KB

1import { useEffect } from "react";
2import { ThreadId } from "@t3tools/contracts";
3import { expect, it, vi } from "vite-plus/test";
4​
5import { act, render } from "../../../test/render";
6import {
7 type ComposerImageAttachment,
8 DraftId,
9 useComposerDraftStore,
10} from "../../composerDraftStore";
11import { getPreparingComposerImages } from "./preparingComposerImages";
12import { useComposerAttachmentIntake } from "./useComposerAttachmentIntake";
13​
14const compression = vi.hoisted(() => ({ prepare: vi.fn() }));
15vi.mock("../../lib/imageCompression", async (importOriginal) => ({
16 ...(await importOriginal<typeof import("../../lib/imageCompression")>()),
17 prepareImageForAttachment: compression.prepare,
18}));
19​
20type IntakeInput = Parameters<typeof useComposerAttachmentIntake>[0];
21type Intake = ReturnType<typeof useComposerAttachmentIntake>;
22​
23function Harness({ input, ready }: { input: IntakeInput; ready: (value: Intake) => void }) {
24 const intake = useComposerAttachmentIntake(input);
25 useEffect(() => ready(intake));
26 return null;
28​
29function deferred<T>() {
30 let resolve!: (value: T) => void;
31 const promise = new Promise<T>((fulfill) => {
32 resolve = fulfill;
33 });
34 return { promise, resolve };
36​
37// A clipboard image arrives as a new File named image.png on every paste.
38const pastedImage = (fill = 0) =>
39 new File([new Uint8Array(100).fill(fill)], "image.png", { type: "image/png" });
40// Compression shrinks an image, so the draft's copy no longer matches the pasted bytes; images
41// with different pixels compress to different sizes.
42const compress = async (source: File) => {
43 const [firstByte = 0] = new Uint8Array(await source.arrayBuffer());
44 return {
45 ok: true as const,
46 file: new File([new Uint8Array(60 + firstByte)], "image.png", { type: "image/png" }),
47 recompressed: true as const,
48 };
49};
50const visiblePreparing = (targetKey: string) =>
51 getPreparingComposerImages(targetKey).filter((image) => !image.hidden);
52​
53it("prepares a repeated paste without a tile, and still keeps a different image", async () => {
54 const target = DraftId.make("intake-repeat-paste");
55 const store = useComposerDraftStore.getState();
56 using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) };
57 const firstCompression = deferred<void>();
58 compression.prepare
59 .mockReset()
60 .mockImplementationOnce(async (file: File) => {
61 await firstCompression.promise;
62 return compress(file);
63 })
64 .mockImplementation(compress);
65 const composerImagesRef = { current: [] as ComposerImageAttachment[] };
66 const addImages = (images: ComposerImageAttachment[]) => {
67 const ids = store.addImages(target, images);
68 composerImagesRef.current =
69 useComposerDraftStore.getState().getComposerDraft(target)?.images ?? [];
70 return ids;
71 };
72 const input = {
73 activeThreadId: ThreadId.make("thread"),
74 activePendingProgress: null,
75 activePendingIsResponding: false,
76 pendingUserInputs: [],
77 supportsQuestionAttachments: false,
78 isLiteralPendingAnswer: false,
79 projectSelectionRequired: false,
80 questionAttachmentTarget: null,
81 attachmentTargetKey: target,
82 fileStagingLimit: null,
83 promptRef: { current: "" },
84 composerImagesRef,
85 composerFilesRef: { current: [] },
86 composerEditorRef: { current: null },
87 isRevertingCheckpointRef: { current: false },
88 setThreadError: () => {},
89 countReservedAttachments: () =>
90 composerImagesRef.current.length + getPreparingComposerImages(target).length,
91 addComposerImage: (image) => addImages([image]),
92 addComposerImagesToDraft: addImages,
93 addComposerFilesToDraft: () => [],
94 removeComposerImageFromDraft: () => {},
95 insertAttachmentReferences: () => false,
96 } satisfies IntakeInput;
97 let intake!: Intake;
98 await using _screen = await render(
99 <Harness
100 input={input}
101 ready={(value) => {
102 intake = value;
103 }}
104 />,
105 );
106​
107 const first = intake.addComposerAttachments([pastedImage()]);
108 const whileCompressing = intake.addComposerAttachments([pastedImage()]);
109 expect(visiblePreparing(target)).toHaveLength(1);
110 await act(async () => {
111 firstCompression.resolve();
112 await Promise.all([first, whileCompressing]);
113 });
114​
115 const afterCompression = intake.addComposerAttachments([pastedImage()]);
116 expect(visiblePreparing(target)).toHaveLength(0);
117 await act(() => afterCompression);
118 expect(composerImagesRef.current).toHaveLength(1);
119​
120 // Same name, type and size, different pixels: it compresses differently and is kept.
121 const different = intake.addComposerAttachments([pastedImage(7)]);
122 expect(visiblePreparing(target)).toHaveLength(0);
123 await act(() => different);
124 expect(composerImagesRef.current).toHaveLength(2);
125});