1import { renderToStaticMarkup } from "react-dom/server";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const fixture = vi.hoisted(() => ({
5 drafts: {} as Record<string, unknown>,
6 uploads: {} as Record<string, unknown>,
7 preparations: {} as Record<string, number>,
8 preparationAtom: Symbol("preparation"),
9}));
10vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
11vi.mock("@effect/atom-react", () => ({
12 useAtomValue: (atom: unknown) =>
13 atom === "drafts"
14 ? fixture.drafts
15 : atom === "uploads"
16 ? fixture.uploads
17 : atom === fixture.preparationAtom
18 ? fixture.preparations
19 : {},
20}));
21vi.mock("./use-composer-drafts", () => ({
22 composerDraftsAtom: "drafts",
23 clearComposerDraft: vi.fn(),
24}));
25vi.mock("./composer-attachment-uploads", async () => ({
26 ...(await import("../lib/composerAttachmentUploadQueue")),
27 composerAttachmentUploadsAtom: "uploads",
28}));
29vi.mock("./question-attachments", async (importOriginal) => ({
30 ...(await importOriginal<typeof import("./question-attachments")>()),
31 questionAttachmentPreparationAtom: fixture.preparationAtom,
32}));
33vi.mock("./entities", () => ({
34 useServerConfigs: () =>
35 new Map([
36 [
37 "environment-1",
38 {
39 environment: {
40 capabilities: {
41 questionAttachments: true,
42 attachmentUploads: true,
43 fileAttachments: { maxUploadBytes: 20_000_000 },
44 },
45 },
46 },
47 ],
48 ]),
49}));
50vi.mock("./session", () => ({ readEnvironmentScope: () => true }));
51vi.mock("./threads", () => ({ threadEnvironment: {} }));
52vi.mock("./use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
53vi.mock("./use-thread-selection", () => ({
54 useThreadSelection: () => ({
55 selectedThread: { environmentId: "environment-1", id: "thread-1" },
56 }),
57}));
58vi.mock("./use-thread-detail", () => ({
59 useSelectedThreadPendingRequests: () => ({
60 approvals: [],
61 userInputs: [
62 {
63 requestId: "request-1",
64 createdAt: "2026-09-08T00:00:00Z",
65 responseCapability: "live",
66 dismissible: false,
67 questions: ["first", "second"].map((id) => ({
68 id,
69 header: id,
70 question: `Attach ${id} file`,
71 options: [],
72 allowCustomAnswer: true,
73 multiSelect: false,
74 })),
75 },
76 ],
77 }),
78}));
79
80import { ApprovalRequestId, EnvironmentId, ThreadId } from "@t3tools/contracts";
81import { questionAttachmentDraftKey } from "./question-attachments";
82import { useSelectedThreadRequests } from "./use-selected-thread-requests";
83
84const environmentId = EnvironmentId.make("environment-1");
85const key = (question: string) =>
86 questionAttachmentDraftKey(
87 environmentId,
88 ThreadId.make("thread-1"),
89 ApprovalRequestId.make("request-1"),
90 question,
91 );
92function submitButtonMarkup() {
93 function Probe() {
94 const { activePendingUserInputAnswers } = useSelectedThreadRequests();
95 return <button disabled={activePendingUserInputAnswers === null}>Submit answers</button>;
96 }
97 return renderToStaticMarkup(<Probe />);
98}
99beforeEach(() => {
100 fixture.preparations = {};
101 fixture.drafts = Object.fromEntries(
102 ["first", "second"].map((id) => [
103 key(id),
104 {
105 attachments: [
106 {
107 id,
108 type: "file",
109 name: `${id}.txt`,
110 mimeType: "text/plain",
111 sizeBytes: 4,
112 fileUri: `file:///${id}.txt`,
113 },
114 ],
115 },
116 ]),
117 );
118 fixture.uploads = { "environment-1:first": { status: "ready" } };
119});
120describe("question attachment submission readiness", () => {
121 it.each([
122 undefined,
123 { status: "uploading", progress: 0.5 },
124 { status: "failed", reason: "Offline" },
125 ])("keeps Submit disabled until all question uploads finish: %j", (state) => {
126 if (state) fixture.uploads["environment-1:second"] = state;
127 expect(submitButtonMarkup()).toContain("disabled");
128 fixture.uploads["environment-1:second"] = { status: "ready" };
129 expect(submitButtonMarkup()).not.toContain("disabled");
130 });
131 it("ignores an upload in another environment", () => {
132 fixture.uploads["environment-1:second"] = { status: "ready" };
133 fixture.uploads["environment-2:second"] = { status: "uploading", progress: 0.5 };
134 expect(submitButtonMarkup()).not.toContain("disabled");
135 });
136 it("waits for attachment preparation even when uploads are ready", () => {
137 fixture.uploads["environment-1:second"] = { status: "ready" };
138 fixture.preparations[key("first")] = 1;
139 expect(submitButtonMarkup()).toContain("disabled");
140 });
141});