@pyreon/state-tree 0.10.0 → 0.11.0
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/lib/devtools.js.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/types/devtools.d.ts.map +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +11 -6
- package/src/devtools.ts +2 -4
- package/src/index.ts +6 -6
- package/src/instance.ts +11 -17
- package/src/middleware.ts +4 -8
- package/src/model.ts +8 -18
- package/src/patch.ts +22 -39
- package/src/registry.ts +2 -6
- package/src/snapshot.ts +7 -11
- package/src/tests/devtools.test.ts +43 -43
- package/src/tests/edge-cases.test.ts +715 -0
- package/src/tests/model.test.ts +161 -167
- package/src/types.ts +5 -9
package/src/tests/model.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { computed, effect } from
|
|
2
|
-
import type { Patch } from
|
|
1
|
+
import { computed, effect } from "@pyreon/reactivity"
|
|
2
|
+
import type { Patch } from "../index"
|
|
3
3
|
import {
|
|
4
4
|
addMiddleware,
|
|
5
5
|
applyPatch,
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
onPatch,
|
|
10
10
|
resetAllHooks,
|
|
11
11
|
resetHook,
|
|
12
|
-
} from
|
|
13
|
-
import { instanceMeta } from
|
|
12
|
+
} from "../index"
|
|
13
|
+
import { instanceMeta } from "../registry"
|
|
14
14
|
|
|
15
15
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
16
16
|
|
|
@@ -29,7 +29,7 @@ const Counter = model({
|
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
const Profile = model({
|
|
32
|
-
state: { name:
|
|
32
|
+
state: { name: "", bio: "" },
|
|
33
33
|
actions: (self) => ({
|
|
34
34
|
rename: (n: string) => self.name.set(n),
|
|
35
35
|
setBio: (b: string) => self.bio.set(b),
|
|
@@ -37,7 +37,7 @@ const Profile = model({
|
|
|
37
37
|
})
|
|
38
38
|
|
|
39
39
|
const App = model({
|
|
40
|
-
state: { profile: Profile, title:
|
|
40
|
+
state: { profile: Profile, title: "My App" },
|
|
41
41
|
actions: (self) => ({
|
|
42
42
|
setTitle: (t: string) => self.title.set(t),
|
|
43
43
|
}),
|
|
@@ -45,54 +45,54 @@ const App = model({
|
|
|
45
45
|
|
|
46
46
|
// ─── State signals ─────────────────────────────────────────────────────────────
|
|
47
47
|
|
|
48
|
-
describe(
|
|
49
|
-
it(
|
|
48
|
+
describe("state signals", () => {
|
|
49
|
+
it("create() returns instance with callable signals", () => {
|
|
50
50
|
const c = Counter.create()
|
|
51
|
-
expect(typeof c.count).toBe(
|
|
51
|
+
expect(typeof c.count).toBe("function")
|
|
52
52
|
expect(c.count()).toBe(0)
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
-
it(
|
|
55
|
+
it("uses defaults when no initial value supplied", () => {
|
|
56
56
|
const c = Counter.create()
|
|
57
57
|
expect(c.count()).toBe(0)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
-
it(
|
|
60
|
+
it("overrides defaults with supplied initial values", () => {
|
|
61
61
|
const c = Counter.create({ count: 42 })
|
|
62
62
|
expect(c.count()).toBe(42)
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
it(
|
|
66
|
-
const NamedCounter = model({ state: { count: 0, label:
|
|
65
|
+
it("partial initial — unspecified keys use defaults", () => {
|
|
66
|
+
const NamedCounter = model({ state: { count: 0, label: "default" } })
|
|
67
67
|
const c = NamedCounter.create({ count: 10 })
|
|
68
68
|
expect(c.count()).toBe(10)
|
|
69
|
-
expect(c.label()).toBe(
|
|
69
|
+
expect(c.label()).toBe("default")
|
|
70
70
|
})
|
|
71
71
|
})
|
|
72
72
|
|
|
73
73
|
// ─── Actions ──────────────────────────────────────────────────────────────────
|
|
74
74
|
|
|
75
|
-
describe(
|
|
76
|
-
it(
|
|
75
|
+
describe("actions", () => {
|
|
76
|
+
it("actions update state signals", () => {
|
|
77
77
|
const c = Counter.create()
|
|
78
78
|
c.inc()
|
|
79
79
|
expect(c.count()).toBe(1)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
it(
|
|
82
|
+
it("actions with arguments work correctly", () => {
|
|
83
83
|
const c = Counter.create()
|
|
84
84
|
c.add(5)
|
|
85
85
|
expect(c.count()).toBe(5)
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
-
it(
|
|
88
|
+
it("self closure allows reading current signal values", () => {
|
|
89
89
|
const c = Counter.create({ count: 3 })
|
|
90
90
|
c.inc()
|
|
91
91
|
c.inc()
|
|
92
92
|
expect(c.count()).toBe(5)
|
|
93
93
|
})
|
|
94
94
|
|
|
95
|
-
it(
|
|
95
|
+
it("actions can call other actions via self (Proxy)", () => {
|
|
96
96
|
const M = model({
|
|
97
97
|
state: { x: 0 },
|
|
98
98
|
actions: (self) => ({
|
|
@@ -111,20 +111,20 @@ describe('actions', () => {
|
|
|
111
111
|
|
|
112
112
|
// ─── Views ────────────────────────────────────────────────────────────────────
|
|
113
113
|
|
|
114
|
-
describe(
|
|
115
|
-
it(
|
|
114
|
+
describe("views", () => {
|
|
115
|
+
it("views return computed signals", () => {
|
|
116
116
|
const c = Counter.create({ count: 5 })
|
|
117
117
|
expect(c.doubled()).toBe(10)
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
-
it(
|
|
120
|
+
it("views recompute when state changes", () => {
|
|
121
121
|
const c = Counter.create({ count: 3 })
|
|
122
122
|
expect(c.doubled()).toBe(6)
|
|
123
123
|
c.inc()
|
|
124
124
|
expect(c.doubled()).toBe(8)
|
|
125
125
|
})
|
|
126
126
|
|
|
127
|
-
it(
|
|
127
|
+
it("views are reactive in effects", () => {
|
|
128
128
|
const c = Counter.create()
|
|
129
129
|
const observed: boolean[] = []
|
|
130
130
|
effect(() => {
|
|
@@ -138,34 +138,34 @@ describe('views', () => {
|
|
|
138
138
|
|
|
139
139
|
// ─── asHook ───────────────────────────────────────────────────────────────────
|
|
140
140
|
|
|
141
|
-
describe(
|
|
141
|
+
describe("asHook", () => {
|
|
142
142
|
afterEach(() => resetAllHooks())
|
|
143
143
|
|
|
144
|
-
it(
|
|
145
|
-
const useC = Counter.asHook(
|
|
144
|
+
it("returns the same instance for the same id", () => {
|
|
145
|
+
const useC = Counter.asHook("hook-test")
|
|
146
146
|
expect(useC()).toBe(useC())
|
|
147
147
|
})
|
|
148
148
|
|
|
149
|
-
it(
|
|
150
|
-
const useA = Counter.asHook(
|
|
151
|
-
const useB = Counter.asHook(
|
|
149
|
+
it("different ids give independent instances", () => {
|
|
150
|
+
const useA = Counter.asHook("hook-a")
|
|
151
|
+
const useB = Counter.asHook("hook-b")
|
|
152
152
|
useA().inc()
|
|
153
153
|
expect(useA().count()).toBe(1)
|
|
154
154
|
expect(useB().count()).toBe(0)
|
|
155
155
|
})
|
|
156
156
|
|
|
157
|
-
it(
|
|
158
|
-
const useC = Counter.asHook(
|
|
157
|
+
it("resetHook clears a singleton so next call creates fresh instance", () => {
|
|
158
|
+
const useC = Counter.asHook("hook-reset")
|
|
159
159
|
useC().add(10)
|
|
160
160
|
expect(useC().count()).toBe(10)
|
|
161
161
|
|
|
162
|
-
resetHook(
|
|
162
|
+
resetHook("hook-reset")
|
|
163
163
|
expect(useC().count()).toBe(0)
|
|
164
164
|
})
|
|
165
165
|
|
|
166
|
-
it(
|
|
167
|
-
const useA = Counter.asHook(
|
|
168
|
-
const useB = Counter.asHook(
|
|
166
|
+
it("resetAllHooks clears all singletons", () => {
|
|
167
|
+
const useA = Counter.asHook("hook-all-a")
|
|
168
|
+
const useB = Counter.asHook("hook-all-b")
|
|
169
169
|
useA().inc()
|
|
170
170
|
useB().add(5)
|
|
171
171
|
|
|
@@ -174,46 +174,42 @@ describe('asHook', () => {
|
|
|
174
174
|
expect(useB().count()).toBe(0)
|
|
175
175
|
})
|
|
176
176
|
|
|
177
|
-
it(
|
|
178
|
-
expect(() => resetHook(
|
|
177
|
+
it("resetHook on non-existent id is a no-op", () => {
|
|
178
|
+
expect(() => resetHook("no-such-hook")).not.toThrow()
|
|
179
179
|
})
|
|
180
180
|
})
|
|
181
181
|
|
|
182
182
|
// ─── Error guards ────────────────────────────────────────────────────────────
|
|
183
183
|
|
|
184
|
-
describe(
|
|
185
|
-
it(
|
|
184
|
+
describe("error guards", () => {
|
|
185
|
+
it("onPatch throws for non-model-instance", () => {
|
|
186
186
|
expect(() =>
|
|
187
187
|
onPatch({} as object, () => {
|
|
188
188
|
/* noop */
|
|
189
189
|
}),
|
|
190
|
-
).toThrow(
|
|
190
|
+
).toThrow("[@pyreon/state-tree]")
|
|
191
191
|
})
|
|
192
192
|
|
|
193
|
-
it(
|
|
194
|
-
expect(() => addMiddleware({} as object, (_c, n) => n(_c))).toThrow(
|
|
195
|
-
'[@pyreon/state-tree]',
|
|
196
|
-
)
|
|
193
|
+
it("addMiddleware throws for non-model-instance", () => {
|
|
194
|
+
expect(() => addMiddleware({} as object, (_c, n) => n(_c))).toThrow("[@pyreon/state-tree]")
|
|
197
195
|
})
|
|
198
196
|
|
|
199
|
-
it(
|
|
200
|
-
expect(() => applySnapshot({} as object, {})).toThrow(
|
|
201
|
-
'[@pyreon/state-tree]',
|
|
202
|
-
)
|
|
197
|
+
it("applySnapshot throws for non-model-instance", () => {
|
|
198
|
+
expect(() => applySnapshot({} as object, {})).toThrow("[@pyreon/state-tree]")
|
|
203
199
|
})
|
|
204
200
|
})
|
|
205
201
|
|
|
206
202
|
// ─── getSnapshot ──────────────────────────────────────────────────────────────
|
|
207
203
|
|
|
208
|
-
describe(
|
|
209
|
-
it(
|
|
204
|
+
describe("getSnapshot", () => {
|
|
205
|
+
it("returns a plain JS object", () => {
|
|
210
206
|
const c = Counter.create({ count: 7 })
|
|
211
207
|
const snap = getSnapshot(c)
|
|
212
208
|
expect(snap).toEqual({ count: 7 })
|
|
213
|
-
expect(typeof snap.count).toBe(
|
|
209
|
+
expect(typeof snap.count).toBe("number")
|
|
214
210
|
})
|
|
215
211
|
|
|
216
|
-
it(
|
|
212
|
+
it("snapshot reflects current state after mutations", () => {
|
|
217
213
|
const c = Counter.create()
|
|
218
214
|
c.inc()
|
|
219
215
|
c.inc()
|
|
@@ -221,29 +217,29 @@ describe('getSnapshot', () => {
|
|
|
221
217
|
expect(getSnapshot(c)).toEqual({ count: 3 })
|
|
222
218
|
})
|
|
223
219
|
|
|
224
|
-
it(
|
|
225
|
-
expect(() => getSnapshot({} as object)).toThrow(
|
|
220
|
+
it("throws for non-model-instance values", () => {
|
|
221
|
+
expect(() => getSnapshot({} as object)).toThrow("[@pyreon/state-tree]")
|
|
226
222
|
})
|
|
227
223
|
})
|
|
228
224
|
|
|
229
225
|
// ─── applySnapshot ────────────────────────────────────────────────────────────
|
|
230
226
|
|
|
231
|
-
describe(
|
|
232
|
-
it(
|
|
227
|
+
describe("applySnapshot", () => {
|
|
228
|
+
it("restores state from a plain snapshot", () => {
|
|
233
229
|
const c = Counter.create({ count: 10 })
|
|
234
230
|
applySnapshot(c, { count: 0 })
|
|
235
231
|
expect(c.count()).toBe(0)
|
|
236
232
|
})
|
|
237
233
|
|
|
238
|
-
it(
|
|
239
|
-
const NamedCounter = model({ state: { count: 0, label:
|
|
240
|
-
const c = NamedCounter.create({ count: 5, label:
|
|
234
|
+
it("partial snapshot — only specified keys are updated", () => {
|
|
235
|
+
const NamedCounter = model({ state: { count: 0, label: "x" } })
|
|
236
|
+
const c = NamedCounter.create({ count: 5, label: "hello" })
|
|
241
237
|
applySnapshot(c, { count: 99 })
|
|
242
238
|
expect(c.count()).toBe(99)
|
|
243
|
-
expect(c.label()).toBe(
|
|
239
|
+
expect(c.label()).toBe("hello")
|
|
244
240
|
})
|
|
245
241
|
|
|
246
|
-
it(
|
|
242
|
+
it("batch: effects fire once even for multi-field updates", () => {
|
|
247
243
|
const M = model({ state: { a: 0, b: 0 } })
|
|
248
244
|
const m = M.create()
|
|
249
245
|
let effectRuns = 0
|
|
@@ -262,17 +258,17 @@ describe('applySnapshot', () => {
|
|
|
262
258
|
|
|
263
259
|
// ─── onPatch ──────────────────────────────────────────────────────────────────
|
|
264
260
|
|
|
265
|
-
describe(
|
|
266
|
-
it(
|
|
261
|
+
describe("onPatch", () => {
|
|
262
|
+
it("fires when a signal is written", () => {
|
|
267
263
|
const c = Counter.create()
|
|
268
264
|
const patches: Patch[] = []
|
|
269
265
|
onPatch(c, (p) => patches.push(p))
|
|
270
266
|
c.inc()
|
|
271
267
|
expect(patches).toHaveLength(1)
|
|
272
|
-
expect(patches[0]).toEqual({ op:
|
|
268
|
+
expect(patches[0]).toEqual({ op: "replace", path: "/count", value: 1 })
|
|
273
269
|
})
|
|
274
270
|
|
|
275
|
-
it(
|
|
271
|
+
it("does NOT fire when value is unchanged", () => {
|
|
276
272
|
const c = Counter.create()
|
|
277
273
|
const patches: Patch[] = []
|
|
278
274
|
onPatch(c, (p) => patches.push(p))
|
|
@@ -280,7 +276,7 @@ describe('onPatch', () => {
|
|
|
280
276
|
expect(patches).toHaveLength(0)
|
|
281
277
|
})
|
|
282
278
|
|
|
283
|
-
it(
|
|
279
|
+
it("unsub stops patch events", () => {
|
|
284
280
|
const c = Counter.create()
|
|
285
281
|
const patches: Patch[] = []
|
|
286
282
|
const unsub = onPatch(c, (p) => patches.push(p))
|
|
@@ -289,7 +285,7 @@ describe('onPatch', () => {
|
|
|
289
285
|
expect(patches).toHaveLength(0)
|
|
290
286
|
})
|
|
291
287
|
|
|
292
|
-
it(
|
|
288
|
+
it("includes correct value in patch", () => {
|
|
293
289
|
const c = Counter.create()
|
|
294
290
|
const values: number[] = []
|
|
295
291
|
onPatch(c, (p) => values.push(p.value as number))
|
|
@@ -301,8 +297,8 @@ describe('onPatch', () => {
|
|
|
301
297
|
|
|
302
298
|
// ─── addMiddleware ────────────────────────────────────────────────────────────
|
|
303
299
|
|
|
304
|
-
describe(
|
|
305
|
-
it(
|
|
300
|
+
describe("addMiddleware", () => {
|
|
301
|
+
it("intercepts action calls", () => {
|
|
306
302
|
const c = Counter.create()
|
|
307
303
|
const intercepted: string[] = []
|
|
308
304
|
addMiddleware(c, (call, next) => {
|
|
@@ -310,17 +306,17 @@ describe('addMiddleware', () => {
|
|
|
310
306
|
return next(call)
|
|
311
307
|
})
|
|
312
308
|
c.inc()
|
|
313
|
-
expect(intercepted).toContain(
|
|
309
|
+
expect(intercepted).toContain("inc")
|
|
314
310
|
})
|
|
315
311
|
|
|
316
|
-
it(
|
|
312
|
+
it("next() executes the action", () => {
|
|
317
313
|
const c = Counter.create()
|
|
318
314
|
addMiddleware(c, (call, next) => next(call))
|
|
319
315
|
c.add(5)
|
|
320
316
|
expect(c.count()).toBe(5)
|
|
321
317
|
})
|
|
322
318
|
|
|
323
|
-
it(
|
|
319
|
+
it("middleware can prevent action from running by not calling next", () => {
|
|
324
320
|
const c = Counter.create()
|
|
325
321
|
addMiddleware(c, (_call, _next) => {
|
|
326
322
|
/* block */
|
|
@@ -329,25 +325,25 @@ describe('addMiddleware', () => {
|
|
|
329
325
|
expect(c.count()).toBe(0)
|
|
330
326
|
})
|
|
331
327
|
|
|
332
|
-
it(
|
|
328
|
+
it("multiple middlewares run in registration order", () => {
|
|
333
329
|
const c = Counter.create()
|
|
334
330
|
const log: string[] = []
|
|
335
331
|
addMiddleware(c, (call, next) => {
|
|
336
|
-
log.push(
|
|
332
|
+
log.push("A")
|
|
337
333
|
next(call)
|
|
338
334
|
log.push("A'")
|
|
339
335
|
})
|
|
340
336
|
addMiddleware(c, (call, next) => {
|
|
341
|
-
log.push(
|
|
337
|
+
log.push("B")
|
|
342
338
|
next(call)
|
|
343
339
|
log.push("B'")
|
|
344
340
|
})
|
|
345
341
|
c.inc()
|
|
346
342
|
// Koa-style: A→B→action→B'→A' (inner middleware unwraps first)
|
|
347
|
-
expect(log).toEqual([
|
|
343
|
+
expect(log).toEqual(["A", "B", "B'", "A'"])
|
|
348
344
|
})
|
|
349
345
|
|
|
350
|
-
it(
|
|
346
|
+
it("unsub removes the middleware", () => {
|
|
351
347
|
const c = Counter.create()
|
|
352
348
|
const log: string[] = []
|
|
353
349
|
const unsub = addMiddleware(c, (call, next) => {
|
|
@@ -362,8 +358,8 @@ describe('addMiddleware', () => {
|
|
|
362
358
|
|
|
363
359
|
// ─── patch.ts trackedSignal coverage ─────────────────────────────────────────
|
|
364
360
|
|
|
365
|
-
describe(
|
|
366
|
-
it(
|
|
361
|
+
describe("trackedSignal extra paths", () => {
|
|
362
|
+
it("subscribe on a state signal works (trackedSignal.subscribe)", () => {
|
|
367
363
|
const c = Counter.create()
|
|
368
364
|
const calls: number[] = []
|
|
369
365
|
const unsub = c.count.subscribe(() => {
|
|
@@ -377,13 +373,13 @@ describe('trackedSignal extra paths', () => {
|
|
|
377
373
|
expect(calls).toHaveLength(1)
|
|
378
374
|
})
|
|
379
375
|
|
|
380
|
-
it(
|
|
376
|
+
it("update on a state signal uses the updater function (trackedSignal.update)", () => {
|
|
381
377
|
const c = Counter.create({ count: 5 })
|
|
382
378
|
c.count.update((n: number) => n * 2)
|
|
383
379
|
expect(c.count()).toBe(10)
|
|
384
380
|
})
|
|
385
381
|
|
|
386
|
-
it(
|
|
382
|
+
it("peek on a state signal reads without tracking (trackedSignal.peek)", () => {
|
|
387
383
|
const c = Counter.create({ count: 42 })
|
|
388
384
|
expect(c.count.peek()).toBe(42)
|
|
389
385
|
})
|
|
@@ -391,8 +387,8 @@ describe('trackedSignal extra paths', () => {
|
|
|
391
387
|
|
|
392
388
|
// ─── patch.ts snapshotValue coverage ─────────────────────────────────────────
|
|
393
389
|
|
|
394
|
-
describe(
|
|
395
|
-
it(
|
|
390
|
+
describe("patch snapshotValue", () => {
|
|
391
|
+
it("emits a snapshot (not a live instance) when setting a nested model signal", () => {
|
|
396
392
|
// When a nested model instance is set as a value and a patch listener is active,
|
|
397
393
|
// snapshotValue should recursively serialize the nested model.
|
|
398
394
|
const Inner = model({
|
|
@@ -403,7 +399,7 @@ describe('patch snapshotValue', () => {
|
|
|
403
399
|
})
|
|
404
400
|
|
|
405
401
|
const Outer = model({
|
|
406
|
-
state: { child: Inner, label:
|
|
402
|
+
state: { child: Inner, label: "hi" },
|
|
407
403
|
actions: (self) => ({
|
|
408
404
|
replaceChild: (newChild: any) => self.child.set(newChild),
|
|
409
405
|
}),
|
|
@@ -418,22 +414,22 @@ describe('patch snapshotValue', () => {
|
|
|
418
414
|
outer.replaceChild(newInner)
|
|
419
415
|
|
|
420
416
|
expect(patches).toHaveLength(1)
|
|
421
|
-
expect(patches[0]!.op).toBe(
|
|
422
|
-
expect(patches[0]!.path).toBe(
|
|
417
|
+
expect(patches[0]!.op).toBe("replace")
|
|
418
|
+
expect(patches[0]!.path).toBe("/child")
|
|
423
419
|
// The value should be a plain snapshot, not the live model instance
|
|
424
420
|
expect(patches[0]!.value).toEqual({ x: 99, y: 42 })
|
|
425
421
|
expect(patches[0]!.value).not.toBe(newInner)
|
|
426
422
|
})
|
|
427
423
|
|
|
428
|
-
it(
|
|
424
|
+
it("snapshotValue recursively serializes deeply nested model instances", () => {
|
|
429
425
|
const Leaf = model({
|
|
430
426
|
state: { val: 0 },
|
|
431
427
|
})
|
|
432
428
|
const Mid = model({
|
|
433
|
-
state: { leaf: Leaf, tag:
|
|
429
|
+
state: { leaf: Leaf, tag: "mid" },
|
|
434
430
|
})
|
|
435
431
|
const Root = model({
|
|
436
|
-
state: { mid: Mid, name:
|
|
432
|
+
state: { mid: Mid, name: "root" },
|
|
437
433
|
actions: (self) => ({
|
|
438
434
|
replaceMid: (m: any) => self.mid.set(m),
|
|
439
435
|
}),
|
|
@@ -443,13 +439,13 @@ describe('patch snapshotValue', () => {
|
|
|
443
439
|
const patches: Patch[] = []
|
|
444
440
|
onPatch(root, (p) => patches.push(p))
|
|
445
441
|
|
|
446
|
-
const newMid = Mid.create({ leaf: { val: 77 }, tag:
|
|
442
|
+
const newMid = Mid.create({ leaf: { val: 77 }, tag: "new" })
|
|
447
443
|
root.replaceMid(newMid)
|
|
448
444
|
|
|
449
|
-
expect(patches[0]!.value).toEqual({ leaf: { val: 77 }, tag:
|
|
445
|
+
expect(patches[0]!.value).toEqual({ leaf: { val: 77 }, tag: "new" })
|
|
450
446
|
})
|
|
451
447
|
|
|
452
|
-
it(
|
|
448
|
+
it("snapshotValue returns the object as-is when it has no meta (!meta branch)", () => {
|
|
453
449
|
// To trigger the !meta branch in snapshotValue, we need isModelInstance to return true
|
|
454
450
|
// for an object that has no actual meta. We do this by temporarily registering a
|
|
455
451
|
// fake object in instanceMeta, making it pass isModelInstance, then deleting the meta
|
|
@@ -476,7 +472,7 @@ describe('patch snapshotValue', () => {
|
|
|
476
472
|
// but with stateKeys that reference properties that don't exist on the object (!sig branch)
|
|
477
473
|
const fakeInstance = {} as any
|
|
478
474
|
instanceMeta.set(fakeInstance, {
|
|
479
|
-
stateKeys: [
|
|
475
|
+
stateKeys: ["missing"],
|
|
480
476
|
patchListeners: new Set(),
|
|
481
477
|
middlewares: [],
|
|
482
478
|
emitPatch: () => {
|
|
@@ -490,12 +486,12 @@ describe('patch snapshotValue', () => {
|
|
|
490
486
|
expect(patches[0]!.value).toEqual({})
|
|
491
487
|
})
|
|
492
488
|
|
|
493
|
-
it(
|
|
489
|
+
it("snapshotValue handles stateKey with nested model value recursively in patches", () => {
|
|
494
490
|
// Ensure the recursive path in snapshotValue is covered:
|
|
495
491
|
// when a stateKey's peek() returns a model instance, it recurses.
|
|
496
492
|
const Leaf = model({ state: { v: 1 } })
|
|
497
493
|
const Branch = model({
|
|
498
|
-
state: { leaf: Leaf, tag:
|
|
494
|
+
state: { leaf: Leaf, tag: "a" },
|
|
499
495
|
})
|
|
500
496
|
const Root = model({
|
|
501
497
|
state: { branch: Branch },
|
|
@@ -508,24 +504,24 @@ describe('patch snapshotValue', () => {
|
|
|
508
504
|
const patches: Patch[] = []
|
|
509
505
|
onPatch(root, (p) => patches.push(p))
|
|
510
506
|
|
|
511
|
-
const newBranch = Branch.create({ leaf: { v: 99 }, tag:
|
|
507
|
+
const newBranch = Branch.create({ leaf: { v: 99 }, tag: "b" })
|
|
512
508
|
root.replaceBranch(newBranch)
|
|
513
509
|
|
|
514
|
-
expect(patches[0]!.value).toEqual({ leaf: { v: 99 }, tag:
|
|
510
|
+
expect(patches[0]!.value).toEqual({ leaf: { v: 99 }, tag: "b" })
|
|
515
511
|
})
|
|
516
512
|
})
|
|
517
513
|
|
|
518
514
|
// ─── middleware.ts edge cases ────────────────────────────────────────────────
|
|
519
515
|
|
|
520
|
-
describe(
|
|
521
|
-
it(
|
|
516
|
+
describe("middleware edge cases", () => {
|
|
517
|
+
it("action runs directly when middleware array is empty (idx >= length branch)", () => {
|
|
522
518
|
const c = Counter.create()
|
|
523
519
|
// No middleware added — dispatch(0, call) hits idx >= meta.middlewares.length immediately
|
|
524
520
|
c.inc()
|
|
525
521
|
expect(c.count()).toBe(1)
|
|
526
522
|
})
|
|
527
523
|
|
|
528
|
-
it(
|
|
524
|
+
it("skips falsy middleware entries (!mw branch)", () => {
|
|
529
525
|
const c = Counter.create()
|
|
530
526
|
// Add a real middleware so the array is non-empty
|
|
531
527
|
addMiddleware(c, (call, next) => next(call))
|
|
@@ -537,7 +533,7 @@ describe('middleware edge cases', () => {
|
|
|
537
533
|
expect(c.count()).toBe(1)
|
|
538
534
|
})
|
|
539
535
|
|
|
540
|
-
it(
|
|
536
|
+
it("double-unsub is a no-op (indexOf returns -1 branch)", () => {
|
|
541
537
|
const c = Counter.create()
|
|
542
538
|
const unsub = addMiddleware(c, (call, next) => next(call))
|
|
543
539
|
unsub()
|
|
@@ -548,8 +544,8 @@ describe('middleware edge cases', () => {
|
|
|
548
544
|
|
|
549
545
|
// ─── snapshot.ts edge cases ──────────────────────────────────────────────────
|
|
550
546
|
|
|
551
|
-
describe(
|
|
552
|
-
it(
|
|
547
|
+
describe("snapshot edge cases", () => {
|
|
548
|
+
it("getSnapshot skips keys whose signal does not exist (!sig branch)", () => {
|
|
553
549
|
// Create an instance then tamper with it to have a missing signal for a state key
|
|
554
550
|
const c = Counter.create({ count: 5 })
|
|
555
551
|
// Delete the signal to simulate the !sig branch
|
|
@@ -559,7 +555,7 @@ describe('snapshot edge cases', () => {
|
|
|
559
555
|
expect(snap).toEqual({})
|
|
560
556
|
})
|
|
561
557
|
|
|
562
|
-
it(
|
|
558
|
+
it("applySnapshot skips keys not present in the snapshot object", () => {
|
|
563
559
|
const M = model({ state: { a: 1, b: 2, c: 3 } })
|
|
564
560
|
const m = M.create({ a: 10, b: 20, c: 30 })
|
|
565
561
|
// Only apply 'b' — 'a' and 'c' should remain unchanged
|
|
@@ -569,7 +565,7 @@ describe('snapshot edge cases', () => {
|
|
|
569
565
|
expect(m.c()).toBe(30)
|
|
570
566
|
})
|
|
571
567
|
|
|
572
|
-
it(
|
|
568
|
+
it("applySnapshot skips keys whose signal does not exist (!sig branch)", () => {
|
|
573
569
|
const c = Counter.create({ count: 5 })
|
|
574
570
|
// Delete the signal to simulate the !sig branch in applySnapshot
|
|
575
571
|
delete (c as any).count
|
|
@@ -580,95 +576,95 @@ describe('snapshot edge cases', () => {
|
|
|
580
576
|
|
|
581
577
|
// ─── Nested models ────────────────────────────────────────────────────────────
|
|
582
578
|
|
|
583
|
-
describe(
|
|
584
|
-
it(
|
|
579
|
+
describe("nested models", () => {
|
|
580
|
+
it("creates nested instance from snapshot", () => {
|
|
585
581
|
const app = App.create({
|
|
586
|
-
profile: { name:
|
|
587
|
-
title:
|
|
582
|
+
profile: { name: "Alice", bio: "dev" },
|
|
583
|
+
title: "App",
|
|
588
584
|
})
|
|
589
|
-
expect(app.profile().name()).toBe(
|
|
585
|
+
expect(app.profile().name()).toBe("Alice")
|
|
590
586
|
})
|
|
591
587
|
|
|
592
|
-
it(
|
|
588
|
+
it("nested instance has its own actions", () => {
|
|
593
589
|
const app = App.create({
|
|
594
|
-
profile: { name:
|
|
595
|
-
title:
|
|
590
|
+
profile: { name: "Alice", bio: "" },
|
|
591
|
+
title: "App",
|
|
596
592
|
})
|
|
597
|
-
app.profile().rename(
|
|
598
|
-
expect(app.profile().name()).toBe(
|
|
593
|
+
app.profile().rename("Bob")
|
|
594
|
+
expect(app.profile().name()).toBe("Bob")
|
|
599
595
|
})
|
|
600
596
|
|
|
601
|
-
it(
|
|
597
|
+
it("nested defaults used when no snapshot provided", () => {
|
|
602
598
|
const app = App.create()
|
|
603
|
-
expect(app.profile().name()).toBe(
|
|
604
|
-
expect(app.title()).toBe(
|
|
599
|
+
expect(app.profile().name()).toBe("")
|
|
600
|
+
expect(app.title()).toBe("My App")
|
|
605
601
|
})
|
|
606
602
|
|
|
607
|
-
it(
|
|
603
|
+
it("getSnapshot recursively serializes nested instances", () => {
|
|
608
604
|
const app = App.create({
|
|
609
|
-
profile: { name:
|
|
610
|
-
title:
|
|
605
|
+
profile: { name: "Alice", bio: "dev" },
|
|
606
|
+
title: "Hello",
|
|
611
607
|
})
|
|
612
608
|
const snap = getSnapshot(app)
|
|
613
609
|
expect(snap).toEqual({
|
|
614
|
-
profile: { name:
|
|
615
|
-
title:
|
|
610
|
+
profile: { name: "Alice", bio: "dev" },
|
|
611
|
+
title: "Hello",
|
|
616
612
|
})
|
|
617
613
|
})
|
|
618
614
|
|
|
619
|
-
it(
|
|
620
|
-
const app = App.create({ profile: { name:
|
|
615
|
+
it("onPatch emits nested path for nested state change", () => {
|
|
616
|
+
const app = App.create({ profile: { name: "Alice", bio: "" }, title: "" })
|
|
621
617
|
const patches: Patch[] = []
|
|
622
618
|
onPatch(app, (p) => patches.push(p))
|
|
623
|
-
app.profile().rename(
|
|
619
|
+
app.profile().rename("Bob")
|
|
624
620
|
expect(patches).toHaveLength(1)
|
|
625
621
|
expect(patches[0]).toEqual({
|
|
626
|
-
op:
|
|
627
|
-
path:
|
|
628
|
-
value:
|
|
622
|
+
op: "replace",
|
|
623
|
+
path: "/profile/name",
|
|
624
|
+
value: "Bob",
|
|
629
625
|
})
|
|
630
626
|
})
|
|
631
627
|
|
|
632
|
-
it(
|
|
628
|
+
it("applySnapshot restores nested state", () => {
|
|
633
629
|
const app = App.create({
|
|
634
|
-
profile: { name:
|
|
635
|
-
title:
|
|
630
|
+
profile: { name: "Alice", bio: "" },
|
|
631
|
+
title: "old",
|
|
636
632
|
})
|
|
637
|
-
applySnapshot(app, { profile: { name:
|
|
638
|
-
expect(app.profile().name()).toBe(
|
|
639
|
-
expect(app.title()).toBe(
|
|
633
|
+
applySnapshot(app, { profile: { name: "Carol", bio: "new" }, title: "new" })
|
|
634
|
+
expect(app.profile().name()).toBe("Carol")
|
|
635
|
+
expect(app.title()).toBe("new")
|
|
640
636
|
})
|
|
641
637
|
})
|
|
642
638
|
|
|
643
639
|
// ─── applyPatch ──────────────────────────────────────────────────────────────
|
|
644
640
|
|
|
645
|
-
describe(
|
|
646
|
-
it(
|
|
641
|
+
describe("applyPatch", () => {
|
|
642
|
+
it("applies a single replace patch", () => {
|
|
647
643
|
const c = Counter.create({ count: 0 })
|
|
648
|
-
applyPatch(c, { op:
|
|
644
|
+
applyPatch(c, { op: "replace", path: "/count", value: 42 })
|
|
649
645
|
expect(c.count()).toBe(42)
|
|
650
646
|
})
|
|
651
647
|
|
|
652
|
-
it(
|
|
648
|
+
it("applies an array of patches", () => {
|
|
653
649
|
const c = Counter.create({ count: 0 })
|
|
654
650
|
applyPatch(c, [
|
|
655
|
-
{ op:
|
|
656
|
-
{ op:
|
|
657
|
-
{ op:
|
|
651
|
+
{ op: "replace", path: "/count", value: 1 },
|
|
652
|
+
{ op: "replace", path: "/count", value: 2 },
|
|
653
|
+
{ op: "replace", path: "/count", value: 3 },
|
|
658
654
|
])
|
|
659
655
|
expect(c.count()).toBe(3)
|
|
660
656
|
})
|
|
661
657
|
|
|
662
|
-
it(
|
|
658
|
+
it("applies patch to nested model instance", () => {
|
|
663
659
|
const app = App.create({
|
|
664
|
-
profile: { name:
|
|
665
|
-
title:
|
|
660
|
+
profile: { name: "Alice", bio: "" },
|
|
661
|
+
title: "old",
|
|
666
662
|
})
|
|
667
|
-
applyPatch(app, { op:
|
|
668
|
-
expect(app.profile().name()).toBe(
|
|
663
|
+
applyPatch(app, { op: "replace", path: "/profile/name", value: "Bob" })
|
|
664
|
+
expect(app.profile().name()).toBe("Bob")
|
|
669
665
|
})
|
|
670
666
|
|
|
671
|
-
it(
|
|
667
|
+
it("roundtrips with onPatch — record and replay", () => {
|
|
672
668
|
const c = Counter.create({ count: 0 })
|
|
673
669
|
const patches: Patch[] = []
|
|
674
670
|
onPatch(c, (p) => patches.push({ ...p }))
|
|
@@ -679,9 +675,9 @@ describe('applyPatch', () => {
|
|
|
679
675
|
expect(c.count()).toBe(12)
|
|
680
676
|
expect(patches).toHaveLength(3)
|
|
681
677
|
// Verify patches contain the final values at each step
|
|
682
|
-
expect(patches[0]).toEqual({ op:
|
|
683
|
-
expect(patches[1]).toEqual({ op:
|
|
684
|
-
expect(patches[2]).toEqual({ op:
|
|
678
|
+
expect(patches[0]).toEqual({ op: "replace", path: "/count", value: 1 })
|
|
679
|
+
expect(patches[1]).toEqual({ op: "replace", path: "/count", value: 2 })
|
|
680
|
+
expect(patches[2]).toEqual({ op: "replace", path: "/count", value: 12 })
|
|
685
681
|
|
|
686
682
|
// Replay on a fresh instance
|
|
687
683
|
const c2 = Counter.create({ count: 0 })
|
|
@@ -689,30 +685,28 @@ describe('applyPatch', () => {
|
|
|
689
685
|
expect(c2.count()).toBe(12)
|
|
690
686
|
})
|
|
691
687
|
|
|
692
|
-
it(
|
|
693
|
-
expect(() =>
|
|
694
|
-
|
|
695
|
-
)
|
|
688
|
+
it("throws for non-model instance", () => {
|
|
689
|
+
expect(() => applyPatch({}, { op: "replace", path: "/x", value: 1 })).toThrow(
|
|
690
|
+
"not a model instance",
|
|
691
|
+
)
|
|
696
692
|
})
|
|
697
693
|
|
|
698
|
-
it(
|
|
694
|
+
it("throws for empty path", () => {
|
|
699
695
|
const c = Counter.create({ count: 0 })
|
|
700
|
-
expect(() => applyPatch(c, { op:
|
|
701
|
-
'empty path',
|
|
702
|
-
)
|
|
696
|
+
expect(() => applyPatch(c, { op: "replace", path: "", value: 1 })).toThrow("empty path")
|
|
703
697
|
})
|
|
704
698
|
|
|
705
|
-
it(
|
|
699
|
+
it("throws for unknown state key", () => {
|
|
706
700
|
const c = Counter.create({ count: 0 })
|
|
707
|
-
expect(() =>
|
|
708
|
-
|
|
709
|
-
)
|
|
701
|
+
expect(() => applyPatch(c, { op: "replace", path: "/nonexistent", value: 1 })).toThrow(
|
|
702
|
+
"unknown state key",
|
|
703
|
+
)
|
|
710
704
|
})
|
|
711
705
|
|
|
712
|
-
it(
|
|
706
|
+
it("throws for unsupported op", () => {
|
|
713
707
|
const c = Counter.create({ count: 0 })
|
|
714
|
-
expect(() =>
|
|
715
|
-
|
|
716
|
-
)
|
|
708
|
+
expect(() => applyPatch(c, { op: "add" as any, path: "/count", value: 1 })).toThrow(
|
|
709
|
+
'unsupported op "add"',
|
|
710
|
+
)
|
|
717
711
|
})
|
|
718
712
|
})
|