Skip to content
Source
Sign inSign up

questionAttachments.test.ts

191 lines8.0 KB

1import {
2 makeThreadRequestsResolvedAnnouncer,
3 userInputDraftKey,
4} from "@t3tools/client-runtime/state/thread-requests";
5import { ApprovalRequestId, EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
6import { threadContextRecord } from "@t3tools/shared/composerContextReferences";
7import { beforeEach, expect, it, vi } from "vite-plus/test";
8import { useComposerDraftStore } from "./composerDraftStore";
9import { formatInlineContextReference } from "./lib/composerContextReferences";
10import { threadContextReference } from "./lib/composerContextRecords";
11import {
12 questionAttachmentDraftId,
13 countQuestionAttachments,
14 questionAttachmentDraftPrefix,
15 changeQuestionAttachmentPreparation,
16 clearQuestionAttachmentDraft,
17 usePendingUserInputAnswers,
18 useQuestionAttachmentPreparation,
19} from "./questionAttachments";
20​
21const release = vi.hoisted(() => vi.fn());
22vi.mock("./lib/attachmentUploadQueue", () => ({ releaseDraftAttachments: release }));
23const environmentId = EnvironmentId.make("environment-1");
24const threadId = ThreadId.make("thread-1");
25const requestId = ApprovalRequestId.make("request-1");
26beforeEach(() => {
27 useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} });
28 useQuestionAttachmentPreparation.setState({ counts: {} });
29 usePendingUserInputAnswers.setState({}, true);
30 release.mockClear();
31});
32it("keeps question files separate from the normal draft and other questions", () => {
33 const revoke = vi.spyOn(URL, "revokeObjectURL");
34 const store = useComposerDraftStore.getState();
35 const first = questionAttachmentDraftId(environmentId, threadId, requestId, "first");
36 const second = questionAttachmentDraftId(environmentId, threadId, requestId, "second");
37 store.setPrompt({ environmentId, threadId }, "Unsent prompt");
38 const image = {
39 type: "image" as const,
40 id: "image-1",
41 name: "image.png",
42 mimeType: "image/png",
43 sizeBytes: 5,
44 previewUrl: "blob:question-image",
45 file: new File(["image"], "image.png", { type: "image/png" }),
46 };
47 store.addImages(first, [image]);
48 store.addFiles(second, [
49 {
50 type: "file",
51 id: "file-1",
52 name: "spec.txt",
53 mimeType: "text/plain",
54 sizeBytes: 4,
55 file: new File(["spec"], "spec.txt", { type: "text/plain" }),
56 },
57 ]);
58 changeQuestionAttachmentPreparation(first, 1);
59 expect(store.getComposerDraft(first)?.images).toHaveLength(1);
60 expect(store.getComposerDraft(second)?.files).toHaveLength(1);
61 clearQuestionAttachmentDraft(first);
62 expect(store.getComposerDraft(first)).toBeNull();
63 expect(store.getComposerDraft(second)?.files).toHaveLength(1);
64 expect(store.getComposerDraft({ environmentId, threadId })?.prompt).toBe("Unsent prompt");
65 expect(useQuestionAttachmentPreparation.getState().counts[first]).toBeUndefined();
66 expect(release).toHaveBeenCalledWith([image]);
67 expect(revoke).toHaveBeenCalledWith(image.previewUrl);
68 revoke.mockRestore();
69});
70it("does not clear another environment whose id contains a question prefix", () => {
71 const otherEnvironment = EnvironmentId.make(
72 `${environmentId}:question-${encodeURIComponent(JSON.stringify(threadId))}-nested`,
73 );
74 const otherKey = questionAttachmentDraftId(otherEnvironment, threadId, requestId, "q");
75 const ownKey = questionAttachmentDraftId(environmentId, threadId, requestId, "q");
76 const store = useComposerDraftStore.getState();
77 store.setPrompt(otherKey, "Keep this answer");
78 store.setPrompt(ownKey, "Discard this answer");
79 const prefix = questionAttachmentDraftPrefix(environmentId, threadId);
80 for (const key of [ownKey, otherKey]) {
81 if (key.startsWith(prefix)) clearQuestionAttachmentDraft(key);
82 }
83 expect(store.getComposerDraft(ownKey)).toBeNull();
84 expect(store.getComposerDraft(otherKey)?.prompt).toBe("Keep this answer");
85});
86​
87it("counts files, images, and pending preparation across the shared question budget", () => {
88 const first = questionAttachmentDraftId(environmentId, threadId, requestId, "first");
89 const second = questionAttachmentDraftId(environmentId, threadId, requestId, "second");
90 const other = questionAttachmentDraftId(
91 EnvironmentId.make("other"),
92 threadId,
93 requestId,
94 "first",
95 );
96 const store = useComposerDraftStore.getState();
97 store.addFiles(
98 second,
99 Array.from({ length: 6 }, (_, index) => ({
100 type: "file" as const,
101 id: `file-${index}`,
102 name: `spec-${index}.txt`,
103 mimeType: "text/plain",
104 sizeBytes: 4,
105 file: new File(["spec"], `spec-${index}.txt`),
106 })),
107 );
108 store.addImages(first, [
109 {
110 type: "image",
111 id: "image",
112 name: "image.png",
113 mimeType: "image/png",
114 sizeBytes: 5,
115 previewUrl: "blob:count-test",
116 file: new File(["image"], "image.png"),
117 },
118 ]);
119 changeQuestionAttachmentPreparation(second, 1);
120 changeQuestionAttachmentPreparation(other, 8);
121 expect(countQuestionAttachments([first, second])).toBe(8);
122 expect(countQuestionAttachments([second])).toBe(7);
123 changeQuestionAttachmentPreparation(second, -1);
124 expect(countQuestionAttachments([first, second])).toBe(7);
125});
126​
127it("clears attachment drafts for requests the thread resolved, wherever they were answered", () => {
128 const store = useComposerDraftStore.getState();
129 const pending = RuntimeRequestId.make("pending");
130 const resolved = RuntimeRequestId.make("resolved");
131 const otherThread = ThreadId.make("thread-2");
132 const keep = questionAttachmentDraftId(environmentId, threadId, pending, "first");
133 const drop = questionAttachmentDraftId(environmentId, threadId, resolved, "first");
134 const elsewhere = questionAttachmentDraftId(environmentId, otherThread, resolved, "first");
135 for (const key of [keep, drop, elsewhere]) store.setPrompt(key, "Answer context");
136 changeQuestionAttachmentPreparation(drop, 1);
137 usePendingUserInputAnswers.setState({
138 [userInputDraftKey(environmentId, threadId, resolved)]: { first: { customAnswer: "Sent" } },
139 });
140​
141 // The thread's projection synchronizes with only `pending` left, e.g. after
142 // `resolved` was answered on another device while no composer showed it.
143 makeThreadRequestsResolvedAnnouncer(
144 environmentId,
145 threadId,
146 )([
147 { id: pending, status: "pending" },
148 { id: resolved, status: "resolved" },
149 ]);
150​
151 expect(store.getComposerDraft(keep)?.prompt).toBe("Answer context");
152 expect(store.getComposerDraft(drop)).toBeNull();
153 expect(store.getComposerDraft(elsewhere)?.prompt).toBe("Answer context");
154 expect(useQuestionAttachmentPreparation.getState().counts[drop]).toBeUndefined();
155 // An answered question's text was sent, so it does not come back as a draft.
156 expect(store.getComposerDraft({ environmentId, threadId })).toBeNull();
157 expect(usePendingUserInputAnswers.getState()).toEqual({});
158});
159​
160// Stop cancels an open question; a provider timeout expires it. Neither sends the answer.
161it.each(["cancelled", "expired"] as const)(
162 "returns typed answers and their thread chips to the thread draft when a question is %s",
163 (status) => {
164 const store = useComposerDraftStore.getState();
165 const request = RuntimeRequestId.make("stopped");
166 const reference = threadContextRecord(
167 { environmentId, threadId: ThreadId.make("reference-thread") },
168 "Reference work",
169 );
170 const answer = `Purple, like ${formatInlineContextReference(threadContextReference(reference))}`;
171 store.setPrompt({ environmentId, threadId }, "Earlier draft");
172 store.setThreadContexts(questionAttachmentDraftId(environmentId, threadId, request, "first"), [
173 reference,
174 ]);
175 usePendingUserInputAnswers.setState({
176 [userInputDraftKey(environmentId, threadId, request)]: {
177 first: { customAnswer: answer },
178 second: { customAnswer: " " },
179 third: { customAnswer: "Also this" },
180 },
181 });
182​
183 makeThreadRequestsResolvedAnnouncer(environmentId, threadId)([{ id: request, status }]);
184​
185 expect(store.getComposerDraft({ environmentId, threadId })).toMatchObject({
186 prompt: `Earlier draft\n\n${answer}\n\nAlso this`,
187 threadContexts: [reference],
188 });
189 expect(usePendingUserInputAnswers.getState()).toEqual({});
190 },
191);