1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
3import * as Cause from "effect/Cause";
4import { AsyncResult } from "effect/reactivity";
5
6import { FileSaveCoordinator } from "./fileSaveCoordinator";
7
8function deferred() {
9 let resolve!: (result: AtomCommandResult<void, never>) => void;
10 const promise = new Promise<AtomCommandResult<void, never>>((resolvePromise) => {
11 resolve = resolvePromise;
12 });
13 return { promise, resolve };
14}
15
16describe("FileSaveCoordinator", () => {
17 afterEach(() => {
18 vi.useRealTimers();
19 });
20
21 it("debounces edits and persists only the latest contents", async () => {
22 vi.useFakeTimers();
23 const persist = vi
24 .fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
25 .mockResolvedValue(AsyncResult.success(undefined));
26 const onPendingChange = vi.fn();
27 const onConfirmed = vi.fn();
28 const coordinator = new FileSaveCoordinator({
29 debounceMs: 500,
30 persist,
31 onPendingChange,
32 onConfirmed,
33 });
34
35 coordinator.change("first");
36 await vi.advanceTimersByTimeAsync(300);
37 coordinator.change("latest");
38 await vi.advanceTimersByTimeAsync(499);
39 expect(persist).not.toHaveBeenCalled();
40
41 await vi.advanceTimersByTimeAsync(1);
42 expect(persist).toHaveBeenCalledOnce();
43 expect(persist).toHaveBeenCalledWith("latest");
44 expect(onConfirmed).toHaveBeenCalledWith("latest");
45 expect(onPendingChange.mock.calls).toEqual([[true], [true], [false]]);
46 });
47
48 it("keeps pending state until an edit made during a write is also saved", async () => {
49 vi.useFakeTimers();
50 const firstWrite = deferred();
51 const persist = vi
52 .fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
53 .mockReturnValueOnce(firstWrite.promise)
54 .mockResolvedValueOnce(AsyncResult.success(undefined));
55 const onPendingChange = vi.fn();
56 const coordinator = new FileSaveCoordinator({
57 debounceMs: 500,
58 persist,
59 onPendingChange,
60 onConfirmed: vi.fn(),
61 });
62
63 coordinator.change("first");
64 await vi.advanceTimersByTimeAsync(500);
65 coordinator.change("latest");
66 await vi.advanceTimersByTimeAsync(500);
67 expect(persist).toHaveBeenCalledTimes(1);
68
69 firstWrite.resolve(AsyncResult.success(undefined));
70 await vi.runAllTimersAsync();
71 expect(persist).toHaveBeenCalledTimes(2);
72 expect(persist).toHaveBeenLastCalledWith("latest");
73 expect(onPendingChange.mock.calls.at(-1)).toEqual([false]);
74 });
75
76 it("saves an edit made inside the debounce window when the editor closes", async () => {
77 vi.useFakeTimers();
78 const persist = vi
79 .fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
80 .mockResolvedValue(AsyncResult.success(undefined));
81 const coordinator = new FileSaveCoordinator({
82 debounceMs: 500,
83 persist,
84 onPendingChange: vi.fn(),
85 onConfirmed: vi.fn(),
86 });
87
88 coordinator.change("unsaved");
89 coordinator.dispose();
90 await vi.runAllTimersAsync();
91
92 expect(persist).toHaveBeenCalledOnce();
93 expect(persist).toHaveBeenCalledWith("unsaved");
94 });
95
96 it.each([false, true])(
97 "keeps an edit pending after write permission is removed (closing=%s)",
98 async (closeEditor) => {
99 vi.useFakeTimers();
100 let canWrite = true;
101 const persist = vi.fn().mockResolvedValue(AsyncResult.success(undefined));
102 const onPendingChange = vi.fn();
103 const coordinator = new FileSaveCoordinator({
104 debounceMs: 500,
105 canPersist: () => canWrite,
106 persist,
107 onPendingChange,
108 onConfirmed: vi.fn(),
109 });
110
111 coordinator.change("unsaved");
112 canWrite = false;
113 if (closeEditor) coordinator.dispose();
114 await vi.runAllTimersAsync();
115
116 expect(persist).not.toHaveBeenCalled();
117 expect(onPendingChange).toHaveBeenLastCalledWith(true);
118 },
119 );
120
121 it("flushes an edit made while a write was in flight when the editor closes", async () => {
122 vi.useFakeTimers();
123 const inFlight = deferred();
124 const persist = vi
125 .fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
126 .mockReturnValueOnce(inFlight.promise)
127 .mockResolvedValue(AsyncResult.success(undefined));
128 const coordinator = new FileSaveCoordinator({
129 debounceMs: 500,
130 persist,
131 onPendingChange: vi.fn(),
132 onConfirmed: vi.fn(),
133 });
134
135 coordinator.change("first");
136 await vi.advanceTimersByTimeAsync(500);
137 coordinator.change("latest");
138 coordinator.dispose();
139 inFlight.resolve(AsyncResult.success(undefined));
140 await vi.runAllTimersAsync();
141
142 expect(persist).toHaveBeenCalledTimes(2);
143 expect(persist).toHaveBeenLastCalledWith("latest");
144 });
145
146 it("does not rewrite a write that lands while the editor closes", async () => {
147 vi.useFakeTimers();
148 const inFlight = deferred();
149 const persist = vi
150 .fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
151 .mockReturnValueOnce(inFlight.promise)
152 .mockResolvedValue(AsyncResult.success(undefined));
153 const coordinator = new FileSaveCoordinator({
154 debounceMs: 500,
155 persist,
156 onPendingChange: vi.fn(),
157 onConfirmed: vi.fn(),
158 });
159
160 coordinator.change("only");
161 await vi.advanceTimersByTimeAsync(500);
162 coordinator.dispose();
163 inFlight.resolve(AsyncResult.success(undefined));
164 await vi.runAllTimersAsync();
165
166 expect(persist).toHaveBeenCalledOnce();
167 });
168
169 it("retries a failed write when the editor closes", async () => {
170 vi.useFakeTimers();
171 const persist = vi
172 .fn()
173 .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("write failed"))))
174 .mockResolvedValue(AsyncResult.success(undefined));
175 const coordinator = new FileSaveCoordinator({
176 debounceMs: 500,
177 persist,
178 onPendingChange: vi.fn(),
179 onConfirmed: vi.fn(),
180 });
181
182 coordinator.change("latest");
183 await vi.advanceTimersByTimeAsync(500);
184 expect(persist).toHaveBeenCalledOnce();
185
186 coordinator.dispose();
187 await vi.runAllTimersAsync();
188
189 expect(persist).toHaveBeenCalledTimes(2);
190 expect(persist).toHaveBeenLastCalledWith("latest");
191 });
192
193 it("leaves the file pending when the latest write fails", async () => {
194 vi.useFakeTimers();
195 const onPendingChange = vi.fn();
196 const coordinator = new FileSaveCoordinator({
197 debounceMs: 500,
198 persist: vi
199 .fn()
200 .mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("write failed")))),
201 onPendingChange,
202 onConfirmed: vi.fn(),
203 });
204
205 coordinator.change("latest");
206 await vi.advanceTimersByTimeAsync(500);
207 await Promise.resolve();
208 expect(onPendingChange).toHaveBeenCalledWith(true);
209 expect(onPendingChange).not.toHaveBeenCalledWith(false);
210 });
211
212 it("ignores editor changes emitted after disposal", async () => {
213 vi.useFakeTimers();
214 const persist = vi
215 .fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
216 .mockResolvedValue(AsyncResult.success(undefined));
217 const onPendingChange = vi.fn();
218 const coordinator = new FileSaveCoordinator({
219 debounceMs: 500,
220 persist,
221 onPendingChange,
222 onConfirmed: vi.fn(),
223 });
224
225 coordinator.dispose();
226 coordinator.change("stale contents");
227 await vi.runAllTimersAsync();
228
229 expect(persist).not.toHaveBeenCalled();
230 expect(onPendingChange).not.toHaveBeenCalled();
231 });
232
233 it("does not persist confirmed contents again on disposal", async () => {
234 vi.useFakeTimers();
235 const persist = vi
236 .fn<(contents: string) => Promise<AtomCommandResult<void, never>>>()
237 .mockResolvedValue(AsyncResult.success(undefined));
238 const coordinator = new FileSaveCoordinator({
239 debounceMs: 500,
240 persist,
241 onPendingChange: vi.fn(),
242 onConfirmed: vi.fn(),
243 });
244
245 coordinator.change("temporary edit");
246 await vi.advanceTimersByTimeAsync(500);
247 coordinator.change("original contents");
248 await vi.advanceTimersByTimeAsync(500);
249 expect(persist).toHaveBeenCalledTimes(2);
250
251 coordinator.dispose();
252 await vi.runAllTimersAsync();
253
254 expect(persist).toHaveBeenCalledTimes(2);
255 });
256});