@voidhash/mimic-react 0.0.1-alpha.5 → 0.0.1-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +3 -3
- package/package.json +10 -4
- package/src/zustand/middleware.ts +1 -1
- package/src/zustand-commander/commander.ts +395 -0
- package/src/zustand-commander/hooks.ts +259 -0
- package/src/zustand-commander/index.ts +139 -0
- package/src/zustand-commander/types.ts +347 -0
- package/tests/zustand-commander/commander.test.ts +774 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createStore, type StoreApi } from "zustand";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
import {
|
|
5
|
+
createCommander,
|
|
6
|
+
performUndo,
|
|
7
|
+
performRedo,
|
|
8
|
+
clearUndoHistory,
|
|
9
|
+
isCommand,
|
|
10
|
+
isUndoableCommand,
|
|
11
|
+
COMMAND_SYMBOL,
|
|
12
|
+
UNDOABLE_COMMAND_SYMBOL,
|
|
13
|
+
type CommanderSlice,
|
|
14
|
+
type Command,
|
|
15
|
+
type UndoableCommand,
|
|
16
|
+
} from "../../src/zustand-commander/index.js";
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Test Store Types
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
interface TestState {
|
|
23
|
+
count: number;
|
|
24
|
+
items: string[];
|
|
25
|
+
selectedId: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type TestStore = TestState & CommanderSlice;
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Helper Functions
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
function createTestStore(
|
|
35
|
+
commander: ReturnType<typeof createCommander<TestState>>,
|
|
36
|
+
initial?: Partial<TestState>
|
|
37
|
+
) {
|
|
38
|
+
return createStore<TestStore>(
|
|
39
|
+
commander.middleware(() => ({
|
|
40
|
+
count: 0,
|
|
41
|
+
items: [],
|
|
42
|
+
selectedId: null,
|
|
43
|
+
...initial,
|
|
44
|
+
}))
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Tests
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
describe("createCommander", () => {
|
|
53
|
+
it("should create a commander instance with action and undoableAction methods", () => {
|
|
54
|
+
const commander = createCommander<TestState>();
|
|
55
|
+
|
|
56
|
+
expect(commander).toBeDefined();
|
|
57
|
+
expect(typeof commander.action).toBe("function");
|
|
58
|
+
expect(typeof commander.undoableAction).toBe("function");
|
|
59
|
+
expect(typeof commander.middleware).toBe("function");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should respect maxUndoStackSize option", () => {
|
|
63
|
+
const commander = createCommander<TestState>({ maxUndoStackSize: 2 });
|
|
64
|
+
const store = createTestStore(commander);
|
|
65
|
+
|
|
66
|
+
// Create an undoable action
|
|
67
|
+
const increment = commander.undoableAction(
|
|
68
|
+
(ctx) => {
|
|
69
|
+
const current = ctx.getState().count;
|
|
70
|
+
ctx.setState({ count: current + 1 });
|
|
71
|
+
return { previousCount: current };
|
|
72
|
+
},
|
|
73
|
+
(ctx, _params, result) => {
|
|
74
|
+
ctx.setState({ count: result.previousCount });
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Get dispatch via middleware context
|
|
79
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
80
|
+
const ctx = {
|
|
81
|
+
getState: () => storeApi.getState(),
|
|
82
|
+
setState: (partial: Partial<TestStore>) => storeApi.setState(partial),
|
|
83
|
+
dispatch: <TParams, TReturn>(cmd: Command<TestStore, TParams, TReturn>) => {
|
|
84
|
+
return (params: TParams): TReturn => cmd.fn(ctx, params);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Execute 5 times - stack should only keep last 2
|
|
89
|
+
for (let i = 0; i < 5; i++) {
|
|
90
|
+
increment.fn(ctx, undefined);
|
|
91
|
+
// Manually add to undo stack as we're bypassing the full dispatch
|
|
92
|
+
storeApi.setState((state: TestStore) => ({
|
|
93
|
+
...state,
|
|
94
|
+
_commander: {
|
|
95
|
+
undoStack: [...state._commander.undoStack, {
|
|
96
|
+
command: increment,
|
|
97
|
+
params: undefined,
|
|
98
|
+
result: { previousCount: i },
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
}].slice(-2),
|
|
101
|
+
redoStack: [],
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
expect(store.getState()._commander.undoStack.length).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("action (regular commands)", () => {
|
|
111
|
+
let commander: ReturnType<typeof createCommander<TestState>>;
|
|
112
|
+
let store: ReturnType<typeof createTestStore>;
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
commander = createCommander<TestState>();
|
|
116
|
+
store = createTestStore(commander);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should create command with schema + function", () => {
|
|
120
|
+
const setCount = commander.action(
|
|
121
|
+
Schema.Struct({ value: Schema.Number }),
|
|
122
|
+
(ctx, params) => {
|
|
123
|
+
ctx.setState({ count: params.value });
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(setCount).toBeDefined();
|
|
128
|
+
expect(setCount[COMMAND_SYMBOL]).toBe(true);
|
|
129
|
+
expect(setCount.paramsSchema).toBeDefined();
|
|
130
|
+
expect(typeof setCount.fn).toBe("function");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should create command without params (void)", () => {
|
|
134
|
+
const reset = commander.action((ctx) => {
|
|
135
|
+
ctx.setState({ count: 0, items: [], selectedId: null });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(reset).toBeDefined();
|
|
139
|
+
expect(reset[COMMAND_SYMBOL]).toBe(true);
|
|
140
|
+
expect(reset.paramsSchema).toBeNull();
|
|
141
|
+
expect(typeof reset.fn).toBe("function");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should execute command and modify state", () => {
|
|
145
|
+
const setCount = commander.action(
|
|
146
|
+
Schema.Struct({ value: Schema.Number }),
|
|
147
|
+
(ctx, params) => {
|
|
148
|
+
ctx.setState({ count: params.value });
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
153
|
+
const ctx = {
|
|
154
|
+
getState: () => storeApi.getState(),
|
|
155
|
+
setState: (partial: Partial<TestStore>) => storeApi.setState(partial),
|
|
156
|
+
dispatch: <TParams, TReturn>(cmd: Command<TestStore, TParams, TReturn>) => {
|
|
157
|
+
return (params: TParams): TReturn => cmd.fn(ctx, params);
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
expect(store.getState().count).toBe(0);
|
|
162
|
+
|
|
163
|
+
setCount.fn(ctx, { value: 42 });
|
|
164
|
+
|
|
165
|
+
expect(store.getState().count).toBe(42);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should NOT add to undo stack for regular actions", () => {
|
|
169
|
+
const setCount = commander.action(
|
|
170
|
+
Schema.Struct({ value: Schema.Number }),
|
|
171
|
+
(ctx, params) => {
|
|
172
|
+
ctx.setState({ count: params.value });
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
177
|
+
const ctx = {
|
|
178
|
+
getState: () => storeApi.getState(),
|
|
179
|
+
setState: (partial: Partial<TestStore>) => storeApi.setState(partial),
|
|
180
|
+
dispatch: <TParams, TReturn>(cmd: Command<TestStore, TParams, TReturn>) => {
|
|
181
|
+
return (params: TParams): TReturn => cmd.fn(ctx, params);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Execute the action
|
|
186
|
+
setCount.fn(ctx, { value: 100 });
|
|
187
|
+
|
|
188
|
+
// Undo stack should remain empty (regular action doesn't add to stack)
|
|
189
|
+
expect(store.getState()._commander.undoStack.length).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should return value from action", () => {
|
|
193
|
+
const getDoubled = commander.action(
|
|
194
|
+
Schema.Struct({ value: Schema.Number }),
|
|
195
|
+
(_ctx, params) => {
|
|
196
|
+
return params.value * 2;
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
201
|
+
const ctx = {
|
|
202
|
+
getState: () => storeApi.getState(),
|
|
203
|
+
setState: (partial: Partial<TestStore>) => storeApi.setState(partial),
|
|
204
|
+
dispatch: <TParams, TReturn>(cmd: Command<TestStore, TParams, TReturn>) => {
|
|
205
|
+
return (params: TParams): TReturn => cmd.fn(ctx, params);
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const result = getDoubled.fn(ctx, { value: 21 });
|
|
210
|
+
expect(result).toBe(42);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("undoableAction", () => {
|
|
215
|
+
let commander: ReturnType<typeof createCommander<TestState>>;
|
|
216
|
+
let store: ReturnType<typeof createTestStore>;
|
|
217
|
+
|
|
218
|
+
beforeEach(() => {
|
|
219
|
+
commander = createCommander<TestState>();
|
|
220
|
+
store = createTestStore(commander);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("should create undoable command with schema + fn + revert", () => {
|
|
224
|
+
const addItem = commander.undoableAction(
|
|
225
|
+
Schema.Struct({ item: Schema.String }),
|
|
226
|
+
(ctx, params) => {
|
|
227
|
+
const items = [...ctx.getState().items, params.item];
|
|
228
|
+
ctx.setState({ items });
|
|
229
|
+
return { index: items.length - 1 };
|
|
230
|
+
},
|
|
231
|
+
(ctx, _params, result) => {
|
|
232
|
+
const items = ctx.getState().items.filter((_, i) => i !== result.index);
|
|
233
|
+
ctx.setState({ items });
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(addItem).toBeDefined();
|
|
238
|
+
expect(addItem[COMMAND_SYMBOL]).toBe(true);
|
|
239
|
+
expect(addItem[UNDOABLE_COMMAND_SYMBOL]).toBe(true);
|
|
240
|
+
expect(addItem.paramsSchema).toBeDefined();
|
|
241
|
+
expect(typeof addItem.fn).toBe("function");
|
|
242
|
+
expect(typeof addItem.revert).toBe("function");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("should create undoable command without params", () => {
|
|
246
|
+
const increment = commander.undoableAction(
|
|
247
|
+
(ctx) => {
|
|
248
|
+
const current = ctx.getState().count;
|
|
249
|
+
ctx.setState({ count: current + 1 });
|
|
250
|
+
return { previousCount: current };
|
|
251
|
+
},
|
|
252
|
+
(ctx, _params, result) => {
|
|
253
|
+
ctx.setState({ count: result.previousCount });
|
|
254
|
+
}
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(increment).toBeDefined();
|
|
258
|
+
expect(increment[COMMAND_SYMBOL]).toBe(true);
|
|
259
|
+
expect(increment[UNDOABLE_COMMAND_SYMBOL]).toBe(true);
|
|
260
|
+
expect(increment.paramsSchema).toBeNull();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("should execute undoable action and modify state", () => {
|
|
264
|
+
const setCount = commander.undoableAction(
|
|
265
|
+
Schema.Struct({ value: Schema.Number }),
|
|
266
|
+
(ctx, params) => {
|
|
267
|
+
const prev = ctx.getState().count;
|
|
268
|
+
ctx.setState({ count: params.value });
|
|
269
|
+
return { previousValue: prev };
|
|
270
|
+
},
|
|
271
|
+
(ctx, _params, result) => {
|
|
272
|
+
ctx.setState({ count: result.previousValue });
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
277
|
+
const ctx = {
|
|
278
|
+
getState: () => storeApi.getState(),
|
|
279
|
+
setState: (partial: Partial<TestStore>) => storeApi.setState(partial),
|
|
280
|
+
dispatch: <TParams, TReturn>(cmd: Command<TestStore, TParams, TReturn>) => {
|
|
281
|
+
return (params: TParams): TReturn => cmd.fn(ctx, params);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
expect(store.getState().count).toBe(0);
|
|
286
|
+
|
|
287
|
+
setCount.fn(ctx, { value: 50 });
|
|
288
|
+
|
|
289
|
+
expect(store.getState().count).toBe(50);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("performUndo", () => {
|
|
294
|
+
let commander: ReturnType<typeof createCommander<TestState>>;
|
|
295
|
+
let store: ReturnType<typeof createTestStore>;
|
|
296
|
+
|
|
297
|
+
beforeEach(() => {
|
|
298
|
+
commander = createCommander<TestState>();
|
|
299
|
+
store = createTestStore(commander);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("should revert last action", () => {
|
|
303
|
+
const increment = commander.undoableAction(
|
|
304
|
+
(ctx) => {
|
|
305
|
+
const current = ctx.getState().count;
|
|
306
|
+
ctx.setState({ count: current + 1 });
|
|
307
|
+
return { previousCount: current };
|
|
308
|
+
},
|
|
309
|
+
(ctx, _params, result) => {
|
|
310
|
+
ctx.setState({ count: result.previousCount });
|
|
311
|
+
}
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
315
|
+
|
|
316
|
+
// Manually set up undo stack with an entry
|
|
317
|
+
store.setState((state: TestStore) => ({
|
|
318
|
+
...state,
|
|
319
|
+
count: 5,
|
|
320
|
+
_commander: {
|
|
321
|
+
undoStack: [{
|
|
322
|
+
command: increment,
|
|
323
|
+
params: undefined,
|
|
324
|
+
result: { previousCount: 4 },
|
|
325
|
+
timestamp: Date.now(),
|
|
326
|
+
}],
|
|
327
|
+
redoStack: [],
|
|
328
|
+
},
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
expect(store.getState().count).toBe(5);
|
|
332
|
+
|
|
333
|
+
const result = performUndo(storeApi);
|
|
334
|
+
|
|
335
|
+
expect(result).toBe(true);
|
|
336
|
+
expect(store.getState().count).toBe(4);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("should move entry to redo stack", () => {
|
|
340
|
+
const increment = commander.undoableAction(
|
|
341
|
+
(ctx) => {
|
|
342
|
+
const current = ctx.getState().count;
|
|
343
|
+
ctx.setState({ count: current + 1 });
|
|
344
|
+
return { previousCount: current };
|
|
345
|
+
},
|
|
346
|
+
(ctx, _params, result) => {
|
|
347
|
+
ctx.setState({ count: result.previousCount });
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
352
|
+
|
|
353
|
+
// Set up with one entry in undo stack
|
|
354
|
+
store.setState((state: TestStore) => ({
|
|
355
|
+
...state,
|
|
356
|
+
count: 10,
|
|
357
|
+
_commander: {
|
|
358
|
+
undoStack: [{
|
|
359
|
+
command: increment,
|
|
360
|
+
params: undefined,
|
|
361
|
+
result: { previousCount: 9 },
|
|
362
|
+
timestamp: Date.now(),
|
|
363
|
+
}],
|
|
364
|
+
redoStack: [],
|
|
365
|
+
},
|
|
366
|
+
}));
|
|
367
|
+
|
|
368
|
+
expect(store.getState()._commander.undoStack.length).toBe(1);
|
|
369
|
+
expect(store.getState()._commander.redoStack.length).toBe(0);
|
|
370
|
+
|
|
371
|
+
performUndo(storeApi);
|
|
372
|
+
|
|
373
|
+
expect(store.getState()._commander.undoStack.length).toBe(0);
|
|
374
|
+
expect(store.getState()._commander.redoStack.length).toBe(1);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("should return false when undo stack is empty", () => {
|
|
378
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
379
|
+
|
|
380
|
+
expect(store.getState()._commander.undoStack.length).toBe(0);
|
|
381
|
+
|
|
382
|
+
const result = performUndo(storeApi);
|
|
383
|
+
|
|
384
|
+
expect(result).toBe(false);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("performRedo", () => {
|
|
389
|
+
let commander: ReturnType<typeof createCommander<TestState>>;
|
|
390
|
+
let store: ReturnType<typeof createTestStore>;
|
|
391
|
+
|
|
392
|
+
beforeEach(() => {
|
|
393
|
+
commander = createCommander<TestState>();
|
|
394
|
+
store = createTestStore(commander);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("should re-execute undone action", () => {
|
|
398
|
+
const increment = commander.undoableAction(
|
|
399
|
+
(ctx) => {
|
|
400
|
+
const current = ctx.getState().count;
|
|
401
|
+
ctx.setState({ count: current + 1 });
|
|
402
|
+
return { previousCount: current };
|
|
403
|
+
},
|
|
404
|
+
(ctx, _params, result) => {
|
|
405
|
+
ctx.setState({ count: result.previousCount });
|
|
406
|
+
}
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
410
|
+
|
|
411
|
+
// Set up with an entry in redo stack
|
|
412
|
+
store.setState((state: TestStore) => ({
|
|
413
|
+
...state,
|
|
414
|
+
count: 4,
|
|
415
|
+
_commander: {
|
|
416
|
+
undoStack: [],
|
|
417
|
+
redoStack: [{
|
|
418
|
+
command: increment,
|
|
419
|
+
params: undefined,
|
|
420
|
+
result: { previousCount: 4 },
|
|
421
|
+
timestamp: Date.now(),
|
|
422
|
+
}],
|
|
423
|
+
},
|
|
424
|
+
}));
|
|
425
|
+
|
|
426
|
+
expect(store.getState().count).toBe(4);
|
|
427
|
+
|
|
428
|
+
const result = performRedo(storeApi);
|
|
429
|
+
|
|
430
|
+
expect(result).toBe(true);
|
|
431
|
+
expect(store.getState().count).toBe(5);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("should move entry back to undo stack", () => {
|
|
435
|
+
const increment = commander.undoableAction(
|
|
436
|
+
(ctx) => {
|
|
437
|
+
const current = ctx.getState().count;
|
|
438
|
+
ctx.setState({ count: current + 1 });
|
|
439
|
+
return { previousCount: current };
|
|
440
|
+
},
|
|
441
|
+
(ctx, _params, result) => {
|
|
442
|
+
ctx.setState({ count: result.previousCount });
|
|
443
|
+
}
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
447
|
+
|
|
448
|
+
// Set up with an entry in redo stack
|
|
449
|
+
store.setState((state: TestStore) => ({
|
|
450
|
+
...state,
|
|
451
|
+
count: 0,
|
|
452
|
+
_commander: {
|
|
453
|
+
undoStack: [],
|
|
454
|
+
redoStack: [{
|
|
455
|
+
command: increment,
|
|
456
|
+
params: undefined,
|
|
457
|
+
result: { previousCount: 0 },
|
|
458
|
+
timestamp: Date.now(),
|
|
459
|
+
}],
|
|
460
|
+
},
|
|
461
|
+
}));
|
|
462
|
+
|
|
463
|
+
expect(store.getState()._commander.undoStack.length).toBe(0);
|
|
464
|
+
expect(store.getState()._commander.redoStack.length).toBe(1);
|
|
465
|
+
|
|
466
|
+
performRedo(storeApi);
|
|
467
|
+
|
|
468
|
+
expect(store.getState()._commander.undoStack.length).toBe(1);
|
|
469
|
+
expect(store.getState()._commander.redoStack.length).toBe(0);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("should return false when redo stack is empty", () => {
|
|
473
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
474
|
+
|
|
475
|
+
expect(store.getState()._commander.redoStack.length).toBe(0);
|
|
476
|
+
|
|
477
|
+
const result = performRedo(storeApi);
|
|
478
|
+
|
|
479
|
+
expect(result).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe("clearUndoHistory", () => {
|
|
484
|
+
it("should clear both stacks", () => {
|
|
485
|
+
const commander = createCommander<TestState>();
|
|
486
|
+
const store = createTestStore(commander);
|
|
487
|
+
|
|
488
|
+
const increment = commander.undoableAction(
|
|
489
|
+
(ctx) => {
|
|
490
|
+
const current = ctx.getState().count;
|
|
491
|
+
ctx.setState({ count: current + 1 });
|
|
492
|
+
return { previousCount: current };
|
|
493
|
+
},
|
|
494
|
+
(ctx, _params, result) => {
|
|
495
|
+
ctx.setState({ count: result.previousCount });
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
500
|
+
|
|
501
|
+
// Add entries to both stacks
|
|
502
|
+
store.setState((state: TestStore) => ({
|
|
503
|
+
...state,
|
|
504
|
+
_commander: {
|
|
505
|
+
undoStack: [{
|
|
506
|
+
command: increment,
|
|
507
|
+
params: undefined,
|
|
508
|
+
result: { previousCount: 0 },
|
|
509
|
+
timestamp: Date.now(),
|
|
510
|
+
}],
|
|
511
|
+
redoStack: [{
|
|
512
|
+
command: increment,
|
|
513
|
+
params: undefined,
|
|
514
|
+
result: { previousCount: 1 },
|
|
515
|
+
timestamp: Date.now(),
|
|
516
|
+
}],
|
|
517
|
+
},
|
|
518
|
+
}));
|
|
519
|
+
|
|
520
|
+
expect(store.getState()._commander.undoStack.length).toBe(1);
|
|
521
|
+
expect(store.getState()._commander.redoStack.length).toBe(1);
|
|
522
|
+
|
|
523
|
+
clearUndoHistory(storeApi);
|
|
524
|
+
|
|
525
|
+
expect(store.getState()._commander.undoStack.length).toBe(0);
|
|
526
|
+
expect(store.getState()._commander.redoStack.length).toBe(0);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe("Type Guards", () => {
|
|
531
|
+
let commander: ReturnType<typeof createCommander<TestState>>;
|
|
532
|
+
|
|
533
|
+
beforeEach(() => {
|
|
534
|
+
commander = createCommander<TestState>();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
describe("isCommand", () => {
|
|
538
|
+
it("should return true for regular commands", () => {
|
|
539
|
+
const cmd = commander.action((ctx) => {
|
|
540
|
+
ctx.setState({ count: 0 });
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
expect(isCommand(cmd)).toBe(true);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("should return true for undoable commands", () => {
|
|
547
|
+
const cmd = commander.undoableAction(
|
|
548
|
+
(ctx) => {
|
|
549
|
+
const prev = ctx.getState().count;
|
|
550
|
+
ctx.setState({ count: 0 });
|
|
551
|
+
return { prev };
|
|
552
|
+
},
|
|
553
|
+
(ctx, _params, result) => {
|
|
554
|
+
ctx.setState({ count: result.prev });
|
|
555
|
+
}
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
expect(isCommand(cmd)).toBe(true);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("should return false for non-commands", () => {
|
|
562
|
+
expect(isCommand(null)).toBe(false);
|
|
563
|
+
expect(isCommand(undefined)).toBe(false);
|
|
564
|
+
expect(isCommand({})).toBe(false);
|
|
565
|
+
expect(isCommand({ fn: () => {} })).toBe(false);
|
|
566
|
+
expect(isCommand("string")).toBe(false);
|
|
567
|
+
expect(isCommand(42)).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe("isUndoableCommand", () => {
|
|
572
|
+
it("should return true for undoable commands", () => {
|
|
573
|
+
const cmd = commander.undoableAction(
|
|
574
|
+
(ctx) => {
|
|
575
|
+
const prev = ctx.getState().count;
|
|
576
|
+
ctx.setState({ count: 0 });
|
|
577
|
+
return { prev };
|
|
578
|
+
},
|
|
579
|
+
(ctx, _params, result) => {
|
|
580
|
+
ctx.setState({ count: result.prev });
|
|
581
|
+
}
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
expect(isUndoableCommand(cmd)).toBe(true);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("should return false for regular commands", () => {
|
|
588
|
+
const cmd = commander.action((ctx) => {
|
|
589
|
+
ctx.setState({ count: 0 });
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
expect(isUndoableCommand(cmd)).toBe(false);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("should return false for non-commands", () => {
|
|
596
|
+
expect(isUndoableCommand(null)).toBe(false);
|
|
597
|
+
expect(isUndoableCommand(undefined)).toBe(false);
|
|
598
|
+
expect(isUndoableCommand({})).toBe(false);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe("Middleware", () => {
|
|
604
|
+
it("should add _commander slice to store", () => {
|
|
605
|
+
const commander = createCommander<TestState>();
|
|
606
|
+
const store = createTestStore(commander);
|
|
607
|
+
|
|
608
|
+
const state = store.getState();
|
|
609
|
+
|
|
610
|
+
expect(state._commander).toBeDefined();
|
|
611
|
+
expect(state._commander.undoStack).toBeDefined();
|
|
612
|
+
expect(state._commander.redoStack).toBeDefined();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("should initialize with empty stacks", () => {
|
|
616
|
+
const commander = createCommander<TestState>();
|
|
617
|
+
const store = createTestStore(commander);
|
|
618
|
+
|
|
619
|
+
const state = store.getState();
|
|
620
|
+
|
|
621
|
+
expect(state._commander.undoStack).toEqual([]);
|
|
622
|
+
expect(state._commander.redoStack).toEqual([]);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("should preserve user state", () => {
|
|
626
|
+
const commander = createCommander<TestState>();
|
|
627
|
+
const store = createTestStore(commander, {
|
|
628
|
+
count: 100,
|
|
629
|
+
items: ["a", "b"],
|
|
630
|
+
selectedId: "test-id",
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const state = store.getState();
|
|
634
|
+
|
|
635
|
+
expect(state.count).toBe(100);
|
|
636
|
+
expect(state.items).toEqual(["a", "b"]);
|
|
637
|
+
expect(state.selectedId).toBe("test-id");
|
|
638
|
+
expect(state._commander).toBeDefined();
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
describe("Command Dispatch in Context", () => {
|
|
643
|
+
it("should allow dispatching commands from within commands", () => {
|
|
644
|
+
const commander = createCommander<TestState>();
|
|
645
|
+
const store = createTestStore(commander);
|
|
646
|
+
|
|
647
|
+
const addItem = commander.action(
|
|
648
|
+
Schema.Struct({ item: Schema.String }),
|
|
649
|
+
(ctx, params) => {
|
|
650
|
+
const items = [...ctx.getState().items, params.item];
|
|
651
|
+
ctx.setState({ items });
|
|
652
|
+
}
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
const addMultiple = commander.action(
|
|
656
|
+
Schema.Struct({ items: Schema.Array(Schema.String) }),
|
|
657
|
+
(ctx, params) => {
|
|
658
|
+
for (const item of params.items) {
|
|
659
|
+
ctx.dispatch(addItem)({ item });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
665
|
+
|
|
666
|
+
// Create a proper dispatch function
|
|
667
|
+
const createCtx = (): typeof ctx => ({
|
|
668
|
+
getState: () => storeApi.getState(),
|
|
669
|
+
setState: (partial: Partial<TestStore>) => storeApi.setState(partial),
|
|
670
|
+
dispatch: <TParams, TReturn>(cmd: Command<TestStore, TParams, TReturn>) => {
|
|
671
|
+
return (params: TParams): TReturn => cmd.fn(createCtx(), params);
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
const ctx = createCtx();
|
|
676
|
+
|
|
677
|
+
addMultiple.fn(ctx, { items: ["x", "y", "z"] });
|
|
678
|
+
|
|
679
|
+
expect(store.getState().items).toEqual(["x", "y", "z"]);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe("Undo/Redo Integration", () => {
|
|
684
|
+
it("should handle multiple undo operations", () => {
|
|
685
|
+
const commander = createCommander<TestState>();
|
|
686
|
+
const store = createTestStore(commander);
|
|
687
|
+
|
|
688
|
+
const setCount = commander.undoableAction(
|
|
689
|
+
Schema.Struct({ value: Schema.Number }),
|
|
690
|
+
(ctx, params) => {
|
|
691
|
+
const prev = ctx.getState().count;
|
|
692
|
+
ctx.setState({ count: params.value });
|
|
693
|
+
return { previousValue: prev };
|
|
694
|
+
},
|
|
695
|
+
(ctx, _params, result) => {
|
|
696
|
+
ctx.setState({ count: result.previousValue });
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
701
|
+
|
|
702
|
+
// Set up stack with multiple entries: 0 -> 10 -> 20 -> 30
|
|
703
|
+
store.setState((state: TestStore) => ({
|
|
704
|
+
...state,
|
|
705
|
+
count: 30,
|
|
706
|
+
_commander: {
|
|
707
|
+
undoStack: [
|
|
708
|
+
{ command: setCount, params: { value: 10 }, result: { previousValue: 0 }, timestamp: 1 },
|
|
709
|
+
{ command: setCount, params: { value: 20 }, result: { previousValue: 10 }, timestamp: 2 },
|
|
710
|
+
{ command: setCount, params: { value: 30 }, result: { previousValue: 20 }, timestamp: 3 },
|
|
711
|
+
],
|
|
712
|
+
redoStack: [],
|
|
713
|
+
},
|
|
714
|
+
}));
|
|
715
|
+
|
|
716
|
+
// Undo 30 -> 20
|
|
717
|
+
performUndo(storeApi);
|
|
718
|
+
expect(store.getState().count).toBe(20);
|
|
719
|
+
|
|
720
|
+
// Undo 20 -> 10
|
|
721
|
+
performUndo(storeApi);
|
|
722
|
+
expect(store.getState().count).toBe(10);
|
|
723
|
+
|
|
724
|
+
// Undo 10 -> 0
|
|
725
|
+
performUndo(storeApi);
|
|
726
|
+
expect(store.getState().count).toBe(0);
|
|
727
|
+
|
|
728
|
+
// Redo stack should have all 3 entries
|
|
729
|
+
expect(store.getState()._commander.redoStack.length).toBe(3);
|
|
730
|
+
expect(store.getState()._commander.undoStack.length).toBe(0);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("should handle undo then redo", () => {
|
|
734
|
+
const commander = createCommander<TestState>();
|
|
735
|
+
const store = createTestStore(commander);
|
|
736
|
+
|
|
737
|
+
const increment = commander.undoableAction(
|
|
738
|
+
(ctx) => {
|
|
739
|
+
const current = ctx.getState().count;
|
|
740
|
+
ctx.setState({ count: current + 1 });
|
|
741
|
+
return { previousCount: current };
|
|
742
|
+
},
|
|
743
|
+
(ctx, _params, result) => {
|
|
744
|
+
ctx.setState({ count: result.previousCount });
|
|
745
|
+
}
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const storeApi = store as unknown as StoreApi<TestStore>;
|
|
749
|
+
|
|
750
|
+
// Start at count 5
|
|
751
|
+
store.setState((state: TestStore) => ({
|
|
752
|
+
...state,
|
|
753
|
+
count: 5,
|
|
754
|
+
_commander: {
|
|
755
|
+
undoStack: [{
|
|
756
|
+
command: increment,
|
|
757
|
+
params: undefined,
|
|
758
|
+
result: { previousCount: 4 },
|
|
759
|
+
timestamp: Date.now(),
|
|
760
|
+
}],
|
|
761
|
+
redoStack: [],
|
|
762
|
+
},
|
|
763
|
+
}));
|
|
764
|
+
|
|
765
|
+
// Undo: 5 -> 4
|
|
766
|
+
performUndo(storeApi);
|
|
767
|
+
expect(store.getState().count).toBe(4);
|
|
768
|
+
|
|
769
|
+
// Redo: 4 -> 5
|
|
770
|
+
performRedo(storeApi);
|
|
771
|
+
expect(store.getState().count).toBe(5);
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
|