1import { Cause } from "effect";
2import { AsyncResult } from "effect/reactivity";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { toastManager } from "../components/ui/toast";
6import { announce, cancelAnnouncement } from "../lib/announce";
7import {
8 releaseThreadUndoShortcut,
9 showThreadUndoNotice,
10 threadUndoClaimsShortcut,
11 useThreadUndoNotice,
12} from "./showThreadUndoNotice";
13import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo";
14
15vi.mock("../lib/announce", () => ({ announce: vi.fn(), cancelAnnouncement: vi.fn() }));
16
17beforeEach(() => vi.useFakeTimers());
18afterEach(() => {
19 vi.runAllTimers();
20 vi.useRealTimers();
21 vi.restoreAllMocks();
22});
23
24function setup() {
25 const add = vi.spyOn(toastManager, "add").mockReturnValue("error-toast");
26 const undo = vi.fn(async () => AsyncResult.success(undefined));
27 const claim = ThreadUndo.begin("pin", "env/thread");
28 const options = { action: "Unpinned" as const, failureTitle: "Restore failed", undo, claim };
29 return { add, undo, claim, options };
30}
31
32function notice() {
33 const value = useThreadUndoNotice.getState().notice;
34 if (!value) throw new Error("Undo notice is missing");
35 return value;
36}
37
38describe("thread undo notice", () => {
39 it("drops invalidated claims immediately and rejects a captured stale undo", async () => {
40 const { undo, options } = setup();
41 showThreadUndoNotice(options);
42 const stale = notice();
43 ThreadUndo.invalidate("pin", "env/thread");
44 expect(useThreadUndoNotice.getState().notice).toBeNull();
45 showThreadUndoNotice({ ...options, claim: ThreadUndo.begin("pin", "env/thread") });
46 await stale.undo();
47 expect(undo).not.toHaveBeenCalled();
48 await notice().undo();
49 expect(undo).toHaveBeenCalledOnce();
50 });
51
52 it("claims mod+z from text fields until the user types, then again for a new notice", () => {
53 const { options } = setup();
54 expect(threadUndoClaimsShortcut()).toBe(false);
55 showThreadUndoNotice(options);
56 expect(threadUndoClaimsShortcut()).toBe(true);
57 releaseThreadUndoShortcut();
58 expect(threadUndoClaimsShortcut()).toBe(false);
59 showThreadUndoNotice({ ...options, claim: ThreadUndo.begin("pin", "env/next") });
60 expect(threadUndoClaimsShortcut()).toBe(true);
61 });
62
63 it("stops claiming mod+z once nothing is left to undo", async () => {
64 const { options } = setup();
65 showThreadUndoNotice(options);
66 await notice().undo();
67 expect(threadUndoClaimsShortcut()).toBe(false);
68 showThreadUndoNotice({ ...options, claim: ThreadUndo.begin("pin", "env/expiring") });
69 vi.advanceTimersByTime(5_000);
70 expect(threadUndoClaimsShortcut()).toBe(false);
71 });
72
73 it("announces each new summary once, and cancels it when the notice clears", async () => {
74 const { options } = setup();
75 vi.mocked(announce).mockClear();
76 vi.mocked(cancelAnnouncement).mockClear();
77 showThreadUndoNotice(options);
78 expect(announce).toHaveBeenCalledExactlyOnceWith(
79 expect.stringMatching(/^Unpinned 1 thread, /),
80 "thread-undo-notice",
81 );
82 ThreadUndo.invalidate("pin", "env/other");
83 expect(announce).toHaveBeenCalledOnce();
84 showThreadUndoNotice({ ...options, claim: ThreadUndo.begin("pin", "env/second") });
85 expect(announce).toHaveBeenLastCalledWith(
86 expect.stringMatching(/^Unpinned 2 threads, /),
87 "thread-undo-notice",
88 );
89 await notice().undo();
90 expect(cancelAnnouncement).toHaveBeenCalledWith("thread-undo-notice");
91 });
92
93 it("announces that the undo happened, naming the undone action", async () => {
94 const { options } = setup();
95 showThreadUndoNotice(options);
96 vi.mocked(announce).mockClear();
97 await notice().undo();
98 expect(announce).toHaveBeenCalledExactlyOnceWith("Undid unpin of 1 thread");
99 });
100
101 it("does not announce an undo that failed", async () => {
102 const { options, add } = setup();
103 showThreadUndoNotice({
104 ...options,
105 undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))),
106 });
107 vi.mocked(announce).mockClear();
108 await notice().undo();
109 expect(add).toHaveBeenCalledOnce();
110 expect(announce).not.toHaveBeenCalled();
111 });
112});