1import { AsyncResult } from "effect/reactivity";
2import * as Cause from "effect/Cause";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { toastManager } from "../components/ui/toast";
6import {
7 showThreadUndoNotice,
8 undoLatestThreadAction,
9 useThreadUndoNotice,
10} from "./showThreadUndoNotice";
11import * as ThreadUndo from "./threadUndo";
12
13beforeEach(() => vi.useFakeTimers());
14afterEach(() => {
15 vi.runAllTimers();
16 vi.useRealTimers();
17 vi.restoreAllMocks();
18});
19
20function setup() {
21 const add = vi.spyOn(toastManager, "add").mockReturnValue("error-toast");
22 const undo = vi.fn(async () => AsyncResult.success(undefined));
23 const claim = ThreadUndo.begin("pin", "env/thread");
24 const options = { action: "Unpinned" as const, failureTitle: "Restore failed", undo, claim };
25 return { add, undo, claim, options };
26}
27
28function notice() {
29 const value = useThreadUndoNotice.getState().notice;
30 if (!value) throw new Error("Undo notice is missing");
31 return value;
32}
33
34describe("thread undo notice", () => {
35 it("aggregates consecutive actions without success toasts and restores the group once", async () => {
36 const { add, undo, options } = setup();
37 showThreadUndoNotice({
38 ...options,
39 action: "Settled",
40 claim: ThreadUndo.begin("settle", "env/a"),
41 });
42 showThreadUndoNotice({
43 ...options,
44 action: "Settled",
45 claim: ThreadUndo.begin("settle", "env/b"),
46 });
47 expect(notice()).toMatchObject({ action: "Settled", count: 2 });
48 expect(add).not.toHaveBeenCalled();
49 const group = notice();
50 await group.undo();
51 await group.undo();
52 expect(undo).toHaveBeenCalledTimes(2);
53 expect(undoLatestThreadAction()).toBe(false);
54 });
55
56 it("drops invalidated claims immediately and rejects a captured stale undo", async () => {
57 const { undo, options } = setup();
58 showThreadUndoNotice(options);
59 const stale = notice();
60 ThreadUndo.invalidate("pin", "env/thread");
61 expect(useThreadUndoNotice.getState().notice).toBeNull();
62 showThreadUndoNotice({ ...options, claim: ThreadUndo.begin("pin", "env/thread") });
63 await stale.undo();
64 expect(undo).not.toHaveBeenCalled();
65 await notice().undo();
66 expect(undo).toHaveBeenCalledOnce();
67 });
68
69 it("does not show a notice for a late completion after a newer action", () => {
70 const { add, options } = setup();
71 ThreadUndo.invalidate("pin", "env/thread");
72 showThreadUndoNotice(options);
73 expect(useThreadUndoNotice.getState().notice).toBeNull();
74 expect(add).not.toHaveBeenCalled();
75 });
76
77 it("keeps the group available until five seconds after the latest action", async () => {
78 const { undo, claim, options } = setup();
79 showThreadUndoNotice(options);
80 vi.advanceTimersByTime(4_000);
81 const second = ThreadUndo.begin("pin", "env/second");
82 showThreadUndoNotice({ ...options, claim: second });
83 const group = notice();
84 vi.advanceTimersByTime(4_999);
85 expect(notice().count).toBe(2);
86 vi.advanceTimersByTime(1);
87 expect(useThreadUndoNotice.getState().notice).toBeNull();
88 expect(claim.isCurrent()).toBe(false);
89 expect(second.isCurrent()).toBe(false);
90 await group.undo();
91 expect(undo).not.toHaveBeenCalled();
92 });
93
94 it("reports a failed restore and releases its claim", async () => {
95 const { add, claim, options } = setup();
96 showThreadUndoNotice({
97 ...options,
98 undo: async () => AsyncResult.failure(Cause.fail(new Error("offline"))),
99 });
100 await notice().undo();
101 expect(add).toHaveBeenLastCalledWith(
102 expect.objectContaining({ type: "error", title: "Restore failed", description: "offline" }),
103 );
104 expect(claim.isCurrent()).toBe(false);
105 });
106
107 it("reports a rejected restore promise", async () => {
108 const { add, options } = setup();
109 showThreadUndoNotice({
110 ...options,
111 undo: async () => {
112 throw new Error("disconnected");
113 },
114 });
115 await notice().undo();
116 expect(add).toHaveBeenLastCalledWith(
117 expect.objectContaining({ type: "error", description: "disconnected" }),
118 );
119 });
120
121 it("does not report interrupted restores as errors", async () => {
122 const { add, options } = setup();
123 showThreadUndoNotice({ ...options, undo: async () => AsyncResult.failure(Cause.interrupt()) });
124 await notice().undo();
125 expect(add).not.toHaveBeenCalled();
126 });
127
128 it("undoes the latest kind first, then reveals the preceding group", async () => {
129 const { options } = setup();
130 const older = vi.fn(async () => AsyncResult.success(undefined));
131 const newer = vi.fn(async () => AsyncResult.success(undefined));
132 showThreadUndoNotice({
133 ...options,
134 action: "Settled",
135 undo: older,
136 claim: ThreadUndo.begin("settle", "env/a"),
137 });
138 showThreadUndoNotice({
139 ...options,
140 action: "Snoozed",
141 undo: newer,
142 claim: ThreadUndo.begin("snooze", "env/b"),
143 });
144 expect(undoLatestThreadAction()).toBe(true);
145 expect(newer).toHaveBeenCalledOnce();
146 expect(older).not.toHaveBeenCalled();
147 expect(notice().action).toBe("Settled");
148 await notice().undo();
149 expect(older).toHaveBeenCalledOnce();
150 expect(undoLatestThreadAction()).toBe(false);
151 });
152});