1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { toastManager } from "../components/ui/toast";
6import { DraftId, useComposerDraftStore } from "../composerDraftStore";
7import { useThreadUndoNotice } from "../hooks/showThreadUndoNotice";
8import { releaseDraftAttachments } from "./attachmentUploadQueue";
9import { discardComposerDraft } from "./discardComposerDraft";
10
11vi.mock("./attachmentUploadQueue", () => ({ releaseDraftAttachments: vi.fn() }));
12
13const environmentId = EnvironmentId.make("environment-local");
14const projectRef = scopeProjectRef(environmentId, ProjectId.make("project-1"));
15const draftId = DraftId.make("draft-1");
16const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1"));
17
18function undoNotice() {
19 const notice = useThreadUndoNotice.getState().notice;
20 if (!notice) throw new Error("Undo notice is missing");
21 return notice;
22}
23
24beforeEach(() => {
25 vi.useFakeTimers();
26 useComposerDraftStore.setState({
27 draftsByThreadKey: {},
28 draftThreadsByThreadKey: {},
29 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
30 });
31});
32afterEach(() => {
33 vi.runAllTimers();
34 vi.useRealTimers();
35 vi.clearAllMocks();
36 vi.restoreAllMocks();
37});
38
39describe("discardComposerDraft", () => {
40 it("restores a discarded new-thread draft with its project mapping", async () => {
41 const store = useComposerDraftStore.getState();
42 store.setProjectDraftThreadId(projectRef, draftId);
43 store.setPrompt(draftId, "half-written prompt");
44 const before = useComposerDraftStore.getState();
45
46 discardComposerDraft(draftId);
47 expect(useComposerDraftStore.getState().getDraftSession(draftId)).toBeNull();
48 expect(undoNotice()).toMatchObject({ action: "Discarded", count: 1 });
49
50 await undoNotice().undo();
51 const after = useComposerDraftStore.getState();
52 expect(after.getDraftSession(draftId)).toEqual(before.getDraftSession(draftId));
53 expect(after.getComposerDraft(draftId)?.prompt).toBe("half-written prompt");
54 expect(after.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual(
55 before.logicalProjectDraftThreadKeyByLogicalProjectKey,
56 );
57 vi.runAllTimers();
58 expect(releaseDraftAttachments).not.toHaveBeenCalled();
59 });
60
61 it("clears a thread draft for good and releases its uploads once undo expires", async () => {
62 useComposerDraftStore.getState().setPrompt(threadRef, "reply in progress");
63
64 discardComposerDraft(threadRef);
65 const notice = undoNotice();
66 expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe("");
67 expect(releaseDraftAttachments).not.toHaveBeenCalled();
68
69 vi.advanceTimersByTime(5_000);
70 expect(useThreadUndoNotice.getState().notice).toBeNull();
71 expect(releaseDraftAttachments).toHaveBeenCalledOnce();
72 await notice.undo();
73 expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe("");
74 });
75
76 it("keeps text typed after the discard and releases the old uploads", async () => {
77 useComposerDraftStore.getState().setPrompt(threadRef, "old reply");
78 discardComposerDraft(threadRef);
79 useComposerDraftStore.getState().setPrompt(threadRef, "new reply");
80 const addToast = vi.spyOn(toastManager, "add").mockReturnValue("error-toast");
81
82 await undoNotice().undo();
83 expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt).toBe("new reply");
84 expect(releaseDraftAttachments).toHaveBeenCalledOnce();
85 expect(addToast).toHaveBeenCalledWith(
86 expect.objectContaining({ title: "Failed to restore draft" }),
87 );
88 });
89});