@pyreon/state-tree 0.24.4 → 0.24.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/package.json +3 -6
- package/src/devtools.ts +0 -85
- package/src/index.ts +0 -29
- package/src/instance.ts +0 -128
- package/src/manifest.ts +0 -161
- package/src/middleware.ts +0 -53
- package/src/model.ts +0 -107
- package/src/patch.ts +0 -156
- package/src/registry.ts +0 -12
- package/src/snapshot.ts +0 -62
- package/src/tests/comprehensive.test.ts +0 -485
- package/src/tests/devtools.test.ts +0 -163
- package/src/tests/edge-cases.test.ts +0 -715
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/model.test.ts +0 -712
- package/src/types.ts +0 -94
|
@@ -1,485 +0,0 @@
|
|
|
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
|
-
|
|
14
|
-
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
const Counter = model({
|
|
17
|
-
state: { count: 0 },
|
|
18
|
-
views: (self) => ({
|
|
19
|
-
doubled: computed(() => self.count() * 2),
|
|
20
|
-
}),
|
|
21
|
-
actions: (self) => ({
|
|
22
|
-
inc: () => self.count.update((c: number) => c + 1),
|
|
23
|
-
dec: () => self.count.update((c: number) => c - 1),
|
|
24
|
-
add: (n: number) => self.count.update((c: number) => c + n),
|
|
25
|
-
reset: () => self.count.set(0),
|
|
26
|
-
}),
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
const Profile = model({
|
|
30
|
-
state: { name: '', bio: '' },
|
|
31
|
-
actions: (self) => ({
|
|
32
|
-
rename: (n: string) => self.name.set(n),
|
|
33
|
-
setBio: (b: string) => self.bio.set(b),
|
|
34
|
-
}),
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
const App = model({
|
|
38
|
-
state: { profile: Profile, title: 'My App' },
|
|
39
|
-
actions: (self) => ({
|
|
40
|
-
setTitle: (t: string) => self.title.set(t),
|
|
41
|
-
}),
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
// ─── getSnapshot — JSON-serializable output ────────────────────────────────
|
|
45
|
-
|
|
46
|
-
describe('getSnapshot — JSON-serializable output', () => {
|
|
47
|
-
it("returns a plain object that can be JSON.stringify'd and parsed back", () => {
|
|
48
|
-
const c = Counter.create({ count: 42 })
|
|
49
|
-
const snap = getSnapshot(c)
|
|
50
|
-
const json = JSON.stringify(snap)
|
|
51
|
-
const parsed = JSON.parse(json)
|
|
52
|
-
expect(parsed).toEqual({ count: 42 })
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('snapshot contains no signal functions', () => {
|
|
56
|
-
const c = Counter.create({ count: 5 })
|
|
57
|
-
const snap = getSnapshot(c)
|
|
58
|
-
for (const val of Object.values(snap)) {
|
|
59
|
-
expect(typeof val).not.toBe('function')
|
|
60
|
-
}
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('nested model snapshot is fully serializable', () => {
|
|
64
|
-
const app = App.create({
|
|
65
|
-
profile: { name: 'Alice', bio: 'dev' },
|
|
66
|
-
title: 'Test',
|
|
67
|
-
})
|
|
68
|
-
const snap = getSnapshot(app)
|
|
69
|
-
const json = JSON.stringify(snap)
|
|
70
|
-
const parsed = JSON.parse(json)
|
|
71
|
-
expect(parsed).toEqual({
|
|
72
|
-
profile: { name: 'Alice', bio: 'dev' },
|
|
73
|
-
title: 'Test',
|
|
74
|
-
})
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('snapshot does not include views or actions', () => {
|
|
78
|
-
const c = Counter.create({ count: 3 })
|
|
79
|
-
const snap = getSnapshot(c)
|
|
80
|
-
expect(snap).toEqual({ count: 3 })
|
|
81
|
-
expect(snap).not.toHaveProperty('doubled')
|
|
82
|
-
expect(snap).not.toHaveProperty('inc')
|
|
83
|
-
})
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
// ─── applySnapshot — restores model state ──────────────────────────────────
|
|
87
|
-
|
|
88
|
-
describe('applySnapshot — restores model state', () => {
|
|
89
|
-
it('restores a complete snapshot', () => {
|
|
90
|
-
const c = Counter.create({ count: 99 })
|
|
91
|
-
applySnapshot(c, { count: 0 })
|
|
92
|
-
expect(c.count()).toBe(0)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('restores partial snapshot — only specified keys', () => {
|
|
96
|
-
const M = model({ state: { a: 1, b: 2, c: 3 } })
|
|
97
|
-
const m = M.create({ a: 10, b: 20, c: 30 })
|
|
98
|
-
applySnapshot(m, { b: 99 })
|
|
99
|
-
expect(m.a()).toBe(10)
|
|
100
|
-
expect(m.b()).toBe(99)
|
|
101
|
-
expect(m.c()).toBe(30)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('restores nested model state recursively', () => {
|
|
105
|
-
const app = App.create({
|
|
106
|
-
profile: { name: 'Alice', bio: 'dev' },
|
|
107
|
-
title: 'Old',
|
|
108
|
-
})
|
|
109
|
-
applySnapshot(app, {
|
|
110
|
-
profile: { name: 'Bob', bio: 'engineer' },
|
|
111
|
-
title: 'New',
|
|
112
|
-
})
|
|
113
|
-
expect(app.profile().name()).toBe('Bob')
|
|
114
|
-
expect(app.profile().bio()).toBe('engineer')
|
|
115
|
-
expect(app.title()).toBe('New')
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('roundtrips: getSnapshot -> applySnapshot produces same state', () => {
|
|
119
|
-
const app = App.create({
|
|
120
|
-
profile: { name: 'Carol', bio: 'designer' },
|
|
121
|
-
title: 'Portfolio',
|
|
122
|
-
})
|
|
123
|
-
const snap = getSnapshot(app)
|
|
124
|
-
|
|
125
|
-
const app2 = App.create()
|
|
126
|
-
applySnapshot(app2, snap)
|
|
127
|
-
expect(getSnapshot(app2)).toEqual(snap)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('batches updates — effect fires once for multi-field snapshot', () => {
|
|
131
|
-
const M = model({ state: { x: 0, y: 0, z: 0 } })
|
|
132
|
-
const m = M.create()
|
|
133
|
-
let effectRuns = 0
|
|
134
|
-
effect(() => {
|
|
135
|
-
m.x()
|
|
136
|
-
m.y()
|
|
137
|
-
m.z()
|
|
138
|
-
effectRuns++
|
|
139
|
-
})
|
|
140
|
-
effectRuns = 0
|
|
141
|
-
applySnapshot(m, { x: 1, y: 2, z: 3 })
|
|
142
|
-
expect(effectRuns).toBe(1)
|
|
143
|
-
})
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
// ─── onPatch — listener receives correct format ────────────────────────────
|
|
147
|
-
|
|
148
|
-
describe('onPatch — patch format', () => {
|
|
149
|
-
it('patch has op, path, and value fields', () => {
|
|
150
|
-
const c = Counter.create()
|
|
151
|
-
const patches: Patch[] = []
|
|
152
|
-
onPatch(c, (p) => patches.push(p))
|
|
153
|
-
c.inc()
|
|
154
|
-
expect(patches).toHaveLength(1)
|
|
155
|
-
expect(patches[0]).toHaveProperty('op', 'replace')
|
|
156
|
-
expect(patches[0]).toHaveProperty('path', '/count')
|
|
157
|
-
expect(patches[0]).toHaveProperty('value', 1)
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('path uses JSON pointer format with leading slash', () => {
|
|
161
|
-
const c = Counter.create()
|
|
162
|
-
const patches: Patch[] = []
|
|
163
|
-
onPatch(c, (p) => patches.push(p))
|
|
164
|
-
c.add(5)
|
|
165
|
-
expect(patches[0]!.path).toMatch(/^\//)
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
it('nested model patches have composite paths', () => {
|
|
169
|
-
const app = App.create({ profile: { name: 'A', bio: '' }, title: '' })
|
|
170
|
-
const patches: Patch[] = []
|
|
171
|
-
onPatch(app, (p) => patches.push(p))
|
|
172
|
-
|
|
173
|
-
app.profile().rename('B')
|
|
174
|
-
expect(patches[0]!.path).toBe('/profile/name')
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
it('value contains new value after mutation, not old', () => {
|
|
178
|
-
const c = Counter.create({ count: 10 })
|
|
179
|
-
const patches: Patch[] = []
|
|
180
|
-
onPatch(c, (p) => patches.push(p))
|
|
181
|
-
|
|
182
|
-
c.add(5)
|
|
183
|
-
expect(patches[0]!.value).toBe(15)
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
it('emits patches for each signal write in sequence', () => {
|
|
187
|
-
const c = Counter.create()
|
|
188
|
-
const patches: Patch[] = []
|
|
189
|
-
onPatch(c, (p) => patches.push(p))
|
|
190
|
-
|
|
191
|
-
c.inc()
|
|
192
|
-
c.inc()
|
|
193
|
-
c.add(10)
|
|
194
|
-
|
|
195
|
-
expect(patches).toHaveLength(3)
|
|
196
|
-
expect(patches.map((p) => p.value)).toEqual([1, 2, 12])
|
|
197
|
-
})
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
// ─── applyPatch — applies patches correctly ────────────────────────────────
|
|
201
|
-
|
|
202
|
-
describe('applyPatch — applies patches', () => {
|
|
203
|
-
it('applies a single replace patch to top-level field', () => {
|
|
204
|
-
const c = Counter.create()
|
|
205
|
-
applyPatch(c, { op: 'replace', path: '/count', value: 42 })
|
|
206
|
-
expect(c.count()).toBe(42)
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
it('applies array of patches in order', () => {
|
|
210
|
-
const c = Counter.create()
|
|
211
|
-
applyPatch(c, [
|
|
212
|
-
{ op: 'replace', path: '/count', value: 5 },
|
|
213
|
-
{ op: 'replace', path: '/count', value: 10 },
|
|
214
|
-
])
|
|
215
|
-
expect(c.count()).toBe(10)
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
it('applies patches to nested model instances', () => {
|
|
219
|
-
const app = App.create({
|
|
220
|
-
profile: { name: 'A', bio: 'b' },
|
|
221
|
-
title: 't',
|
|
222
|
-
})
|
|
223
|
-
applyPatch(app, { op: 'replace', path: '/profile/name', value: 'B' })
|
|
224
|
-
expect(app.profile().name()).toBe('B')
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
it('roundtrip: record patches with onPatch, replay on fresh instance', () => {
|
|
228
|
-
const original = Counter.create()
|
|
229
|
-
const patches: Patch[] = []
|
|
230
|
-
onPatch(original, (p) => patches.push({ ...p }))
|
|
231
|
-
|
|
232
|
-
original.inc()
|
|
233
|
-
original.add(10)
|
|
234
|
-
original.dec()
|
|
235
|
-
|
|
236
|
-
const replica = Counter.create()
|
|
237
|
-
applyPatch(replica, patches)
|
|
238
|
-
expect(replica.count()).toBe(original.count())
|
|
239
|
-
expect(getSnapshot(replica)).toEqual(getSnapshot(original))
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
it('throws for unsupported op', () => {
|
|
243
|
-
const c = Counter.create()
|
|
244
|
-
expect(() => applyPatch(c, { op: 'add' as any, path: '/count', value: 1 })).toThrow(
|
|
245
|
-
'unsupported op',
|
|
246
|
-
)
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
it('throws for empty path', () => {
|
|
250
|
-
const c = Counter.create()
|
|
251
|
-
expect(() => applyPatch(c, { op: 'replace', path: '', value: 1 })).toThrow('empty path')
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
it('throws for unknown key', () => {
|
|
255
|
-
const c = Counter.create()
|
|
256
|
-
expect(() => applyPatch(c, { op: 'replace', path: '/unknown', value: 1 })).toThrow(
|
|
257
|
-
'unknown state key',
|
|
258
|
-
)
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
it('throws for non-model instance', () => {
|
|
262
|
-
expect(() => applyPatch({}, { op: 'replace', path: '/x', value: 1 })).toThrow(
|
|
263
|
-
'not a model instance',
|
|
264
|
-
)
|
|
265
|
-
})
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
// ─── addMiddleware — intercepts actions ────────────────────────────────────
|
|
269
|
-
|
|
270
|
-
describe('addMiddleware — intercepts actions', () => {
|
|
271
|
-
it('captures action name and args', () => {
|
|
272
|
-
const c = Counter.create()
|
|
273
|
-
const calls: { name: string; args: unknown[] }[] = []
|
|
274
|
-
addMiddleware(c, (call, next) => {
|
|
275
|
-
calls.push({ name: call.name, args: [...call.args] })
|
|
276
|
-
return next(call)
|
|
277
|
-
})
|
|
278
|
-
c.add(5)
|
|
279
|
-
expect(calls).toEqual([{ name: 'add', args: [5] }])
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
it('middleware can block action by not calling next', () => {
|
|
283
|
-
const c = Counter.create()
|
|
284
|
-
addMiddleware(c, () => {
|
|
285
|
-
/* intentionally block */
|
|
286
|
-
})
|
|
287
|
-
c.inc()
|
|
288
|
-
expect(c.count()).toBe(0)
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
it('middleware can modify args', () => {
|
|
292
|
-
const c = Counter.create()
|
|
293
|
-
addMiddleware(c, (call, next) => {
|
|
294
|
-
if (call.name === 'add') {
|
|
295
|
-
return next({ ...call, args: [(call.args[0] as number) * 3] })
|
|
296
|
-
}
|
|
297
|
-
return next(call)
|
|
298
|
-
})
|
|
299
|
-
c.add(5)
|
|
300
|
-
expect(c.count()).toBe(15) // 5 * 3
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
it('unsub removes the middleware', () => {
|
|
304
|
-
const c = Counter.create()
|
|
305
|
-
const log: string[] = []
|
|
306
|
-
const unsub = addMiddleware(c, (call, next) => {
|
|
307
|
-
log.push(call.name)
|
|
308
|
-
return next(call)
|
|
309
|
-
})
|
|
310
|
-
c.inc()
|
|
311
|
-
expect(log).toHaveLength(1)
|
|
312
|
-
|
|
313
|
-
unsub()
|
|
314
|
-
c.inc()
|
|
315
|
-
expect(log).toHaveLength(1) // no new entries
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
it('multiple middlewares execute in Koa-style onion order', () => {
|
|
319
|
-
const c = Counter.create()
|
|
320
|
-
const log: string[] = []
|
|
321
|
-
addMiddleware(c, (call, next) => {
|
|
322
|
-
log.push('A:before')
|
|
323
|
-
const r = next(call)
|
|
324
|
-
log.push('A:after')
|
|
325
|
-
return r
|
|
326
|
-
})
|
|
327
|
-
addMiddleware(c, (call, next) => {
|
|
328
|
-
log.push('B:before')
|
|
329
|
-
const r = next(call)
|
|
330
|
-
log.push('B:after')
|
|
331
|
-
return r
|
|
332
|
-
})
|
|
333
|
-
c.inc()
|
|
334
|
-
expect(log).toEqual(['A:before', 'B:before', 'B:after', 'A:after'])
|
|
335
|
-
})
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
// ─── Nested model composition ──────────────────────────────────────────────
|
|
339
|
-
|
|
340
|
-
describe('nested model composition', () => {
|
|
341
|
-
it('deeply nested models work correctly', () => {
|
|
342
|
-
const Leaf = model({
|
|
343
|
-
state: { val: 0 },
|
|
344
|
-
actions: (self) => ({
|
|
345
|
-
set: (v: number) => self.val.set(v),
|
|
346
|
-
}),
|
|
347
|
-
})
|
|
348
|
-
const Branch = model({
|
|
349
|
-
state: { leaf: Leaf, tag: '' },
|
|
350
|
-
actions: (self) => ({
|
|
351
|
-
setTag: (t: string) => self.tag.set(t),
|
|
352
|
-
}),
|
|
353
|
-
})
|
|
354
|
-
const Root = model({
|
|
355
|
-
state: { branch: Branch, name: 'root' },
|
|
356
|
-
})
|
|
357
|
-
|
|
358
|
-
const root = Root.create({
|
|
359
|
-
branch: { leaf: { val: 42 }, tag: 'test' },
|
|
360
|
-
name: 'myRoot',
|
|
361
|
-
})
|
|
362
|
-
|
|
363
|
-
expect(root.branch().leaf().val()).toBe(42)
|
|
364
|
-
expect(root.branch().tag()).toBe('test')
|
|
365
|
-
expect(root.name()).toBe('myRoot')
|
|
366
|
-
})
|
|
367
|
-
|
|
368
|
-
it('nested model patches propagate up with correct paths', () => {
|
|
369
|
-
const Leaf = model({
|
|
370
|
-
state: { val: 0 },
|
|
371
|
-
actions: (self) => ({
|
|
372
|
-
setVal: (v: number) => self.val.set(v),
|
|
373
|
-
}),
|
|
374
|
-
})
|
|
375
|
-
const Branch = model({
|
|
376
|
-
state: { leaf: Leaf },
|
|
377
|
-
})
|
|
378
|
-
const Root = model({
|
|
379
|
-
state: { branch: Branch },
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
const root = Root.create()
|
|
383
|
-
const patches: Patch[] = []
|
|
384
|
-
onPatch(root, (p) => patches.push(p))
|
|
385
|
-
|
|
386
|
-
root.branch().leaf().setVal(99)
|
|
387
|
-
expect(patches).toHaveLength(1)
|
|
388
|
-
expect(patches[0]!.path).toBe('/branch/leaf/val')
|
|
389
|
-
expect(patches[0]!.value).toBe(99)
|
|
390
|
-
})
|
|
391
|
-
|
|
392
|
-
it('nested getSnapshot serializes all levels', () => {
|
|
393
|
-
const Leaf = model({ state: { x: 1 } })
|
|
394
|
-
const Mid = model({ state: { leaf: Leaf, y: 2 } })
|
|
395
|
-
const Top = model({ state: { mid: Mid, z: 3 } })
|
|
396
|
-
|
|
397
|
-
const top = Top.create()
|
|
398
|
-
expect(getSnapshot(top)).toEqual({
|
|
399
|
-
mid: { leaf: { x: 1 }, y: 2 },
|
|
400
|
-
z: 3,
|
|
401
|
-
})
|
|
402
|
-
})
|
|
403
|
-
|
|
404
|
-
it('applyPatch to deeply nested path works', () => {
|
|
405
|
-
const Leaf = model({ state: { x: 0 } })
|
|
406
|
-
const Mid = model({ state: { leaf: Leaf } })
|
|
407
|
-
const Top = model({ state: { mid: Mid } })
|
|
408
|
-
|
|
409
|
-
const top = Top.create()
|
|
410
|
-
applyPatch(top, { op: 'replace', path: '/mid/leaf/x', value: 999 })
|
|
411
|
-
expect(top.mid().leaf().x()).toBe(999)
|
|
412
|
-
})
|
|
413
|
-
})
|
|
414
|
-
|
|
415
|
-
// ─── asHook — singleton hook ───────────────────────────────────────────────
|
|
416
|
-
|
|
417
|
-
describe('asHook — creates singleton hook', () => {
|
|
418
|
-
afterEach(() => resetAllHooks())
|
|
419
|
-
|
|
420
|
-
it('returns the same instance every time', () => {
|
|
421
|
-
const useC = Counter.asHook('hook-same')
|
|
422
|
-
const a = useC()
|
|
423
|
-
const b = useC()
|
|
424
|
-
expect(a).toBe(b)
|
|
425
|
-
})
|
|
426
|
-
|
|
427
|
-
it('state mutations persist across calls', () => {
|
|
428
|
-
const useC = Counter.asHook('hook-persist')
|
|
429
|
-
useC().add(10)
|
|
430
|
-
expect(useC().count()).toBe(10)
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
it('different ids yield independent instances', () => {
|
|
434
|
-
const useA = Counter.asHook('hook-id-a')
|
|
435
|
-
const useB = Counter.asHook('hook-id-b')
|
|
436
|
-
useA().add(5)
|
|
437
|
-
expect(useA().count()).toBe(5)
|
|
438
|
-
expect(useB().count()).toBe(0)
|
|
439
|
-
})
|
|
440
|
-
|
|
441
|
-
it('resetHook clears specific singleton', () => {
|
|
442
|
-
const useC = Counter.asHook('hook-reset-2')
|
|
443
|
-
useC().add(100)
|
|
444
|
-
resetHook('hook-reset-2')
|
|
445
|
-
expect(useC().count()).toBe(0)
|
|
446
|
-
})
|
|
447
|
-
|
|
448
|
-
it('resetAllHooks clears all singletons', () => {
|
|
449
|
-
const useA = Counter.asHook('hook-all-1')
|
|
450
|
-
const useB = Counter.asHook('hook-all-2')
|
|
451
|
-
useA().add(5)
|
|
452
|
-
useB().add(10)
|
|
453
|
-
|
|
454
|
-
resetAllHooks()
|
|
455
|
-
|
|
456
|
-
expect(useA().count()).toBe(0)
|
|
457
|
-
expect(useB().count()).toBe(0)
|
|
458
|
-
})
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
// ─── Effect reactivity ─────────────────────────────────────────────────────
|
|
462
|
-
|
|
463
|
-
describe('effect reactivity with model instances', () => {
|
|
464
|
-
it('effect tracks signal reads from model instance', () => {
|
|
465
|
-
const c = Counter.create()
|
|
466
|
-
const observed: number[] = []
|
|
467
|
-
effect(() => {
|
|
468
|
-
observed.push(c.count())
|
|
469
|
-
})
|
|
470
|
-
c.inc()
|
|
471
|
-
c.inc()
|
|
472
|
-
expect(observed).toEqual([0, 1, 2])
|
|
473
|
-
})
|
|
474
|
-
|
|
475
|
-
it('effect tracks computed views', () => {
|
|
476
|
-
const c = Counter.create({ count: 1 })
|
|
477
|
-
const observed: number[] = []
|
|
478
|
-
effect(() => {
|
|
479
|
-
observed.push(c.doubled())
|
|
480
|
-
})
|
|
481
|
-
c.inc()
|
|
482
|
-
c.add(5)
|
|
483
|
-
expect(observed).toEqual([2, 4, 14])
|
|
484
|
-
})
|
|
485
|
-
})
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
_resetDevtools,
|
|
3
|
-
getActiveModels,
|
|
4
|
-
getModelInstance,
|
|
5
|
-
getModelSnapshot,
|
|
6
|
-
onModelChange,
|
|
7
|
-
registerInstance,
|
|
8
|
-
unregisterInstance,
|
|
9
|
-
} from '../devtools'
|
|
10
|
-
import { model } from '../index'
|
|
11
|
-
|
|
12
|
-
const Counter = model({
|
|
13
|
-
state: { count: 0 },
|
|
14
|
-
actions: (self) => ({
|
|
15
|
-
inc: () => self.count.update((c: number) => c + 1),
|
|
16
|
-
}),
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
afterEach(() => _resetDevtools())
|
|
20
|
-
|
|
21
|
-
describe('state-tree devtools', () => {
|
|
22
|
-
test('getActiveModels returns empty initially', () => {
|
|
23
|
-
expect(getActiveModels()).toEqual([])
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
test('registerInstance makes model visible', () => {
|
|
27
|
-
const counter = Counter.create()
|
|
28
|
-
registerInstance('app-counter', counter)
|
|
29
|
-
expect(getActiveModels()).toEqual(['app-counter'])
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
test('getModelInstance returns the registered instance', () => {
|
|
33
|
-
const counter = Counter.create()
|
|
34
|
-
registerInstance('app-counter', counter)
|
|
35
|
-
expect(getModelInstance('app-counter')).toBe(counter)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
test('getModelInstance returns undefined for unregistered name', () => {
|
|
39
|
-
expect(getModelInstance('nope')).toBeUndefined()
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
test('unregisterInstance removes the model', () => {
|
|
43
|
-
const counter = Counter.create()
|
|
44
|
-
registerInstance('app-counter', counter)
|
|
45
|
-
unregisterInstance('app-counter')
|
|
46
|
-
expect(getActiveModels()).toEqual([])
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('getModelSnapshot returns current snapshot', () => {
|
|
50
|
-
const counter = Counter.create({ count: 5 })
|
|
51
|
-
registerInstance('app-counter', counter)
|
|
52
|
-
expect(getModelSnapshot('app-counter')).toEqual({ count: 5 })
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
test('getModelSnapshot reflects mutations', () => {
|
|
56
|
-
const counter = Counter.create()
|
|
57
|
-
registerInstance('app-counter', counter)
|
|
58
|
-
counter.inc()
|
|
59
|
-
counter.inc()
|
|
60
|
-
expect(getModelSnapshot('app-counter')).toEqual({ count: 2 })
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('getModelSnapshot returns undefined for unregistered name', () => {
|
|
64
|
-
expect(getModelSnapshot('nope')).toBeUndefined()
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
test('onModelChange fires on register', () => {
|
|
68
|
-
const calls: number[] = []
|
|
69
|
-
const unsub = onModelChange(() => calls.push(1))
|
|
70
|
-
|
|
71
|
-
const counter = Counter.create()
|
|
72
|
-
registerInstance('app-counter', counter)
|
|
73
|
-
expect(calls.length).toBe(1)
|
|
74
|
-
|
|
75
|
-
unsub()
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
test('onModelChange fires on unregister', () => {
|
|
79
|
-
const counter = Counter.create()
|
|
80
|
-
registerInstance('app-counter', counter)
|
|
81
|
-
|
|
82
|
-
const calls: number[] = []
|
|
83
|
-
const unsub = onModelChange(() => calls.push(1))
|
|
84
|
-
unregisterInstance('app-counter')
|
|
85
|
-
expect(calls.length).toBe(1)
|
|
86
|
-
|
|
87
|
-
unsub()
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
test('onModelChange unsubscribe stops notifications', () => {
|
|
91
|
-
const calls: number[] = []
|
|
92
|
-
const unsub = onModelChange(() => calls.push(1))
|
|
93
|
-
unsub()
|
|
94
|
-
|
|
95
|
-
registerInstance('app-counter', Counter.create())
|
|
96
|
-
expect(calls.length).toBe(0)
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('multiple instances are tracked', () => {
|
|
100
|
-
registerInstance('a', Counter.create())
|
|
101
|
-
registerInstance('b', Counter.create())
|
|
102
|
-
expect(getActiveModels().sort()).toEqual(['a', 'b'])
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
test("getModelInstance returns undefined and cleans up when WeakRef target is GC'd", () => {
|
|
106
|
-
// We simulate a GC'd WeakRef by monkey-patching the registered WeakRef's deref.
|
|
107
|
-
const counter = Counter.create()
|
|
108
|
-
registerInstance('gc-test', counter)
|
|
109
|
-
|
|
110
|
-
// Verify it's accessible
|
|
111
|
-
expect(getModelInstance('gc-test')).toBe(counter)
|
|
112
|
-
|
|
113
|
-
// Now register a new entry with a fake WeakRef-like object that returns undefined.
|
|
114
|
-
// Since _activeModels is a Map<string, WeakRef<object>>, we can re-register
|
|
115
|
-
// to overwrite the entry, but registerInstance creates a real WeakRef.
|
|
116
|
-
// Instead, we'll unregister and then test getModelInstance on a missing key.
|
|
117
|
-
// But that tests the !ref branch (line 53), not the !instance branch (lines 55-57).
|
|
118
|
-
|
|
119
|
-
// The only way to test lines 55-57 is to have a WeakRef whose deref() returns undefined.
|
|
120
|
-
// We achieve this by creating the WeakRef with the mocked constructor before registering.
|
|
121
|
-
_resetDevtools()
|
|
122
|
-
|
|
123
|
-
const OriginalWeakRef = globalThis.WeakRef
|
|
124
|
-
let collected = false
|
|
125
|
-
class GCWeakRef {
|
|
126
|
-
_target: any
|
|
127
|
-
constructor(target: any) {
|
|
128
|
-
this._target = target
|
|
129
|
-
}
|
|
130
|
-
deref() {
|
|
131
|
-
return collected ? undefined : this._target
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
globalThis.WeakRef = GCWeakRef as any
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
const c2 = Counter.create()
|
|
138
|
-
registerInstance('gc-victim', c2)
|
|
139
|
-
|
|
140
|
-
// Before GC
|
|
141
|
-
expect(getModelInstance('gc-victim')).toBe(c2)
|
|
142
|
-
expect(getActiveModels()).toContain('gc-victim')
|
|
143
|
-
|
|
144
|
-
// Simulate GC
|
|
145
|
-
collected = true
|
|
146
|
-
|
|
147
|
-
// getActiveModels cleans up dead refs first (line 43 branch)
|
|
148
|
-
expect(getActiveModels()).not.toContain('gc-victim')
|
|
149
|
-
|
|
150
|
-
// Re-register to test getModelInstance's GC cleanup path (lines 55-57)
|
|
151
|
-
registerInstance('gc-victim-2', Counter.create())
|
|
152
|
-
collected = true
|
|
153
|
-
|
|
154
|
-
// getModelInstance hits lines 55-57: instance is undefined, deletes entry, returns undefined
|
|
155
|
-
expect(getModelInstance('gc-victim-2')).toBeUndefined()
|
|
156
|
-
|
|
157
|
-
// getModelSnapshot returns undefined for GC'd instance
|
|
158
|
-
expect(getModelSnapshot('gc-victim-2')).toBeUndefined()
|
|
159
|
-
} finally {
|
|
160
|
-
globalThis.WeakRef = OriginalWeakRef
|
|
161
|
-
}
|
|
162
|
-
})
|
|
163
|
-
})
|