@pyreon/state-tree 0.11.4 → 0.11.6
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/README.md +63 -63
- package/lib/devtools.js.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +1 -1
- package/package.json +14 -14
- package/src/devtools.ts +1 -1
- package/src/index.ts +6 -6
- package/src/instance.ts +8 -8
- package/src/middleware.ts +3 -3
- package/src/model.ts +4 -4
- package/src/patch.ts +14 -14
- package/src/registry.ts +2 -2
- package/src/snapshot.ts +6 -6
- package/src/tests/comprehensive.test.ts +109 -109
- package/src/tests/devtools.test.ts +43 -43
- package/src/tests/edge-cases.test.ts +138 -138
- package/src/tests/model.test.ts +157 -157
- package/src/types.ts +3 -3
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,42 +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(
|
|
193
|
+
it('addMiddleware throws for non-model-instance', () => {
|
|
194
|
+
expect(() => addMiddleware({} as object, (_c, n) => n(_c))).toThrow('[@pyreon/state-tree]')
|
|
195
195
|
})
|
|
196
196
|
|
|
197
|
-
it(
|
|
198
|
-
expect(() => applySnapshot({} as object, {})).toThrow(
|
|
197
|
+
it('applySnapshot throws for non-model-instance', () => {
|
|
198
|
+
expect(() => applySnapshot({} as object, {})).toThrow('[@pyreon/state-tree]')
|
|
199
199
|
})
|
|
200
200
|
})
|
|
201
201
|
|
|
202
202
|
// ─── getSnapshot ──────────────────────────────────────────────────────────────
|
|
203
203
|
|
|
204
|
-
describe(
|
|
205
|
-
it(
|
|
204
|
+
describe('getSnapshot', () => {
|
|
205
|
+
it('returns a plain JS object', () => {
|
|
206
206
|
const c = Counter.create({ count: 7 })
|
|
207
207
|
const snap = getSnapshot(c)
|
|
208
208
|
expect(snap).toEqual({ count: 7 })
|
|
209
|
-
expect(typeof snap.count).toBe(
|
|
209
|
+
expect(typeof snap.count).toBe('number')
|
|
210
210
|
})
|
|
211
211
|
|
|
212
|
-
it(
|
|
212
|
+
it('snapshot reflects current state after mutations', () => {
|
|
213
213
|
const c = Counter.create()
|
|
214
214
|
c.inc()
|
|
215
215
|
c.inc()
|
|
@@ -217,29 +217,29 @@ describe("getSnapshot", () => {
|
|
|
217
217
|
expect(getSnapshot(c)).toEqual({ count: 3 })
|
|
218
218
|
})
|
|
219
219
|
|
|
220
|
-
it(
|
|
221
|
-
expect(() => getSnapshot({} as object)).toThrow(
|
|
220
|
+
it('throws for non-model-instance values', () => {
|
|
221
|
+
expect(() => getSnapshot({} as object)).toThrow('[@pyreon/state-tree]')
|
|
222
222
|
})
|
|
223
223
|
})
|
|
224
224
|
|
|
225
225
|
// ─── applySnapshot ────────────────────────────────────────────────────────────
|
|
226
226
|
|
|
227
|
-
describe(
|
|
228
|
-
it(
|
|
227
|
+
describe('applySnapshot', () => {
|
|
228
|
+
it('restores state from a plain snapshot', () => {
|
|
229
229
|
const c = Counter.create({ count: 10 })
|
|
230
230
|
applySnapshot(c, { count: 0 })
|
|
231
231
|
expect(c.count()).toBe(0)
|
|
232
232
|
})
|
|
233
233
|
|
|
234
|
-
it(
|
|
235
|
-
const NamedCounter = model({ state: { count: 0, label:
|
|
236
|
-
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' })
|
|
237
237
|
applySnapshot(c, { count: 99 })
|
|
238
238
|
expect(c.count()).toBe(99)
|
|
239
|
-
expect(c.label()).toBe(
|
|
239
|
+
expect(c.label()).toBe('hello')
|
|
240
240
|
})
|
|
241
241
|
|
|
242
|
-
it(
|
|
242
|
+
it('batch: effects fire once even for multi-field updates', () => {
|
|
243
243
|
const M = model({ state: { a: 0, b: 0 } })
|
|
244
244
|
const m = M.create()
|
|
245
245
|
let effectRuns = 0
|
|
@@ -258,17 +258,17 @@ describe("applySnapshot", () => {
|
|
|
258
258
|
|
|
259
259
|
// ─── onPatch ──────────────────────────────────────────────────────────────────
|
|
260
260
|
|
|
261
|
-
describe(
|
|
262
|
-
it(
|
|
261
|
+
describe('onPatch', () => {
|
|
262
|
+
it('fires when a signal is written', () => {
|
|
263
263
|
const c = Counter.create()
|
|
264
264
|
const patches: Patch[] = []
|
|
265
265
|
onPatch(c, (p) => patches.push(p))
|
|
266
266
|
c.inc()
|
|
267
267
|
expect(patches).toHaveLength(1)
|
|
268
|
-
expect(patches[0]).toEqual({ op:
|
|
268
|
+
expect(patches[0]).toEqual({ op: 'replace', path: '/count', value: 1 })
|
|
269
269
|
})
|
|
270
270
|
|
|
271
|
-
it(
|
|
271
|
+
it('does NOT fire when value is unchanged', () => {
|
|
272
272
|
const c = Counter.create()
|
|
273
273
|
const patches: Patch[] = []
|
|
274
274
|
onPatch(c, (p) => patches.push(p))
|
|
@@ -276,7 +276,7 @@ describe("onPatch", () => {
|
|
|
276
276
|
expect(patches).toHaveLength(0)
|
|
277
277
|
})
|
|
278
278
|
|
|
279
|
-
it(
|
|
279
|
+
it('unsub stops patch events', () => {
|
|
280
280
|
const c = Counter.create()
|
|
281
281
|
const patches: Patch[] = []
|
|
282
282
|
const unsub = onPatch(c, (p) => patches.push(p))
|
|
@@ -285,7 +285,7 @@ describe("onPatch", () => {
|
|
|
285
285
|
expect(patches).toHaveLength(0)
|
|
286
286
|
})
|
|
287
287
|
|
|
288
|
-
it(
|
|
288
|
+
it('includes correct value in patch', () => {
|
|
289
289
|
const c = Counter.create()
|
|
290
290
|
const values: number[] = []
|
|
291
291
|
onPatch(c, (p) => values.push(p.value as number))
|
|
@@ -297,8 +297,8 @@ describe("onPatch", () => {
|
|
|
297
297
|
|
|
298
298
|
// ─── addMiddleware ────────────────────────────────────────────────────────────
|
|
299
299
|
|
|
300
|
-
describe(
|
|
301
|
-
it(
|
|
300
|
+
describe('addMiddleware', () => {
|
|
301
|
+
it('intercepts action calls', () => {
|
|
302
302
|
const c = Counter.create()
|
|
303
303
|
const intercepted: string[] = []
|
|
304
304
|
addMiddleware(c, (call, next) => {
|
|
@@ -306,17 +306,17 @@ describe("addMiddleware", () => {
|
|
|
306
306
|
return next(call)
|
|
307
307
|
})
|
|
308
308
|
c.inc()
|
|
309
|
-
expect(intercepted).toContain(
|
|
309
|
+
expect(intercepted).toContain('inc')
|
|
310
310
|
})
|
|
311
311
|
|
|
312
|
-
it(
|
|
312
|
+
it('next() executes the action', () => {
|
|
313
313
|
const c = Counter.create()
|
|
314
314
|
addMiddleware(c, (call, next) => next(call))
|
|
315
315
|
c.add(5)
|
|
316
316
|
expect(c.count()).toBe(5)
|
|
317
317
|
})
|
|
318
318
|
|
|
319
|
-
it(
|
|
319
|
+
it('middleware can prevent action from running by not calling next', () => {
|
|
320
320
|
const c = Counter.create()
|
|
321
321
|
addMiddleware(c, (_call, _next) => {
|
|
322
322
|
/* block */
|
|
@@ -325,25 +325,25 @@ describe("addMiddleware", () => {
|
|
|
325
325
|
expect(c.count()).toBe(0)
|
|
326
326
|
})
|
|
327
327
|
|
|
328
|
-
it(
|
|
328
|
+
it('multiple middlewares run in registration order', () => {
|
|
329
329
|
const c = Counter.create()
|
|
330
330
|
const log: string[] = []
|
|
331
331
|
addMiddleware(c, (call, next) => {
|
|
332
|
-
log.push(
|
|
332
|
+
log.push('A')
|
|
333
333
|
next(call)
|
|
334
334
|
log.push("A'")
|
|
335
335
|
})
|
|
336
336
|
addMiddleware(c, (call, next) => {
|
|
337
|
-
log.push(
|
|
337
|
+
log.push('B')
|
|
338
338
|
next(call)
|
|
339
339
|
log.push("B'")
|
|
340
340
|
})
|
|
341
341
|
c.inc()
|
|
342
342
|
// Koa-style: A→B→action→B'→A' (inner middleware unwraps first)
|
|
343
|
-
expect(log).toEqual([
|
|
343
|
+
expect(log).toEqual(['A', 'B', "B'", "A'"])
|
|
344
344
|
})
|
|
345
345
|
|
|
346
|
-
it(
|
|
346
|
+
it('unsub removes the middleware', () => {
|
|
347
347
|
const c = Counter.create()
|
|
348
348
|
const log: string[] = []
|
|
349
349
|
const unsub = addMiddleware(c, (call, next) => {
|
|
@@ -358,8 +358,8 @@ describe("addMiddleware", () => {
|
|
|
358
358
|
|
|
359
359
|
// ─── patch.ts trackedSignal coverage ─────────────────────────────────────────
|
|
360
360
|
|
|
361
|
-
describe(
|
|
362
|
-
it(
|
|
361
|
+
describe('trackedSignal extra paths', () => {
|
|
362
|
+
it('subscribe on a state signal works (trackedSignal.subscribe)', () => {
|
|
363
363
|
const c = Counter.create()
|
|
364
364
|
const calls: number[] = []
|
|
365
365
|
const unsub = c.count.subscribe(() => {
|
|
@@ -373,13 +373,13 @@ describe("trackedSignal extra paths", () => {
|
|
|
373
373
|
expect(calls).toHaveLength(1)
|
|
374
374
|
})
|
|
375
375
|
|
|
376
|
-
it(
|
|
376
|
+
it('update on a state signal uses the updater function (trackedSignal.update)', () => {
|
|
377
377
|
const c = Counter.create({ count: 5 })
|
|
378
378
|
c.count.update((n: number) => n * 2)
|
|
379
379
|
expect(c.count()).toBe(10)
|
|
380
380
|
})
|
|
381
381
|
|
|
382
|
-
it(
|
|
382
|
+
it('peek on a state signal reads without tracking (trackedSignal.peek)', () => {
|
|
383
383
|
const c = Counter.create({ count: 42 })
|
|
384
384
|
expect(c.count.peek()).toBe(42)
|
|
385
385
|
})
|
|
@@ -387,8 +387,8 @@ describe("trackedSignal extra paths", () => {
|
|
|
387
387
|
|
|
388
388
|
// ─── patch.ts snapshotValue coverage ─────────────────────────────────────────
|
|
389
389
|
|
|
390
|
-
describe(
|
|
391
|
-
it(
|
|
390
|
+
describe('patch snapshotValue', () => {
|
|
391
|
+
it('emits a snapshot (not a live instance) when setting a nested model signal', () => {
|
|
392
392
|
// When a nested model instance is set as a value and a patch listener is active,
|
|
393
393
|
// snapshotValue should recursively serialize the nested model.
|
|
394
394
|
const Inner = model({
|
|
@@ -399,7 +399,7 @@ describe("patch snapshotValue", () => {
|
|
|
399
399
|
})
|
|
400
400
|
|
|
401
401
|
const Outer = model({
|
|
402
|
-
state: { child: Inner, label:
|
|
402
|
+
state: { child: Inner, label: 'hi' },
|
|
403
403
|
actions: (self) => ({
|
|
404
404
|
replaceChild: (newChild: any) => self.child.set(newChild),
|
|
405
405
|
}),
|
|
@@ -414,22 +414,22 @@ describe("patch snapshotValue", () => {
|
|
|
414
414
|
outer.replaceChild(newInner)
|
|
415
415
|
|
|
416
416
|
expect(patches).toHaveLength(1)
|
|
417
|
-
expect(patches[0]!.op).toBe(
|
|
418
|
-
expect(patches[0]!.path).toBe(
|
|
417
|
+
expect(patches[0]!.op).toBe('replace')
|
|
418
|
+
expect(patches[0]!.path).toBe('/child')
|
|
419
419
|
// The value should be a plain snapshot, not the live model instance
|
|
420
420
|
expect(patches[0]!.value).toEqual({ x: 99, y: 42 })
|
|
421
421
|
expect(patches[0]!.value).not.toBe(newInner)
|
|
422
422
|
})
|
|
423
423
|
|
|
424
|
-
it(
|
|
424
|
+
it('snapshotValue recursively serializes deeply nested model instances', () => {
|
|
425
425
|
const Leaf = model({
|
|
426
426
|
state: { val: 0 },
|
|
427
427
|
})
|
|
428
428
|
const Mid = model({
|
|
429
|
-
state: { leaf: Leaf, tag:
|
|
429
|
+
state: { leaf: Leaf, tag: 'mid' },
|
|
430
430
|
})
|
|
431
431
|
const Root = model({
|
|
432
|
-
state: { mid: Mid, name:
|
|
432
|
+
state: { mid: Mid, name: 'root' },
|
|
433
433
|
actions: (self) => ({
|
|
434
434
|
replaceMid: (m: any) => self.mid.set(m),
|
|
435
435
|
}),
|
|
@@ -439,13 +439,13 @@ describe("patch snapshotValue", () => {
|
|
|
439
439
|
const patches: Patch[] = []
|
|
440
440
|
onPatch(root, (p) => patches.push(p))
|
|
441
441
|
|
|
442
|
-
const newMid = Mid.create({ leaf: { val: 77 }, tag:
|
|
442
|
+
const newMid = Mid.create({ leaf: { val: 77 }, tag: 'new' })
|
|
443
443
|
root.replaceMid(newMid)
|
|
444
444
|
|
|
445
|
-
expect(patches[0]!.value).toEqual({ leaf: { val: 77 }, tag:
|
|
445
|
+
expect(patches[0]!.value).toEqual({ leaf: { val: 77 }, tag: 'new' })
|
|
446
446
|
})
|
|
447
447
|
|
|
448
|
-
it(
|
|
448
|
+
it('snapshotValue returns the object as-is when it has no meta (!meta branch)', () => {
|
|
449
449
|
// To trigger the !meta branch in snapshotValue, we need isModelInstance to return true
|
|
450
450
|
// for an object that has no actual meta. We do this by temporarily registering a
|
|
451
451
|
// fake object in instanceMeta, making it pass isModelInstance, then deleting the meta
|
|
@@ -472,7 +472,7 @@ describe("patch snapshotValue", () => {
|
|
|
472
472
|
// but with stateKeys that reference properties that don't exist on the object (!sig branch)
|
|
473
473
|
const fakeInstance = {} as any
|
|
474
474
|
instanceMeta.set(fakeInstance, {
|
|
475
|
-
stateKeys: [
|
|
475
|
+
stateKeys: ['missing'],
|
|
476
476
|
patchListeners: new Set(),
|
|
477
477
|
middlewares: [],
|
|
478
478
|
emitPatch: () => {
|
|
@@ -486,12 +486,12 @@ describe("patch snapshotValue", () => {
|
|
|
486
486
|
expect(patches[0]!.value).toEqual({})
|
|
487
487
|
})
|
|
488
488
|
|
|
489
|
-
it(
|
|
489
|
+
it('snapshotValue handles stateKey with nested model value recursively in patches', () => {
|
|
490
490
|
// Ensure the recursive path in snapshotValue is covered:
|
|
491
491
|
// when a stateKey's peek() returns a model instance, it recurses.
|
|
492
492
|
const Leaf = model({ state: { v: 1 } })
|
|
493
493
|
const Branch = model({
|
|
494
|
-
state: { leaf: Leaf, tag:
|
|
494
|
+
state: { leaf: Leaf, tag: 'a' },
|
|
495
495
|
})
|
|
496
496
|
const Root = model({
|
|
497
497
|
state: { branch: Branch },
|
|
@@ -504,24 +504,24 @@ describe("patch snapshotValue", () => {
|
|
|
504
504
|
const patches: Patch[] = []
|
|
505
505
|
onPatch(root, (p) => patches.push(p))
|
|
506
506
|
|
|
507
|
-
const newBranch = Branch.create({ leaf: { v: 99 }, tag:
|
|
507
|
+
const newBranch = Branch.create({ leaf: { v: 99 }, tag: 'b' })
|
|
508
508
|
root.replaceBranch(newBranch)
|
|
509
509
|
|
|
510
|
-
expect(patches[0]!.value).toEqual({ leaf: { v: 99 }, tag:
|
|
510
|
+
expect(patches[0]!.value).toEqual({ leaf: { v: 99 }, tag: 'b' })
|
|
511
511
|
})
|
|
512
512
|
})
|
|
513
513
|
|
|
514
514
|
// ─── middleware.ts edge cases ────────────────────────────────────────────────
|
|
515
515
|
|
|
516
|
-
describe(
|
|
517
|
-
it(
|
|
516
|
+
describe('middleware edge cases', () => {
|
|
517
|
+
it('action runs directly when middleware array is empty (idx >= length branch)', () => {
|
|
518
518
|
const c = Counter.create()
|
|
519
519
|
// No middleware added — dispatch(0, call) hits idx >= meta.middlewares.length immediately
|
|
520
520
|
c.inc()
|
|
521
521
|
expect(c.count()).toBe(1)
|
|
522
522
|
})
|
|
523
523
|
|
|
524
|
-
it(
|
|
524
|
+
it('skips falsy middleware entries (!mw branch)', () => {
|
|
525
525
|
const c = Counter.create()
|
|
526
526
|
// Add a real middleware so the array is non-empty
|
|
527
527
|
addMiddleware(c, (call, next) => next(call))
|
|
@@ -533,7 +533,7 @@ describe("middleware edge cases", () => {
|
|
|
533
533
|
expect(c.count()).toBe(1)
|
|
534
534
|
})
|
|
535
535
|
|
|
536
|
-
it(
|
|
536
|
+
it('double-unsub is a no-op (indexOf returns -1 branch)', () => {
|
|
537
537
|
const c = Counter.create()
|
|
538
538
|
const unsub = addMiddleware(c, (call, next) => next(call))
|
|
539
539
|
unsub()
|
|
@@ -544,8 +544,8 @@ describe("middleware edge cases", () => {
|
|
|
544
544
|
|
|
545
545
|
// ─── snapshot.ts edge cases ──────────────────────────────────────────────────
|
|
546
546
|
|
|
547
|
-
describe(
|
|
548
|
-
it(
|
|
547
|
+
describe('snapshot edge cases', () => {
|
|
548
|
+
it('getSnapshot skips keys whose signal does not exist (!sig branch)', () => {
|
|
549
549
|
// Create an instance then tamper with it to have a missing signal for a state key
|
|
550
550
|
const c = Counter.create({ count: 5 })
|
|
551
551
|
// Delete the signal to simulate the !sig branch
|
|
@@ -555,7 +555,7 @@ describe("snapshot edge cases", () => {
|
|
|
555
555
|
expect(snap).toEqual({})
|
|
556
556
|
})
|
|
557
557
|
|
|
558
|
-
it(
|
|
558
|
+
it('applySnapshot skips keys not present in the snapshot object', () => {
|
|
559
559
|
const M = model({ state: { a: 1, b: 2, c: 3 } })
|
|
560
560
|
const m = M.create({ a: 10, b: 20, c: 30 })
|
|
561
561
|
// Only apply 'b' — 'a' and 'c' should remain unchanged
|
|
@@ -565,7 +565,7 @@ describe("snapshot edge cases", () => {
|
|
|
565
565
|
expect(m.c()).toBe(30)
|
|
566
566
|
})
|
|
567
567
|
|
|
568
|
-
it(
|
|
568
|
+
it('applySnapshot skips keys whose signal does not exist (!sig branch)', () => {
|
|
569
569
|
const c = Counter.create({ count: 5 })
|
|
570
570
|
// Delete the signal to simulate the !sig branch in applySnapshot
|
|
571
571
|
delete (c as any).count
|
|
@@ -576,95 +576,95 @@ describe("snapshot edge cases", () => {
|
|
|
576
576
|
|
|
577
577
|
// ─── Nested models ────────────────────────────────────────────────────────────
|
|
578
578
|
|
|
579
|
-
describe(
|
|
580
|
-
it(
|
|
579
|
+
describe('nested models', () => {
|
|
580
|
+
it('creates nested instance from snapshot', () => {
|
|
581
581
|
const app = App.create({
|
|
582
|
-
profile: { name:
|
|
583
|
-
title:
|
|
582
|
+
profile: { name: 'Alice', bio: 'dev' },
|
|
583
|
+
title: 'App',
|
|
584
584
|
})
|
|
585
|
-
expect(app.profile().name()).toBe(
|
|
585
|
+
expect(app.profile().name()).toBe('Alice')
|
|
586
586
|
})
|
|
587
587
|
|
|
588
|
-
it(
|
|
588
|
+
it('nested instance has its own actions', () => {
|
|
589
589
|
const app = App.create({
|
|
590
|
-
profile: { name:
|
|
591
|
-
title:
|
|
590
|
+
profile: { name: 'Alice', bio: '' },
|
|
591
|
+
title: 'App',
|
|
592
592
|
})
|
|
593
|
-
app.profile().rename(
|
|
594
|
-
expect(app.profile().name()).toBe(
|
|
593
|
+
app.profile().rename('Bob')
|
|
594
|
+
expect(app.profile().name()).toBe('Bob')
|
|
595
595
|
})
|
|
596
596
|
|
|
597
|
-
it(
|
|
597
|
+
it('nested defaults used when no snapshot provided', () => {
|
|
598
598
|
const app = App.create()
|
|
599
|
-
expect(app.profile().name()).toBe(
|
|
600
|
-
expect(app.title()).toBe(
|
|
599
|
+
expect(app.profile().name()).toBe('')
|
|
600
|
+
expect(app.title()).toBe('My App')
|
|
601
601
|
})
|
|
602
602
|
|
|
603
|
-
it(
|
|
603
|
+
it('getSnapshot recursively serializes nested instances', () => {
|
|
604
604
|
const app = App.create({
|
|
605
|
-
profile: { name:
|
|
606
|
-
title:
|
|
605
|
+
profile: { name: 'Alice', bio: 'dev' },
|
|
606
|
+
title: 'Hello',
|
|
607
607
|
})
|
|
608
608
|
const snap = getSnapshot(app)
|
|
609
609
|
expect(snap).toEqual({
|
|
610
|
-
profile: { name:
|
|
611
|
-
title:
|
|
610
|
+
profile: { name: 'Alice', bio: 'dev' },
|
|
611
|
+
title: 'Hello',
|
|
612
612
|
})
|
|
613
613
|
})
|
|
614
614
|
|
|
615
|
-
it(
|
|
616
|
-
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: '' })
|
|
617
617
|
const patches: Patch[] = []
|
|
618
618
|
onPatch(app, (p) => patches.push(p))
|
|
619
|
-
app.profile().rename(
|
|
619
|
+
app.profile().rename('Bob')
|
|
620
620
|
expect(patches).toHaveLength(1)
|
|
621
621
|
expect(patches[0]).toEqual({
|
|
622
|
-
op:
|
|
623
|
-
path:
|
|
624
|
-
value:
|
|
622
|
+
op: 'replace',
|
|
623
|
+
path: '/profile/name',
|
|
624
|
+
value: 'Bob',
|
|
625
625
|
})
|
|
626
626
|
})
|
|
627
627
|
|
|
628
|
-
it(
|
|
628
|
+
it('applySnapshot restores nested state', () => {
|
|
629
629
|
const app = App.create({
|
|
630
|
-
profile: { name:
|
|
631
|
-
title:
|
|
630
|
+
profile: { name: 'Alice', bio: '' },
|
|
631
|
+
title: 'old',
|
|
632
632
|
})
|
|
633
|
-
applySnapshot(app, { profile: { name:
|
|
634
|
-
expect(app.profile().name()).toBe(
|
|
635
|
-
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')
|
|
636
636
|
})
|
|
637
637
|
})
|
|
638
638
|
|
|
639
639
|
// ─── applyPatch ──────────────────────────────────────────────────────────────
|
|
640
640
|
|
|
641
|
-
describe(
|
|
642
|
-
it(
|
|
641
|
+
describe('applyPatch', () => {
|
|
642
|
+
it('applies a single replace patch', () => {
|
|
643
643
|
const c = Counter.create({ count: 0 })
|
|
644
|
-
applyPatch(c, { op:
|
|
644
|
+
applyPatch(c, { op: 'replace', path: '/count', value: 42 })
|
|
645
645
|
expect(c.count()).toBe(42)
|
|
646
646
|
})
|
|
647
647
|
|
|
648
|
-
it(
|
|
648
|
+
it('applies an array of patches', () => {
|
|
649
649
|
const c = Counter.create({ count: 0 })
|
|
650
650
|
applyPatch(c, [
|
|
651
|
-
{ op:
|
|
652
|
-
{ op:
|
|
653
|
-
{ op:
|
|
651
|
+
{ op: 'replace', path: '/count', value: 1 },
|
|
652
|
+
{ op: 'replace', path: '/count', value: 2 },
|
|
653
|
+
{ op: 'replace', path: '/count', value: 3 },
|
|
654
654
|
])
|
|
655
655
|
expect(c.count()).toBe(3)
|
|
656
656
|
})
|
|
657
657
|
|
|
658
|
-
it(
|
|
658
|
+
it('applies patch to nested model instance', () => {
|
|
659
659
|
const app = App.create({
|
|
660
|
-
profile: { name:
|
|
661
|
-
title:
|
|
660
|
+
profile: { name: 'Alice', bio: '' },
|
|
661
|
+
title: 'old',
|
|
662
662
|
})
|
|
663
|
-
applyPatch(app, { op:
|
|
664
|
-
expect(app.profile().name()).toBe(
|
|
663
|
+
applyPatch(app, { op: 'replace', path: '/profile/name', value: 'Bob' })
|
|
664
|
+
expect(app.profile().name()).toBe('Bob')
|
|
665
665
|
})
|
|
666
666
|
|
|
667
|
-
it(
|
|
667
|
+
it('roundtrips with onPatch — record and replay', () => {
|
|
668
668
|
const c = Counter.create({ count: 0 })
|
|
669
669
|
const patches: Patch[] = []
|
|
670
670
|
onPatch(c, (p) => patches.push({ ...p }))
|
|
@@ -675,9 +675,9 @@ describe("applyPatch", () => {
|
|
|
675
675
|
expect(c.count()).toBe(12)
|
|
676
676
|
expect(patches).toHaveLength(3)
|
|
677
677
|
// Verify patches contain the final values at each step
|
|
678
|
-
expect(patches[0]).toEqual({ op:
|
|
679
|
-
expect(patches[1]).toEqual({ op:
|
|
680
|
-
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 })
|
|
681
681
|
|
|
682
682
|
// Replay on a fresh instance
|
|
683
683
|
const c2 = Counter.create({ count: 0 })
|
|
@@ -685,27 +685,27 @@ describe("applyPatch", () => {
|
|
|
685
685
|
expect(c2.count()).toBe(12)
|
|
686
686
|
})
|
|
687
687
|
|
|
688
|
-
it(
|
|
689
|
-
expect(() => applyPatch({}, { op:
|
|
690
|
-
|
|
688
|
+
it('throws for non-model instance', () => {
|
|
689
|
+
expect(() => applyPatch({}, { op: 'replace', path: '/x', value: 1 })).toThrow(
|
|
690
|
+
'not a model instance',
|
|
691
691
|
)
|
|
692
692
|
})
|
|
693
693
|
|
|
694
|
-
it(
|
|
694
|
+
it('throws for empty path', () => {
|
|
695
695
|
const c = Counter.create({ count: 0 })
|
|
696
|
-
expect(() => applyPatch(c, { op:
|
|
696
|
+
expect(() => applyPatch(c, { op: 'replace', path: '', value: 1 })).toThrow('empty path')
|
|
697
697
|
})
|
|
698
698
|
|
|
699
|
-
it(
|
|
699
|
+
it('throws for unknown state key', () => {
|
|
700
700
|
const c = Counter.create({ count: 0 })
|
|
701
|
-
expect(() => applyPatch(c, { op:
|
|
702
|
-
|
|
701
|
+
expect(() => applyPatch(c, { op: 'replace', path: '/nonexistent', value: 1 })).toThrow(
|
|
702
|
+
'unknown state key',
|
|
703
703
|
)
|
|
704
704
|
})
|
|
705
705
|
|
|
706
|
-
it(
|
|
706
|
+
it('throws for unsupported op', () => {
|
|
707
707
|
const c = Counter.create({ count: 0 })
|
|
708
|
-
expect(() => applyPatch(c, { op:
|
|
708
|
+
expect(() => applyPatch(c, { op: 'add' as any, path: '/count', value: 1 })).toThrow(
|
|
709
709
|
'unsupported op "add"',
|
|
710
710
|
)
|
|
711
711
|
})
|