@storve/core 1.0.1 → 1.0.3
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/LICENSE +21 -0
- package/README.md +993 -26
- package/dist/adapters/indexedDB.cjs +0 -1
- package/dist/adapters/indexedDB.mjs +0 -1
- package/dist/adapters/localStorage.cjs +0 -1
- package/dist/adapters/localStorage.mjs +0 -1
- package/dist/adapters/memory.cjs +0 -1
- package/dist/adapters/memory.mjs +0 -1
- package/dist/adapters/sessionStorage.cjs +0 -1
- package/dist/adapters/sessionStorage.mjs +0 -1
- package/dist/async-entry.d.ts +0 -1
- package/dist/async.cjs +0 -1
- package/dist/async.d.ts +0 -1
- package/dist/async.mjs +0 -1
- package/dist/batch.d.ts +0 -1
- package/dist/compose.d.ts +0 -1
- package/dist/computed-entry.d.ts +0 -1
- package/dist/computed.cjs +0 -1
- package/dist/computed.d.ts +0 -1
- package/dist/computed.mjs +0 -1
- package/dist/devtools/history.d.ts +0 -1
- package/dist/devtools/index.d.ts +0 -1
- package/dist/devtools/redux-bridge.d.ts +0 -1
- package/dist/devtools/snapshots.d.ts +0 -1
- package/dist/devtools/withDevtools.d.ts +0 -1
- package/dist/devtools.cjs +0 -1
- package/dist/devtools.mjs +0 -1
- package/dist/extensions/noop.d.ts +0 -1
- package/dist/index.cjs +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.mjs +0 -1
- package/dist/persist/adapters/indexedDB.d.ts +0 -1
- package/dist/persist/adapters/localStorage.d.ts +0 -1
- package/dist/persist/adapters/memory.d.ts +0 -1
- package/dist/persist/adapters/sessionStorage.d.ts +0 -1
- package/dist/persist/debounce.d.ts +0 -1
- package/dist/persist/hydrate.d.ts +0 -1
- package/dist/persist/index.d.ts +0 -1
- package/dist/persist/serialize.d.ts +0 -1
- package/dist/persist.cjs +0 -1
- package/dist/persist.mjs +0 -1
- package/dist/proxy.d.ts +0 -1
- package/dist/registry-qtr1UpFU.js +0 -1
- package/dist/registry-zaKZ1P-s.js +0 -1
- package/dist/registry.d.ts +0 -1
- package/dist/signals/createSignal.d.ts +0 -1
- package/dist/signals/index.d.ts +0 -1
- package/dist/signals/useSignal.d.ts +0 -1
- package/dist/signals.cjs +0 -1
- package/dist/signals.mjs +0 -1
- package/dist/store.d.ts +0 -1
- package/dist/sync/channel.d.ts +0 -1
- package/dist/sync/index.d.ts +0 -1
- package/dist/sync/protocol.d.ts +0 -1
- package/dist/sync/withSync.d.ts +0 -1
- package/dist/sync.cjs +0 -1
- package/dist/sync.mjs +0 -1
- package/dist/types.d.ts +0 -1
- package/package.json +9 -3
- package/CHANGELOG.md +0 -151
- package/benchmarks/run.ts +0 -102
- package/benchmarks/week2.md +0 -9
- package/benchmarks/week2.ts +0 -64
- package/benchmarks/week4.md +0 -13
- package/benchmarks/week4.ts +0 -178
- package/benchmarks/week5.md +0 -15
- package/benchmarks/week5.ts +0 -184
- package/coverage/coverage-summary.json +0 -31
- package/dist/adapters/indexedDB.cjs.map +0 -1
- package/dist/adapters/indexedDB.mjs.map +0 -1
- package/dist/adapters/localStorage.cjs.map +0 -1
- package/dist/adapters/localStorage.mjs.map +0 -1
- package/dist/adapters/memory.cjs.map +0 -1
- package/dist/adapters/memory.mjs.map +0 -1
- package/dist/adapters/sessionStorage.cjs.map +0 -1
- package/dist/adapters/sessionStorage.mjs.map +0 -1
- package/dist/async-entry.d.ts.map +0 -1
- package/dist/async.cjs.map +0 -1
- package/dist/async.d.ts.map +0 -1
- package/dist/async.mjs.map +0 -1
- package/dist/batch.d.ts.map +0 -1
- package/dist/compose.d.ts.map +0 -1
- package/dist/computed-entry.d.ts.map +0 -1
- package/dist/computed.cjs.map +0 -1
- package/dist/computed.d.ts.map +0 -1
- package/dist/computed.mjs.map +0 -1
- package/dist/devtools/history.d.ts.map +0 -1
- package/dist/devtools/index.d.ts.map +0 -1
- package/dist/devtools/redux-bridge.d.ts.map +0 -1
- package/dist/devtools/snapshots.d.ts.map +0 -1
- package/dist/devtools/withDevtools.d.ts.map +0 -1
- package/dist/devtools.cjs.map +0 -1
- package/dist/devtools.mjs.map +0 -1
- package/dist/extensions/noop.d.ts.map +0 -1
- package/dist/index.cjs.js +0 -118
- package/dist/index.cjs.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.esm.js +0 -116
- package/dist/index.esm.js.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/persist/adapters/indexedDB.d.ts.map +0 -1
- package/dist/persist/adapters/localStorage.d.ts.map +0 -1
- package/dist/persist/adapters/memory.d.ts.map +0 -1
- package/dist/persist/adapters/sessionStorage.d.ts.map +0 -1
- package/dist/persist/debounce.d.ts.map +0 -1
- package/dist/persist/hydrate.d.ts.map +0 -1
- package/dist/persist/index.d.ts.map +0 -1
- package/dist/persist/serialize.d.ts.map +0 -1
- package/dist/persist.cjs.map +0 -1
- package/dist/persist.mjs.map +0 -1
- package/dist/proxy.d.ts.map +0 -1
- package/dist/registry-D3X0HSbl.js +0 -26
- package/dist/registry-D3X0HSbl.js.map +0 -1
- package/dist/registry-RDjbeJdx.js +0 -29
- package/dist/registry-RDjbeJdx.js.map +0 -1
- package/dist/registry-qtr1UpFU.js.map +0 -1
- package/dist/registry-zaKZ1P-s.js.map +0 -1
- package/dist/registry.d.ts.map +0 -1
- package/dist/signals/createSignal.d.ts.map +0 -1
- package/dist/signals/index.d.ts.map +0 -1
- package/dist/signals/useSignal.d.ts.map +0 -1
- package/dist/signals.cjs.map +0 -1
- package/dist/signals.mjs.map +0 -1
- package/dist/stats.html +0 -4949
- package/dist/store.d.ts.map +0 -1
- package/dist/sync/channel.d.ts.map +0 -1
- package/dist/sync/index.d.ts.map +0 -1
- package/dist/sync/protocol.d.ts.map +0 -1
- package/dist/sync/withSync.d.ts.map +0 -1
- package/dist/sync.cjs.map +0 -1
- package/dist/sync.mjs.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/rollup.config.mjs +0 -44
- package/src/async-entry.ts +0 -6
- package/src/async.ts +0 -240
- package/src/batch.ts +0 -33
- package/src/compose.ts +0 -50
- package/src/computed-entry.ts +0 -6
- package/src/computed.ts +0 -187
- package/src/devtools/history.ts +0 -103
- package/src/devtools/index.ts +0 -5
- package/src/devtools/redux-bridge.ts +0 -70
- package/src/devtools/snapshots.ts +0 -54
- package/src/devtools/withDevtools.ts +0 -196
- package/src/extensions/noop.ts +0 -12
- package/src/index.ts +0 -4
- package/src/persist/adapters/indexedDB.ts +0 -114
- package/src/persist/adapters/localStorage.ts +0 -28
- package/src/persist/adapters/memory.ts +0 -26
- package/src/persist/adapters/sessionStorage.ts +0 -28
- package/src/persist/debounce.ts +0 -28
- package/src/persist/hydrate.ts +0 -60
- package/src/persist/index.ts +0 -141
- package/src/persist/serialize.ts +0 -60
- package/src/proxy.ts +0 -87
- package/src/registry.ts +0 -67
- package/src/signals/createSignal.ts +0 -81
- package/src/signals/index.ts +0 -20
- package/src/signals/useSignal.ts +0 -18
- package/src/store.ts +0 -250
- package/src/sync/channel.ts +0 -15
- package/src/sync/index.ts +0 -3
- package/src/sync/protocol.ts +0 -18
- package/src/sync/withSync.ts +0 -147
- package/src/types.ts +0 -159
- package/tests/async.test.ts +0 -1100
- package/tests/batch.test.ts +0 -41
- package/tests/compose.test.ts +0 -209
- package/tests/computed.test.ts +0 -867
- package/tests/devtools.test.ts +0 -1039
- package/tests/integration/persist.integration.test.ts +0 -258
- package/tests/integration/signals.integration.test.ts +0 -309
- package/tests/integration.test.ts +0 -278
- package/tests/persist/adapters/indexedDB.adapter.test.ts +0 -185
- package/tests/persist/adapters/localStorage.adapter.test.ts +0 -105
- package/tests/persist/adapters/memory.adapter.test.ts +0 -112
- package/tests/persist/adapters/sessionStorage.adapter.test.ts +0 -128
- package/tests/persist/debounce.test.ts +0 -121
- package/tests/persist/hydrate.test.ts +0 -120
- package/tests/persist/migrate.test.ts +0 -208
- package/tests/persist/persist.test.ts +0 -357
- package/tests/persist/serialize.test.ts +0 -128
- package/tests/proxy.test.ts +0 -473
- package/tests/registry.test.ts +0 -67
- package/tests/signals/derived.test.ts +0 -244
- package/tests/signals/inference.test.ts +0 -108
- package/tests/signals/signal.test.ts +0 -348
- package/tests/signals/useSignal.test.tsx +0 -275
- package/tests/store.test.ts +0 -482
- package/tests/stress.test.ts +0 -268
- package/tests/sync.test.ts +0 -576
- package/tests/types.test.ts +0 -32
- package/tests/v0.3.test.ts +0 -813
- package/tree-shake-test.js +0 -1
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -22
- package/vitest_play.ts +0 -7
package/tests/devtools.test.ts
DELETED
|
@@ -1,1039 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import type { Store } from '../src/types';
|
|
3
|
-
import { createStore } from '../src/store';
|
|
4
|
-
import { withDevtools } from '../src/devtools/withDevtools';
|
|
5
|
-
import {
|
|
6
|
-
createRingBuffer, push, undo, redo, canUndo, canRedo,
|
|
7
|
-
type HistoryEntry
|
|
8
|
-
} from '../src/devtools/history';
|
|
9
|
-
import {
|
|
10
|
-
createSnapshotMap, saveSnapshot, getSnapshot,
|
|
11
|
-
deleteSnapshot, listSnapshots
|
|
12
|
-
} from '../src/devtools/snapshots';
|
|
13
|
-
// import { connectReduxDevtools } from '../src/devtools/redux-bridge';
|
|
14
|
-
|
|
15
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
function makeStore(opts?: { maxHistory?: number; enabled?: boolean; name?: string }) {
|
|
18
|
-
return createStore(
|
|
19
|
-
withDevtools({ count: 0, label: 'init' }, {
|
|
20
|
-
name: opts?.name ?? 'TestStore',
|
|
21
|
-
maxHistory: opts?.maxHistory,
|
|
22
|
-
enabled: opts?.enabled,
|
|
23
|
-
})
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
type InternalStore<S extends object> = Store<S> & {
|
|
28
|
-
__devtools: import('../src/devtools/redux-bridge').DevtoolsInternals<S>;
|
|
29
|
-
undo: () => void;
|
|
30
|
-
redo: () => void;
|
|
31
|
-
canUndo: boolean;
|
|
32
|
-
canRedo: boolean;
|
|
33
|
-
history: HistoryEntry<S>[];
|
|
34
|
-
snapshots: string[];
|
|
35
|
-
clearHistory: () => void;
|
|
36
|
-
deleteSnapshot: (name: string) => void;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function internals<S extends object>(store: Store<S>) {
|
|
40
|
-
return (store as unknown as InternalStore<S>).__devtools;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function historyOf<S extends object>(store: Store<S>): HistoryEntry<S>[] {
|
|
44
|
-
return (store as unknown as InternalStore<S>).history;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function snapshotsOf<S extends object>(store: Store<S>): string[] {
|
|
48
|
-
return (store as unknown as InternalStore<S>).snapshots;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
-
// 1. RING BUFFER — pure logic
|
|
53
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
describe('Ring Buffer — pure logic', () => {
|
|
56
|
-
|
|
57
|
-
describe('createRingBuffer', () => {
|
|
58
|
-
it('starts empty with cursor -1', () => {
|
|
59
|
-
const buf = createRingBuffer<number>(10);
|
|
60
|
-
expect(buf.entries).toHaveLength(0);
|
|
61
|
-
expect(buf.cursor).toBe(-1);
|
|
62
|
-
expect(buf.capacity).toBe(10);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('defaults capacity to 50', () => {
|
|
66
|
-
const buf = createRingBuffer<number>();
|
|
67
|
-
expect(buf.capacity).toBe(50);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('returns a new object on each call', () => {
|
|
71
|
-
const a = createRingBuffer<number>();
|
|
72
|
-
const b = createRingBuffer<number>();
|
|
73
|
-
expect(a).not.toBe(b);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe('push', () => {
|
|
78
|
-
it('adds first entry and sets cursor to 0', () => {
|
|
79
|
-
let buf = createRingBuffer<number>(5);
|
|
80
|
-
buf = push(buf, 1, 'init');
|
|
81
|
-
expect(buf.entries).toHaveLength(1);
|
|
82
|
-
expect(buf.cursor).toBe(0);
|
|
83
|
-
expect(buf.entries[0].state).toBe(1);
|
|
84
|
-
expect(buf.entries[0].actionName).toBe('init');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('records a timestamp on each entry', () => {
|
|
88
|
-
const before = Date.now();
|
|
89
|
-
let buf = createRingBuffer<number>(5);
|
|
90
|
-
buf = push(buf, 1, 'init');
|
|
91
|
-
const after = Date.now();
|
|
92
|
-
expect(buf.entries[0].timestamp).toBeGreaterThanOrEqual(before);
|
|
93
|
-
expect(buf.entries[0].timestamp).toBeLessThanOrEqual(after);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('appends entries up to capacity', () => {
|
|
97
|
-
let buf = createRingBuffer<number>(3);
|
|
98
|
-
buf = push(buf, 1, 'a');
|
|
99
|
-
buf = push(buf, 2, 'b');
|
|
100
|
-
buf = push(buf, 3, 'c');
|
|
101
|
-
expect(buf.entries).toHaveLength(3);
|
|
102
|
-
expect(buf.cursor).toBe(2);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('drops oldest entry when capacity is exceeded', () => {
|
|
106
|
-
let buf = createRingBuffer<number>(3);
|
|
107
|
-
buf = push(buf, 1, 'a');
|
|
108
|
-
buf = push(buf, 2, 'b');
|
|
109
|
-
buf = push(buf, 3, 'c');
|
|
110
|
-
buf = push(buf, 4, 'd');
|
|
111
|
-
expect(buf.entries).toHaveLength(3);
|
|
112
|
-
expect(buf.entries[0].actionName).toBe('b');
|
|
113
|
-
expect(buf.entries[2].actionName).toBe('d');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('cursor stays at capacity - 1 after overflow', () => {
|
|
117
|
-
let buf = createRingBuffer<number>(3);
|
|
118
|
-
buf = push(buf, 1, 'a');
|
|
119
|
-
buf = push(buf, 2, 'b');
|
|
120
|
-
buf = push(buf, 3, 'c');
|
|
121
|
-
buf = push(buf, 4, 'd');
|
|
122
|
-
expect(buf.cursor).toBe(2);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('discards redo stack when pushing after undo', () => {
|
|
126
|
-
let buf = createRingBuffer<string>(10);
|
|
127
|
-
buf = push(buf, 'A', '1');
|
|
128
|
-
buf = push(buf, 'B', '2');
|
|
129
|
-
buf = push(buf, 'C', '3');
|
|
130
|
-
buf = undo(buf).buffer;
|
|
131
|
-
buf = undo(buf).buffer; // cursor at A
|
|
132
|
-
buf = push(buf, 'D', '4');
|
|
133
|
-
expect(buf.entries).toHaveLength(2);
|
|
134
|
-
expect(buf.entries[0].state).toBe('A');
|
|
135
|
-
expect(buf.entries[1].state).toBe('D');
|
|
136
|
-
expect(canRedo(buf)).toBe(false);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('is immutable — does not mutate input buffer', () => {
|
|
140
|
-
const original = createRingBuffer<number>(5);
|
|
141
|
-
const next = push(original, 1, 'a');
|
|
142
|
-
expect(original.entries).toHaveLength(0);
|
|
143
|
-
expect(original.cursor).toBe(-1);
|
|
144
|
-
expect(next).not.toBe(original);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('handles capacity of 1', () => {
|
|
148
|
-
let buf = createRingBuffer<number>(1);
|
|
149
|
-
buf = push(buf, 1, 'a');
|
|
150
|
-
buf = push(buf, 2, 'b');
|
|
151
|
-
expect(buf.entries).toHaveLength(1);
|
|
152
|
-
expect(buf.entries[0].state).toBe(2);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('handles object states without cross-contamination', () => {
|
|
156
|
-
let buf = createRingBuffer<{ x: number }>(5);
|
|
157
|
-
const s1 = { x: 1 };
|
|
158
|
-
const s2 = { x: 2 };
|
|
159
|
-
buf = push(buf, s1, 'a');
|
|
160
|
-
buf = push(buf, s2, 'b');
|
|
161
|
-
expect(buf.entries[0].state).toBe(s1);
|
|
162
|
-
expect(buf.entries[1].state).toBe(s2);
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe('undo', () => {
|
|
167
|
-
it('moves cursor back one and returns previous state', () => {
|
|
168
|
-
let buf = createRingBuffer<number>(10);
|
|
169
|
-
buf = push(buf, 1, 'a');
|
|
170
|
-
buf = push(buf, 2, 'b');
|
|
171
|
-
buf = push(buf, 3, 'c');
|
|
172
|
-
const result = undo(buf);
|
|
173
|
-
expect(result.state).toBe(2);
|
|
174
|
-
expect(result.buffer.cursor).toBe(1);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('at cursor 0 returns null and same buffer reference', () => {
|
|
178
|
-
let buf = createRingBuffer<number>(10);
|
|
179
|
-
buf = push(buf, 1, 'only');
|
|
180
|
-
const result = undo(buf);
|
|
181
|
-
expect(result.state).toBeNull();
|
|
182
|
-
expect(result.buffer).toBe(buf);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('on empty buffer returns null', () => {
|
|
186
|
-
const buf = createRingBuffer<number>(10);
|
|
187
|
-
const result = undo(buf);
|
|
188
|
-
expect(result.state).toBeNull();
|
|
189
|
-
expect(result.buffer).toBe(buf);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('does not add a new entry to the buffer', () => {
|
|
193
|
-
let buf = createRingBuffer<number>(10);
|
|
194
|
-
buf = push(buf, 1, 'a');
|
|
195
|
-
buf = push(buf, 2, 'b');
|
|
196
|
-
const result = undo(buf);
|
|
197
|
-
expect(result.buffer.entries).toHaveLength(2);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('is immutable — does not mutate input buffer', () => {
|
|
201
|
-
let buf = createRingBuffer<number>(10);
|
|
202
|
-
buf = push(buf, 1, 'a');
|
|
203
|
-
buf = push(buf, 2, 'b');
|
|
204
|
-
const cursorBefore = buf.cursor;
|
|
205
|
-
undo(buf);
|
|
206
|
-
expect(buf.cursor).toBe(cursorBefore);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('can undo multiple times sequentially', () => {
|
|
210
|
-
let buf = createRingBuffer<number>(10);
|
|
211
|
-
buf = push(buf, 10, 'a');
|
|
212
|
-
buf = push(buf, 20, 'b');
|
|
213
|
-
buf = push(buf, 30, 'c');
|
|
214
|
-
const r1 = undo(buf);
|
|
215
|
-
const r2 = undo(r1.buffer);
|
|
216
|
-
expect(r1.state).toBe(20);
|
|
217
|
-
expect(r2.state).toBe(10);
|
|
218
|
-
expect(r2.buffer.cursor).toBe(0);
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
describe('redo', () => {
|
|
223
|
-
it('moves cursor forward and returns next state', () => {
|
|
224
|
-
let buf = createRingBuffer<number>(10);
|
|
225
|
-
buf = push(buf, 1, 'a');
|
|
226
|
-
buf = push(buf, 2, 'b');
|
|
227
|
-
const undone = undo(buf);
|
|
228
|
-
const redone = redo(undone.buffer);
|
|
229
|
-
expect(redone.state).toBe(2);
|
|
230
|
-
expect(redone.buffer.cursor).toBe(1);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it('at head returns null and same buffer reference', () => {
|
|
234
|
-
let buf = createRingBuffer<number>(10);
|
|
235
|
-
buf = push(buf, 1, 'a');
|
|
236
|
-
const result = redo(buf);
|
|
237
|
-
expect(result.state).toBeNull();
|
|
238
|
-
expect(result.buffer).toBe(buf);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('on empty buffer returns null', () => {
|
|
242
|
-
const buf = createRingBuffer<number>(10);
|
|
243
|
-
const result = redo(buf);
|
|
244
|
-
expect(result.state).toBeNull();
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('does not add a new entry to the buffer', () => {
|
|
248
|
-
let buf = createRingBuffer<number>(10);
|
|
249
|
-
buf = push(buf, 1, 'a');
|
|
250
|
-
buf = push(buf, 2, 'b');
|
|
251
|
-
const { buffer: afterUndo } = undo(buf);
|
|
252
|
-
const { buffer: afterRedo } = redo(afterUndo);
|
|
253
|
-
expect(afterRedo.entries).toHaveLength(2);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('is immutable — does not mutate input buffer', () => {
|
|
257
|
-
let buf = createRingBuffer<number>(10);
|
|
258
|
-
buf = push(buf, 1, 'a');
|
|
259
|
-
buf = push(buf, 2, 'b');
|
|
260
|
-
const { buffer: undone } = undo(buf);
|
|
261
|
-
const cursorBefore = undone.cursor;
|
|
262
|
-
redo(undone);
|
|
263
|
-
expect(undone.cursor).toBe(cursorBefore);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('undo then redo returns to original state', () => {
|
|
267
|
-
let buf = createRingBuffer<number>(10);
|
|
268
|
-
buf = push(buf, 1, 'a');
|
|
269
|
-
buf = push(buf, 2, 'b');
|
|
270
|
-
buf = push(buf, 3, 'c');
|
|
271
|
-
buf = undo(buf).buffer;
|
|
272
|
-
buf = undo(buf).buffer;
|
|
273
|
-
buf = redo(buf).buffer;
|
|
274
|
-
buf = redo(buf).buffer;
|
|
275
|
-
expect(buf.cursor).toBe(2);
|
|
276
|
-
expect(buf.entries[buf.cursor].state).toBe(3);
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
describe('canUndo / canRedo', () => {
|
|
281
|
-
it('canUndo is false on empty buffer', () => {
|
|
282
|
-
expect(canUndo(createRingBuffer(10))).toBe(false);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it('canUndo is false with one entry', () => {
|
|
286
|
-
let buf = createRingBuffer<number>(10);
|
|
287
|
-
buf = push(buf, 1, 'a');
|
|
288
|
-
expect(canUndo(buf)).toBe(false);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
it('canUndo is true with two entries', () => {
|
|
292
|
-
let buf = createRingBuffer<number>(10);
|
|
293
|
-
buf = push(buf, 1, 'a');
|
|
294
|
-
buf = push(buf, 2, 'b');
|
|
295
|
-
expect(canUndo(buf)).toBe(true);
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it('canRedo is false on empty buffer', () => {
|
|
299
|
-
expect(canRedo(createRingBuffer(10))).toBe(false);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('canRedo is false at head', () => {
|
|
303
|
-
let buf = createRingBuffer<number>(10);
|
|
304
|
-
buf = push(buf, 1, 'a');
|
|
305
|
-
expect(canRedo(buf)).toBe(false);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it('canRedo is true after undo', () => {
|
|
309
|
-
let buf = createRingBuffer<number>(10);
|
|
310
|
-
buf = push(buf, 1, 'a');
|
|
311
|
-
buf = push(buf, 2, 'b');
|
|
312
|
-
buf = undo(buf).buffer;
|
|
313
|
-
expect(canRedo(buf)).toBe(true);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it('canRedo is false after push clears redo stack', () => {
|
|
317
|
-
let buf = createRingBuffer<number>(10);
|
|
318
|
-
buf = push(buf, 1, 'a');
|
|
319
|
-
buf = push(buf, 2, 'b');
|
|
320
|
-
buf = undo(buf).buffer;
|
|
321
|
-
buf = push(buf, 3, 'c');
|
|
322
|
-
expect(canRedo(buf)).toBe(false);
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
328
|
-
// 2. NAMED SNAPSHOTS — pure logic
|
|
329
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
330
|
-
|
|
331
|
-
describe('Named Snapshots — pure logic', () => {
|
|
332
|
-
|
|
333
|
-
describe('createSnapshotMap', () => {
|
|
334
|
-
it('returns an empty Map', () => {
|
|
335
|
-
const map = createSnapshotMap();
|
|
336
|
-
expect(map.size).toBe(0);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it('returns a new instance each call', () => {
|
|
340
|
-
expect(createSnapshotMap()).not.toBe(createSnapshotMap());
|
|
341
|
-
});
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
describe('saveSnapshot', () => {
|
|
345
|
-
it('saves a snapshot under the given name', () => {
|
|
346
|
-
let map = createSnapshotMap<number>();
|
|
347
|
-
map = saveSnapshot(map, 'v1', 42);
|
|
348
|
-
expect(getSnapshot(map, 'v1')?.state).toBe(42);
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it('records a timestamp', () => {
|
|
352
|
-
const before = Date.now();
|
|
353
|
-
let map = createSnapshotMap<number>();
|
|
354
|
-
map = saveSnapshot(map, 'v1', 1);
|
|
355
|
-
const after = Date.now();
|
|
356
|
-
const ts = getSnapshot(map, 'v1')!.timestamp;
|
|
357
|
-
expect(ts).toBeGreaterThanOrEqual(before);
|
|
358
|
-
expect(ts).toBeLessThanOrEqual(after);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it('overwrites an existing snapshot with the same name', () => {
|
|
362
|
-
let map = createSnapshotMap<number>();
|
|
363
|
-
map = saveSnapshot(map, 'v1', 1);
|
|
364
|
-
map = saveSnapshot(map, 'v1', 99);
|
|
365
|
-
expect(getSnapshot(map, 'v1')?.state).toBe(99);
|
|
366
|
-
expect(map.size).toBe(1);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('is immutable — does not mutate input map', () => {
|
|
370
|
-
const original = createSnapshotMap<number>();
|
|
371
|
-
saveSnapshot(original, 'v1', 1);
|
|
372
|
-
expect(original.size).toBe(0);
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it('stores multiple snapshots independently', () => {
|
|
376
|
-
let map = createSnapshotMap<number>();
|
|
377
|
-
map = saveSnapshot(map, 'a', 1);
|
|
378
|
-
map = saveSnapshot(map, 'b', 2);
|
|
379
|
-
map = saveSnapshot(map, 'c', 3);
|
|
380
|
-
expect(map.size).toBe(3);
|
|
381
|
-
expect(getSnapshot(map, 'b')?.state).toBe(2);
|
|
382
|
-
});
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
describe('getSnapshot', () => {
|
|
386
|
-
it('returns null for unknown name', () => {
|
|
387
|
-
const map = createSnapshotMap<number>();
|
|
388
|
-
expect(getSnapshot(map, 'nope')).toBeNull();
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it('returns the correct entry for a known name', () => {
|
|
392
|
-
let map = createSnapshotMap<{ x: number }>();
|
|
393
|
-
map = saveSnapshot(map, 'test', { x: 5 });
|
|
394
|
-
expect(getSnapshot(map, 'test')?.state).toEqual({ x: 5 });
|
|
395
|
-
});
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
describe('deleteSnapshot', () => {
|
|
399
|
-
it('removes the named snapshot', () => {
|
|
400
|
-
let map = createSnapshotMap<number>();
|
|
401
|
-
map = saveSnapshot(map, 'del', 1);
|
|
402
|
-
map = deleteSnapshot(map, 'del');
|
|
403
|
-
expect(getSnapshot(map, 'del')).toBeNull();
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
it('is a no-op for unknown name', () => {
|
|
407
|
-
let map = createSnapshotMap<number>();
|
|
408
|
-
map = saveSnapshot(map, 'keep', 1);
|
|
409
|
-
map = deleteSnapshot(map, 'unknown');
|
|
410
|
-
expect(map.size).toBe(1);
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
it('is immutable — does not mutate input map', () => {
|
|
414
|
-
let map = createSnapshotMap<number>();
|
|
415
|
-
map = saveSnapshot(map, 'del', 1);
|
|
416
|
-
const before = map.size;
|
|
417
|
-
deleteSnapshot(map, 'del');
|
|
418
|
-
expect(map.size).toBe(before);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it('does not affect other snapshots', () => {
|
|
422
|
-
let map = createSnapshotMap<number>();
|
|
423
|
-
map = saveSnapshot(map, 'a', 1);
|
|
424
|
-
map = saveSnapshot(map, 'b', 2);
|
|
425
|
-
map = deleteSnapshot(map, 'a');
|
|
426
|
-
expect(getSnapshot(map, 'b')?.state).toBe(2);
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
describe('listSnapshots', () => {
|
|
431
|
-
it('returns empty array for empty map', () => {
|
|
432
|
-
expect(listSnapshots(createSnapshotMap())).toEqual([]);
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
it('returns all snapshot names', () => {
|
|
436
|
-
let map = createSnapshotMap<number>();
|
|
437
|
-
map = saveSnapshot(map, 'x', 1);
|
|
438
|
-
map = saveSnapshot(map, 'y', 2);
|
|
439
|
-
const names = listSnapshots(map);
|
|
440
|
-
expect(names).toContain('x');
|
|
441
|
-
expect(names).toContain('y');
|
|
442
|
-
expect(names).toHaveLength(2);
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
it('does not include deleted names', () => {
|
|
446
|
-
let map = createSnapshotMap<number>();
|
|
447
|
-
map = saveSnapshot(map, 'gone', 1);
|
|
448
|
-
map = saveSnapshot(map, 'stay', 2);
|
|
449
|
-
map = deleteSnapshot(map, 'gone');
|
|
450
|
-
expect(listSnapshots(map)).toEqual(['stay']);
|
|
451
|
-
});
|
|
452
|
-
});
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
456
|
-
// 3. withDevtools — store integration
|
|
457
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
458
|
-
|
|
459
|
-
describe('withDevtools — store integration', () => {
|
|
460
|
-
|
|
461
|
-
describe('setup', () => {
|
|
462
|
-
it('attaches __devtools internals to the store', () => {
|
|
463
|
-
const store = makeStore();
|
|
464
|
-
expect(internals(store)).toBeDefined();
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
it('captures initialState at wrap time', () => {
|
|
468
|
-
const store = makeStore();
|
|
469
|
-
expect(internals(store).initialState).toEqual({ count: 0, label: 'init' });
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
it('does not expose __devtools on public types', () => {
|
|
473
|
-
const store = makeStore();
|
|
474
|
-
// history and snapshots are exposed via getters, not __devtools
|
|
475
|
-
expect(typeof (store as unknown as Record<string, unknown>).history).toBe('object');
|
|
476
|
-
expect(typeof (store as unknown as Record<string, unknown>).snapshots).toBe('object');
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
it('enabled: false leaves store untouched', () => {
|
|
480
|
-
const store = makeStore({ enabled: false });
|
|
481
|
-
expect(internals(store)).toBeUndefined();
|
|
482
|
-
expect((store as unknown as Record<string, unknown>).undo).toBeUndefined();
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
it('respects custom maxHistory', () => {
|
|
486
|
-
const store = makeStore({ maxHistory: 5 });
|
|
487
|
-
expect(internals(store).buffer.capacity).toBe(5);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
it('starts with empty history', () => {
|
|
491
|
-
const store = makeStore();
|
|
492
|
-
expect(historyOf(store)).toHaveLength(0);
|
|
493
|
-
});
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
describe('history tracking via setState', () => {
|
|
497
|
-
it('pushes an entry on each setState', () => {
|
|
498
|
-
const store = makeStore();
|
|
499
|
-
store.setState({ count: 1 });
|
|
500
|
-
store.setState({ count: 2 });
|
|
501
|
-
expect(historyOf(store)).toHaveLength(2);
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
it('captures correct state in each entry', () => {
|
|
505
|
-
const store = makeStore();
|
|
506
|
-
store.setState({ count: 1 });
|
|
507
|
-
store.setState({ count: 2 });
|
|
508
|
-
const h = historyOf(store);
|
|
509
|
-
expect(h[0].state.count).toBe(1);
|
|
510
|
-
expect(h[1].state.count).toBe(2);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
it('uses setState as default actionName', () => {
|
|
514
|
-
const store = makeStore();
|
|
515
|
-
store.setState({ count: 1 });
|
|
516
|
-
expect(historyOf(store)[0].actionName).toBe('setState');
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
it('respects maxHistory capacity', () => {
|
|
520
|
-
const store = makeStore({ maxHistory: 3 });
|
|
521
|
-
store.setState({ count: 1 });
|
|
522
|
-
store.setState({ count: 2 });
|
|
523
|
-
store.setState({ count: 3 });
|
|
524
|
-
store.setState({ count: 4 });
|
|
525
|
-
expect(historyOf(store)).toHaveLength(3);
|
|
526
|
-
expect(historyOf(store)[0].state.count).toBe(2);
|
|
527
|
-
});
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
describe('undo / redo', () => {
|
|
531
|
-
it('undo restores previous state', () => {
|
|
532
|
-
const store = makeStore();
|
|
533
|
-
store.setState({ count: 1 });
|
|
534
|
-
store.setState({ count: 2 });
|
|
535
|
-
store.undo();
|
|
536
|
-
expect(store.getState().count).toBe(1);
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
it('redo reapplies undone state', () => {
|
|
540
|
-
const store = makeStore();
|
|
541
|
-
store.setState({ count: 1 });
|
|
542
|
-
store.setState({ count: 2 });
|
|
543
|
-
store.undo();
|
|
544
|
-
store.redo();
|
|
545
|
-
expect(store.getState().count).toBe(2);
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
it('undo at start is a no-op', () => {
|
|
549
|
-
const store = makeStore();
|
|
550
|
-
store.setState({ count: 1 });
|
|
551
|
-
store.undo(); // moves to cursor 0 — but cursor 0 is the only entry, so no-op
|
|
552
|
-
expect(store.getState().count).toBe(1);
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
it('redo at head is a no-op', () => {
|
|
556
|
-
const store = makeStore();
|
|
557
|
-
store.setState({ count: 1 });
|
|
558
|
-
store.redo();
|
|
559
|
-
expect(store.getState().count).toBe(1);
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
it('undo does not push to history', () => {
|
|
563
|
-
const store = makeStore();
|
|
564
|
-
store.setState({ count: 1 });
|
|
565
|
-
store.setState({ count: 2 });
|
|
566
|
-
store.undo();
|
|
567
|
-
expect(historyOf(store)).toHaveLength(2);
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
it('redo does not push to history', () => {
|
|
571
|
-
const store = makeStore();
|
|
572
|
-
store.setState({ count: 1 });
|
|
573
|
-
store.setState({ count: 2 });
|
|
574
|
-
store.undo();
|
|
575
|
-
store.redo();
|
|
576
|
-
expect(historyOf(store)).toHaveLength(2);
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
it('setState after undo clears redo stack', () => {
|
|
580
|
-
const store = makeStore();
|
|
581
|
-
store.setState({ count: 1 });
|
|
582
|
-
store.setState({ count: 2 });
|
|
583
|
-
store.undo();
|
|
584
|
-
store.setState({ count: 3 });
|
|
585
|
-
expect(store.canRedo).toBe(false);
|
|
586
|
-
expect(historyOf(store)).toHaveLength(2);
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
it('canUndo is false with no history', () => {
|
|
590
|
-
const store = makeStore();
|
|
591
|
-
expect(store.canUndo).toBe(false);
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
it('canUndo is false with one entry', () => {
|
|
595
|
-
const store = makeStore();
|
|
596
|
-
store.setState({ count: 1 });
|
|
597
|
-
expect(store.canUndo).toBe(false);
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
it('canUndo is true with two or more entries', () => {
|
|
601
|
-
const store = makeStore();
|
|
602
|
-
store.setState({ count: 1 });
|
|
603
|
-
store.setState({ count: 2 });
|
|
604
|
-
expect(store.canUndo).toBe(true);
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
it('canRedo is true after undo', () => {
|
|
608
|
-
const store = makeStore();
|
|
609
|
-
store.setState({ count: 1 });
|
|
610
|
-
store.setState({ count: 2 });
|
|
611
|
-
store.undo();
|
|
612
|
-
expect(store.canRedo).toBe(true);
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
it('canRedo is false after redo returns to head', () => {
|
|
616
|
-
const store = makeStore();
|
|
617
|
-
store.setState({ count: 1 });
|
|
618
|
-
store.setState({ count: 2 });
|
|
619
|
-
store.undo();
|
|
620
|
-
store.redo();
|
|
621
|
-
expect(store.canRedo).toBe(false);
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
it('multiple undos then multiple redos', () => {
|
|
625
|
-
const store = makeStore();
|
|
626
|
-
store.setState({ count: 1 });
|
|
627
|
-
store.setState({ count: 2 });
|
|
628
|
-
store.setState({ count: 3 });
|
|
629
|
-
store.undo();
|
|
630
|
-
store.undo();
|
|
631
|
-
expect(store.getState().count).toBe(1);
|
|
632
|
-
store.redo();
|
|
633
|
-
store.redo();
|
|
634
|
-
expect(store.getState().count).toBe(3);
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
it('notifies subscribers on undo', () => {
|
|
638
|
-
const store = makeStore();
|
|
639
|
-
store.setState({ count: 1 });
|
|
640
|
-
store.setState({ count: 2 });
|
|
641
|
-
const listener = vi.fn();
|
|
642
|
-
store.subscribe(listener);
|
|
643
|
-
store.undo();
|
|
644
|
-
expect(listener).toHaveBeenCalledOnce();
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
it('notifies subscribers on redo', () => {
|
|
648
|
-
const store = makeStore();
|
|
649
|
-
store.setState({ count: 1 });
|
|
650
|
-
store.setState({ count: 2 });
|
|
651
|
-
store.undo();
|
|
652
|
-
const listener = vi.fn();
|
|
653
|
-
store.subscribe(listener);
|
|
654
|
-
store.redo();
|
|
655
|
-
expect(listener).toHaveBeenCalledOnce();
|
|
656
|
-
});
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
describe('snapshot / restore', () => {
|
|
660
|
-
it('snapshot saves current state', () => {
|
|
661
|
-
const store = makeStore();
|
|
662
|
-
store.setState({ count: 5 });
|
|
663
|
-
store.snapshot('s1');
|
|
664
|
-
expect(snapshotsOf(store)).toContain('s1');
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
it('restore applies the saved state', () => {
|
|
668
|
-
const store = makeStore();
|
|
669
|
-
store.setState({ count: 5 });
|
|
670
|
-
store.snapshot('before');
|
|
671
|
-
store.setState({ count: 99 });
|
|
672
|
-
store.restore('before');
|
|
673
|
-
expect(store.getState().count).toBe(5);
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
it('restore pushes to history', () => {
|
|
677
|
-
const store = makeStore();
|
|
678
|
-
store.setState({ count: 1 });
|
|
679
|
-
store.snapshot('s1');
|
|
680
|
-
store.setState({ count: 2 });
|
|
681
|
-
const lenBefore = historyOf(store).length;
|
|
682
|
-
store.restore('s1');
|
|
683
|
-
expect(historyOf(store).length).toBe(lenBefore + 1);
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
it("restore uses actionName restore('<name>')", () => {
|
|
687
|
-
const store = makeStore();
|
|
688
|
-
store.setState({ count: 1 });
|
|
689
|
-
store.snapshot('my-snap');
|
|
690
|
-
store.restore('my-snap');
|
|
691
|
-
const h = historyOf(store);
|
|
692
|
-
expect(h[h.length - 1].actionName).toBe("restore('my-snap')");
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
it('restore for unknown name throws with clear message', () => {
|
|
696
|
-
const store = makeStore();
|
|
697
|
-
expect(() => store.restore('ghost')).toThrow(/not found/i);
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
it('restore is itself undoable', () => {
|
|
701
|
-
const store = makeStore();
|
|
702
|
-
store.setState({ count: 1 });
|
|
703
|
-
store.snapshot('checkpoint');
|
|
704
|
-
store.setState({ count: 2 });
|
|
705
|
-
store.restore('checkpoint'); // count → 1, pushed to history
|
|
706
|
-
store.undo(); // undo the restore → count back to 2
|
|
707
|
-
expect(store.getState().count).toBe(2);
|
|
708
|
-
});
|
|
709
|
-
|
|
710
|
-
it('multiple snapshots are stored independently', () => {
|
|
711
|
-
const store = makeStore();
|
|
712
|
-
store.setState({ count: 1 });
|
|
713
|
-
store.snapshot('a');
|
|
714
|
-
store.setState({ count: 2 });
|
|
715
|
-
store.snapshot('b');
|
|
716
|
-
store.restore('a');
|
|
717
|
-
expect(store.getState().count).toBe(1);
|
|
718
|
-
store.restore('b');
|
|
719
|
-
expect(store.getState().count).toBe(2);
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
it('overwriting a snapshot updates the saved state', () => {
|
|
723
|
-
const store = makeStore();
|
|
724
|
-
store.setState({ count: 1 });
|
|
725
|
-
store.snapshot('s');
|
|
726
|
-
store.setState({ count: 2 });
|
|
727
|
-
store.snapshot('s'); // overwrite
|
|
728
|
-
store.setState({ count: 99 });
|
|
729
|
-
store.restore('s');
|
|
730
|
-
expect(store.getState().count).toBe(2);
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
it('snapshots survive clearHistory', () => {
|
|
734
|
-
const store = makeStore();
|
|
735
|
-
store.setState({ count: 1 });
|
|
736
|
-
store.snapshot('keep');
|
|
737
|
-
store.setState({ count: 2 });
|
|
738
|
-
(store as unknown as Record<string, () => void>).clearHistory();
|
|
739
|
-
expect(snapshotsOf(store)).toContain('keep');
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
it('deleteSnapshot removes the snapshot', () => {
|
|
743
|
-
const store = makeStore();
|
|
744
|
-
store.setState({ count: 1 });
|
|
745
|
-
store.snapshot('del');
|
|
746
|
-
(store as unknown as Record<string, (n: string) => void>).deleteSnapshot('del');
|
|
747
|
-
expect(snapshotsOf(store)).not.toContain('del');
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
it('deleteSnapshot does not affect other snapshots', () => {
|
|
751
|
-
const store = makeStore();
|
|
752
|
-
store.setState({ count: 1 });
|
|
753
|
-
store.snapshot('a');
|
|
754
|
-
store.snapshot('b');
|
|
755
|
-
(store as unknown as Record<string, (n: string) => void>).deleteSnapshot('a');
|
|
756
|
-
expect(snapshotsOf(store)).toContain('b');
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
it('restoring a deleted snapshot throws', () => {
|
|
760
|
-
const store = makeStore();
|
|
761
|
-
store.setState({ count: 1 });
|
|
762
|
-
store.snapshot('gone');
|
|
763
|
-
(store as unknown as Record<string, (n: string) => void>).deleteSnapshot('gone');
|
|
764
|
-
expect(() => store.restore('gone')).toThrow(/not found/i);
|
|
765
|
-
});
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
describe('clearHistory', () => {
|
|
769
|
-
it('wipes the ring buffer', () => {
|
|
770
|
-
const store = makeStore();
|
|
771
|
-
store.setState({ count: 1 });
|
|
772
|
-
store.setState({ count: 2 });
|
|
773
|
-
(store as unknown as Record<string, () => void>).clearHistory();
|
|
774
|
-
expect(historyOf(store)).toHaveLength(0);
|
|
775
|
-
});
|
|
776
|
-
|
|
777
|
-
it('does not clear snapshots', () => {
|
|
778
|
-
const store = makeStore();
|
|
779
|
-
store.snapshot('keep');
|
|
780
|
-
store.setState({ count: 1 });
|
|
781
|
-
(store as unknown as Record<string, () => void>).clearHistory();
|
|
782
|
-
expect(snapshotsOf(store)).toContain('keep');
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
it('canUndo is false after clearHistory', () => {
|
|
786
|
-
const store = makeStore();
|
|
787
|
-
store.setState({ count: 1 });
|
|
788
|
-
store.setState({ count: 2 });
|
|
789
|
-
(store as unknown as Record<string, () => void>).clearHistory();
|
|
790
|
-
expect(store.canUndo).toBe(false);
|
|
791
|
-
});
|
|
792
|
-
|
|
793
|
-
it('canRedo is false after clearHistory', () => {
|
|
794
|
-
const store = makeStore();
|
|
795
|
-
store.setState({ count: 1 });
|
|
796
|
-
store.setState({ count: 2 });
|
|
797
|
-
store.undo();
|
|
798
|
-
(store as unknown as Record<string, () => void>).clearHistory();
|
|
799
|
-
expect(store.canRedo).toBe(false);
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
it('setState after clearHistory starts fresh history', () => {
|
|
803
|
-
const store = makeStore();
|
|
804
|
-
store.setState({ count: 1 });
|
|
805
|
-
store.setState({ count: 2 });
|
|
806
|
-
(store as unknown as Record<string, () => void>).clearHistory();
|
|
807
|
-
store.setState({ count: 3 });
|
|
808
|
-
expect(historyOf(store)).toHaveLength(1);
|
|
809
|
-
expect(historyOf(store)[0].state.count).toBe(3);
|
|
810
|
-
});
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
describe('internal update guard', () => {
|
|
814
|
-
it('_applySnapshot does not push to history', () => {
|
|
815
|
-
const store = makeStore();
|
|
816
|
-
store.setState({ count: 1 });
|
|
817
|
-
const lenBefore = historyOf(store).length;
|
|
818
|
-
internals(store)._applySnapshot({ count: 99, label: 'x' });
|
|
819
|
-
expect(historyOf(store).length).toBe(lenBefore);
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
it('snapshot() does not push to history', () => {
|
|
823
|
-
const store = makeStore();
|
|
824
|
-
store.setState({ count: 1 });
|
|
825
|
-
const lenBefore = historyOf(store).length;
|
|
826
|
-
store.snapshot('test');
|
|
827
|
-
expect(historyOf(store).length).toBe(lenBefore);
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
it('deleteSnapshot() does not push to history', () => {
|
|
831
|
-
const store = makeStore();
|
|
832
|
-
store.snapshot('del');
|
|
833
|
-
store.setState({ count: 1 });
|
|
834
|
-
const lenBefore = historyOf(store).length;
|
|
835
|
-
(store as unknown as Record<string, (n: string) => void>).deleteSnapshot('del');
|
|
836
|
-
expect(historyOf(store).length).toBe(lenBefore);
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
it('clearHistory() itself does not push to history', () => {
|
|
840
|
-
const store = makeStore();
|
|
841
|
-
store.setState({ count: 1 });
|
|
842
|
-
(store as unknown as Record<string, () => void>).clearHistory();
|
|
843
|
-
expect(historyOf(store)).toHaveLength(0);
|
|
844
|
-
});
|
|
845
|
-
});
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
849
|
-
// 4. REDUX BRIDGE
|
|
850
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
851
|
-
|
|
852
|
-
describe('Redux Bridge', () => {
|
|
853
|
-
let connectMock: ReturnType<typeof vi.fn>;
|
|
854
|
-
let devtoolsInstance: { init: (s: unknown) => void; send: (a: unknown, s: unknown) => void; subscribe: (h: unknown) => (() => void) };
|
|
855
|
-
let messageHandler: (msg: { type: string; payload?: { type: string }; state?: string }) => void;
|
|
856
|
-
|
|
857
|
-
beforeEach(() => {
|
|
858
|
-
devtoolsInstance = {
|
|
859
|
-
init: vi.fn(),
|
|
860
|
-
send: vi.fn(),
|
|
861
|
-
subscribe: vi.fn((h: (msg: { type: string; payload?: { type: string }; state?: string }) => void) => { messageHandler = h; return vi.fn(); }),
|
|
862
|
-
};
|
|
863
|
-
connectMock = vi.fn().mockReturnValue(devtoolsInstance);
|
|
864
|
-
vi.stubGlobal('window', {
|
|
865
|
-
__REDUX_DEVTOOLS_EXTENSION__: { connect: connectMock },
|
|
866
|
-
});
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
afterEach(() => {
|
|
870
|
-
vi.unstubAllGlobals();
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
it('connects with formatted store name', () => {
|
|
874
|
-
makeStore({ name: 'CartStore' });
|
|
875
|
-
expect(connectMock).toHaveBeenCalledWith(
|
|
876
|
-
expect.objectContaining({ name: expect.stringContaining('CartStore') })
|
|
877
|
-
);
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
it('calls init with initial state', () => {
|
|
881
|
-
makeStore();
|
|
882
|
-
expect(devtoolsInstance.init).toHaveBeenCalledWith({ count: 0, label: 'init' });
|
|
883
|
-
});
|
|
884
|
-
|
|
885
|
-
it('sends an action to DevTools on setState', () => {
|
|
886
|
-
const store = makeStore();
|
|
887
|
-
store.setState({ count: 1 });
|
|
888
|
-
expect(devtoolsInstance.send).toHaveBeenCalledWith(
|
|
889
|
-
expect.objectContaining({ type: expect.any(String) }),
|
|
890
|
-
expect.objectContaining({ count: 1 })
|
|
891
|
-
);
|
|
892
|
-
});
|
|
893
|
-
|
|
894
|
-
it('JUMP_TO_STATE applies the given state', () => {
|
|
895
|
-
const store = makeStore();
|
|
896
|
-
store.setState({ count: 10 });
|
|
897
|
-
const targetState = JSON.stringify({ count: 3, label: 'jumped' });
|
|
898
|
-
messageHandler({
|
|
899
|
-
type: 'DISPATCH',
|
|
900
|
-
payload: { type: 'JUMP_TO_STATE' },
|
|
901
|
-
state: targetState,
|
|
902
|
-
});
|
|
903
|
-
expect(store.getState().count).toBe(3);
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
it('JUMP_TO_STATE does not push to history', () => {
|
|
907
|
-
const store = makeStore();
|
|
908
|
-
store.setState({ count: 1 });
|
|
909
|
-
const lenBefore = historyOf(store).length;
|
|
910
|
-
messageHandler({
|
|
911
|
-
type: 'DISPATCH',
|
|
912
|
-
payload: { type: 'JUMP_TO_STATE' },
|
|
913
|
-
state: JSON.stringify({ count: 99, label: 'x' }),
|
|
914
|
-
});
|
|
915
|
-
expect(historyOf(store).length).toBe(lenBefore);
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
it('RESET restores initial state', () => {
|
|
919
|
-
const store = makeStore();
|
|
920
|
-
store.setState({ count: 99 });
|
|
921
|
-
messageHandler({ type: 'DISPATCH', payload: { type: 'RESET' } });
|
|
922
|
-
expect(store.getState()).toEqual({ count: 0, label: 'init' });
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
it('RESET does not push to history', () => {
|
|
926
|
-
const store = makeStore();
|
|
927
|
-
store.setState({ count: 5 });
|
|
928
|
-
const lenBefore = historyOf(store).length;
|
|
929
|
-
messageHandler({ type: 'DISPATCH', payload: { type: 'RESET' } });
|
|
930
|
-
expect(historyOf(store).length).toBe(lenBefore);
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
it('unknown DISPATCH payload type is a no-op', () => {
|
|
934
|
-
const store = makeStore();
|
|
935
|
-
store.setState({ count: 5 });
|
|
936
|
-
expect(() => {
|
|
937
|
-
messageHandler({ type: 'DISPATCH', payload: { type: 'TOTALLY_UNKNOWN' } });
|
|
938
|
-
}).not.toThrow();
|
|
939
|
-
expect(store.getState().count).toBe(5);
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
it('non-DISPATCH message type is a no-op', () => {
|
|
943
|
-
const store = makeStore();
|
|
944
|
-
store.setState({ count: 5 });
|
|
945
|
-
expect(() => {
|
|
946
|
-
messageHandler({ type: 'START' });
|
|
947
|
-
}).not.toThrow();
|
|
948
|
-
expect(store.getState().count).toBe(5);
|
|
949
|
-
});
|
|
950
|
-
|
|
951
|
-
it('is SSR safe — no crash when window is undefined', () => {
|
|
952
|
-
vi.stubGlobal('window', undefined);
|
|
953
|
-
expect(() => makeStore()).not.toThrow();
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
it('is SSR safe — no crash when extension is not installed', () => {
|
|
957
|
-
vi.stubGlobal('window', {});
|
|
958
|
-
expect(() => makeStore()).not.toThrow();
|
|
959
|
-
});
|
|
960
|
-
|
|
961
|
-
it('does not connect when enabled: false', () => {
|
|
962
|
-
makeStore({ enabled: false });
|
|
963
|
-
expect(connectMock).not.toHaveBeenCalled();
|
|
964
|
-
});
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
968
|
-
// 5. EDGE CASES & STRESS
|
|
969
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
970
|
-
|
|
971
|
-
describe('Edge Cases & Stress', () => {
|
|
972
|
-
|
|
973
|
-
it('handles rapid successive setStates without corruption', () => {
|
|
974
|
-
const store = makeStore({ maxHistory: 10 });
|
|
975
|
-
for (let i = 1; i <= 20; i++) store.setState({ count: i });
|
|
976
|
-
expect(historyOf(store)).toHaveLength(10);
|
|
977
|
-
expect(store.getState().count).toBe(20);
|
|
978
|
-
});
|
|
979
|
-
|
|
980
|
-
it('undo all the way back to first entry', () => {
|
|
981
|
-
const store = makeStore({ maxHistory: 5 });
|
|
982
|
-
store.setState({ count: 1 });
|
|
983
|
-
store.setState({ count: 2 });
|
|
984
|
-
store.setState({ count: 3 });
|
|
985
|
-
store.undo();
|
|
986
|
-
store.undo();
|
|
987
|
-
// cursor is at 0, canUndo should be false
|
|
988
|
-
expect(store.canUndo).toBe(false);
|
|
989
|
-
expect(store.getState().count).toBe(1);
|
|
990
|
-
});
|
|
991
|
-
|
|
992
|
-
it('undo/redo interleaved with new setStates', () => {
|
|
993
|
-
const store = makeStore();
|
|
994
|
-
store.setState({ count: 1 });
|
|
995
|
-
store.setState({ count: 2 });
|
|
996
|
-
store.undo(); // back to 1
|
|
997
|
-
store.setState({ count: 3 }); // clears redo, history: [1, 3]
|
|
998
|
-
store.undo(); // back to 1
|
|
999
|
-
expect(store.getState().count).toBe(1);
|
|
1000
|
-
expect(store.canRedo).toBe(true);
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
|
-
it('snapshot saved before any setState can still be restored', () => {
|
|
1004
|
-
const store = makeStore();
|
|
1005
|
-
store.snapshot('empty');
|
|
1006
|
-
store.setState({ count: 5 });
|
|
1007
|
-
store.restore('empty');
|
|
1008
|
-
expect(store.getState().count).toBe(0);
|
|
1009
|
-
});
|
|
1010
|
-
|
|
1011
|
-
it('full cycle: setState → snapshot → mutate → restore → undo restore', () => {
|
|
1012
|
-
const store = makeStore();
|
|
1013
|
-
store.setState({ count: 10 });
|
|
1014
|
-
store.snapshot('ten');
|
|
1015
|
-
store.setState({ count: 20 });
|
|
1016
|
-
store.restore('ten'); // count = 10, pushed to history
|
|
1017
|
-
expect(store.getState().count).toBe(10);
|
|
1018
|
-
store.undo(); // undo restore → count = 20
|
|
1019
|
-
expect(store.getState().count).toBe(20);
|
|
1020
|
-
});
|
|
1021
|
-
|
|
1022
|
-
it('two independent stores do not share history', () => {
|
|
1023
|
-
const a = makeStore({ name: 'A' });
|
|
1024
|
-
const b = makeStore({ name: 'B' });
|
|
1025
|
-
a.setState({ count: 1 });
|
|
1026
|
-
a.setState({ count: 2 });
|
|
1027
|
-
expect(historyOf(b)).toHaveLength(0);
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
it('history entries are readonly — mutations do not affect internal buffer', () => {
|
|
1031
|
-
const store = makeStore();
|
|
1032
|
-
store.setState({ count: 1 });
|
|
1033
|
-
const h = historyOf(store);
|
|
1034
|
-
// Attempt to mutate the returned array
|
|
1035
|
-
(h as unknown as Array<HistoryEntry<object>>).push({ state: { count: 999 }, timestamp: 0, actionName: 'injected' });
|
|
1036
|
-
// Internal buffer should be unaffected
|
|
1037
|
-
expect(historyOf(store)).toHaveLength(1);
|
|
1038
|
-
});
|
|
1039
|
-
});
|