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