1import { useMutate } from "@clo/react-mutation";
2import { assert, assertEquals } from "@std/assert";
3import { act, render, screen } from "@testing-library/react";
4import { userEvent } from "@testing-library/user-event";
5import { test, vi } from "vitest";
6import { createTestMutationClient, IterableStream } from "./share.ts";
7
8test("snapshot should skip no-op mutation", async () => {
9 vi.useFakeTimers({ shouldAdvanceTime: true });
10 const user = userEvent.setup({ delay: null });
11
12 const { client, successMessages, errorMessages } = createTestMutationClient();
13 const s = new IterableStream<string>();
14
15 let state = { value: "initial" };
16 let failed = false;
17 const mutUpdate = client.define({
18 id: "test-1",
19 mutate: async (newValue: string) => {
20 failed = true;
21 },
22 optimistic: ({ args: [newValue] }) => {
23 state.value = newValue;
24 },
25 snapshot: () => state.value,
26 describe: "Update value",
27 describeResult: "Updated value",
28 });
29
30 let renders: Array<{ status: string; isOptimisticData: boolean }> = [];
31 function TestComponent() {
32 const { run, status, isOptimisticData } = useMutate(mutUpdate);
33 renders.push({ status, isOptimisticData });
34 return (
35 <button data-testid="btn" onClick={() => run("initial")}>
36 update
37 </button>
38 );
39 }
40
41 render(<TestComponent />);
42 assertEquals(renders, [{ status: "idle", isOptimisticData: false }]);
43 renders = [];
44
45 // Click to run mutation with same value (no-op)
46 await act(() => user.click(screen.getByTestId("btn")));
47 vi.runAllTimers();
48
49 // Should skip the mutation and return to idle without calling API
50 assert(!failed);
51 assertEquals(state.value, "initial");
52 assertEquals(renders, []); // nothing changed
53 assertEquals(successMessages, []); // nothing happened
54 assertEquals(errorMessages, []);
55});
56
57test("snapshot should allow mutation when value changes", async () => {
58 vi.useFakeTimers({ shouldAdvanceTime: true });
59 const user = userEvent.setup({ delay: null });
60
61 const { client, successMessages, errorMessages } = createTestMutationClient();
62 const s = new IterableStream<string>();
63
64 let state = { value: "initial" };
65
66 const mutUpdate = client.define({
67 id: "test-2",
68 mutate: async (newValue: string) => {
69 return (await s.next()).value;
70 },
71 optimistic: ({ args: [newValue] }) => {
72 state.value = newValue;
73 },
74 snapshot: () => state.value,
75 describe: "Update value",
76 describeResult: "Updated value",
77 });
78
79 let renders: Array<{ status: string; isOptimisticData: boolean }> = [];
80 function TestComponent() {
81 const { run, status, isOptimisticData } = useMutate(mutUpdate);
82 renders.push({ status, isOptimisticData });
83 return (
84 <button data-testid="btn" onClick={() => run("changed")}>
85 update
86 </button>
87 );
88 }
89
90 render(<TestComponent />);
91 renders = [];
92
93 // Click to run mutation with different value
94 await act(() => user.click(screen.getByTestId("btn")));
95 assertEquals(state.value, "changed"); // Optimistic applied
96 assertEquals(renders, [{ status: "mutating", isOptimisticData: true }]);
97 renders = [];
98
99 // Complete the mutation
100 await act(async () => {
101 s.push("ok");
102 vi.advanceTimersByTime(100);
103 });
104
105 assertEquals(renders, [{ status: "success", isOptimisticData: false }]);
106 assertEquals(successMessages, ["Updated value"]);
107 assertEquals(errorMessages, []);
108});
109
110test("debounced snapshot should skip when final state equals initial", async () => {
111 vi.useFakeTimers({ shouldAdvanceTime: true });
112 const user = userEvent.setup({ delay: null });
113
114 const { client, successMessages, errorMessages } = createTestMutationClient();
115 const s = new IterableStream<string>();
116
117 let state = { value: "initial" };
118
119 const mutations: string[] = [];
120 const mutUpdate = client.define({
121 id: "test-3",
122 mutate: async (newValue: string) => {
123 mutations.push(newValue);
124 },
125 optimistic: ({ args: [newValue] }) => {
126 state.value = newValue;
127 },
128 snapshot: () => state.value,
129 debounceMs: 500,
130 refetchOnSuccess: false,
131 describe: "Update value",
132 describeResult: "Updated value",
133 });
134
135 let renders: Array<{ status: string }> = [];
136 function TestComponent() {
137 const { run, status } = useMutate(mutUpdate);
138 renders.push({ status });
139 return (
140 <>
141 <button data-testid="a" onClick={() => run("changed")}>A</button>
142 <button data-testid="b" onClick={() => run("initial")}>B</button>
143 </>
144 );
145 }
146
147 render(<TestComponent />);
148 assertEquals(renders, [{ status: "idle" }]);
149 renders = [];
150
151 // First call - change value
152 await act(() => user.click(screen.getByTestId("a")));
153 assertEquals(state.value, "changed");
154 assertEquals(renders, []); // Debounced mutations don't show mutating, no update
155 assertEquals(mutations, []);
156 renders = [];
157
158 // Second call - revert to initial (no-op overall)
159 await act(() => user.click(screen.getByTestId("b")));
160 assertEquals(state.value, "initial"); // Should be rolled back to initial
161 assertEquals(renders, []); // Still idle
162 assertEquals(successMessages, []);
163 assertEquals(errorMessages, []);
164 assertEquals(mutations, []);
165
166 await act(() => vi.runAllTimers());
167 assertEquals(successMessages, []);
168 assertEquals(errorMessages, []);
169 assertEquals(mutations, []);
170});
171
172test("debounced snapshot should mutate when final differs from initial", async () => {
173 vi.useFakeTimers({ shouldAdvanceTime: true });
174 const user = userEvent.setup({ delay: null });
175
176 const { client, successMessages, errorMessages } = createTestMutationClient();
177 const s = new IterableStream<string>();
178
179 let state = { value: "initial" };
180
181 const mutUpdate = client.define({
182 id: "test-4",
183 mutate: async (newValue: string) => {
184 return (await s.next()).value;
185 },
186 optimistic: ({ args: [newValue] }) => {
187 state.value = newValue;
188 },
189 snapshot: () => state.value,
190 debounceMs: 500,
191 refetchOnSuccess: false,
192 describe: "Update value",
193 describeResult: "Updated value",
194 });
195
196 let renders: Array<{ status: string; result: string | undefined }> = [];
197 function TestComponent() {
198 const { run, status, result } = useMutate(mutUpdate);
199 renders.push({ status, result });
200 return (
201 <>
202 <button data-testid="a" onClick={() => run("temp")}>A</button>
203 <button data-testid="b" onClick={() => run("final")}>B</button>
204 </>
205 );
206 }
207
208 render(<TestComponent />);
209 renders = [];
210
211 // First call
212 await act(() => user.click(screen.getByTestId("a")));
213 assertEquals(state.value, "temp");
214 renders = [];
215
216 // Second call - different from initial
217 await act(() => user.click(screen.getByTestId("b")));
218 assertEquals(state.value, "final");
219 renders = [];
220
221 // Wait for debounce
222 await act(async () => {
223 vi.advanceTimersByTime(500);
224 });
225 assertEquals(renders, [{ status: "mutating", result: undefined }]);
226 renders = [];
227
228 // Complete mutation
229 await act(async () => {
230 s.push("ok");
231 vi.advanceTimersByTime(100);
232 });
233 assertEquals(renders, [{ status: "success", result: "ok" }]);
234 assertEquals(successMessages, []);
235});