@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.
@@ -1,712 +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
- 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('[@pyreon/state-tree]')
195
- })
196
-
197
- it('applySnapshot throws for non-model-instance', () => {
198
- expect(() => applySnapshot({} as object, {})).toThrow('[@pyreon/state-tree]')
199
- })
200
- })
201
-
202
- // ─── getSnapshot ──────────────────────────────────────────────────────────────
203
-
204
- describe('getSnapshot', () => {
205
- it('returns a plain JS object', () => {
206
- const c = Counter.create({ count: 7 })
207
- const snap = getSnapshot(c)
208
- expect(snap).toEqual({ count: 7 })
209
- expect(typeof snap.count).toBe('number')
210
- })
211
-
212
- it('snapshot reflects current state after mutations', () => {
213
- const c = Counter.create()
214
- c.inc()
215
- c.inc()
216
- c.inc()
217
- expect(getSnapshot(c)).toEqual({ count: 3 })
218
- })
219
-
220
- it('throws for non-model-instance values', () => {
221
- expect(() => getSnapshot({} as object)).toThrow('[@pyreon/state-tree]')
222
- })
223
- })
224
-
225
- // ─── applySnapshot ────────────────────────────────────────────────────────────
226
-
227
- describe('applySnapshot', () => {
228
- it('restores state from a plain snapshot', () => {
229
- const c = Counter.create({ count: 10 })
230
- applySnapshot(c, { count: 0 })
231
- expect(c.count()).toBe(0)
232
- })
233
-
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
- applySnapshot(c, { count: 99 })
238
- expect(c.count()).toBe(99)
239
- expect(c.label()).toBe('hello')
240
- })
241
-
242
- it('batch: effects fire once even for multi-field updates', () => {
243
- const M = model({ state: { a: 0, b: 0 } })
244
- const m = M.create()
245
- let effectRuns = 0
246
- effect(() => {
247
- m.a()
248
- m.b()
249
- effectRuns++
250
- })
251
- effectRuns = 0
252
- applySnapshot(m, { a: 1, b: 2 })
253
- expect(effectRuns).toBe(1)
254
- expect(m.a()).toBe(1)
255
- expect(m.b()).toBe(2)
256
- })
257
- })
258
-
259
- // ─── onPatch ──────────────────────────────────────────────────────────────────
260
-
261
- describe('onPatch', () => {
262
- it('fires when a signal is written', () => {
263
- const c = Counter.create()
264
- const patches: Patch[] = []
265
- onPatch(c, (p) => patches.push(p))
266
- c.inc()
267
- expect(patches).toHaveLength(1)
268
- expect(patches[0]).toEqual({ op: 'replace', path: '/count', value: 1 })
269
- })
270
-
271
- it('does NOT fire when value is unchanged', () => {
272
- const c = Counter.create()
273
- const patches: Patch[] = []
274
- onPatch(c, (p) => patches.push(p))
275
- c.count.set(0) // same value
276
- expect(patches).toHaveLength(0)
277
- })
278
-
279
- it('unsub stops patch events', () => {
280
- const c = Counter.create()
281
- const patches: Patch[] = []
282
- const unsub = onPatch(c, (p) => patches.push(p))
283
- unsub()
284
- c.inc()
285
- expect(patches).toHaveLength(0)
286
- })
287
-
288
- it('includes correct value in patch', () => {
289
- const c = Counter.create()
290
- const values: number[] = []
291
- onPatch(c, (p) => values.push(p.value as number))
292
- c.add(3)
293
- c.add(7)
294
- expect(values).toEqual([3, 10])
295
- })
296
- })
297
-
298
- // ─── addMiddleware ────────────────────────────────────────────────────────────
299
-
300
- describe('addMiddleware', () => {
301
- it('intercepts action calls', () => {
302
- const c = Counter.create()
303
- const intercepted: string[] = []
304
- addMiddleware(c, (call, next) => {
305
- intercepted.push(call.name)
306
- return next(call)
307
- })
308
- c.inc()
309
- expect(intercepted).toContain('inc')
310
- })
311
-
312
- it('next() executes the action', () => {
313
- const c = Counter.create()
314
- addMiddleware(c, (call, next) => next(call))
315
- c.add(5)
316
- expect(c.count()).toBe(5)
317
- })
318
-
319
- it('middleware can prevent action from running by not calling next', () => {
320
- const c = Counter.create()
321
- addMiddleware(c, (_call, _next) => {
322
- /* block */
323
- })
324
- c.inc()
325
- expect(c.count()).toBe(0)
326
- })
327
-
328
- it('multiple middlewares run in registration order', () => {
329
- const c = Counter.create()
330
- const log: string[] = []
331
- addMiddleware(c, (call, next) => {
332
- log.push('A')
333
- next(call)
334
- log.push("A'")
335
- })
336
- addMiddleware(c, (call, next) => {
337
- log.push('B')
338
- next(call)
339
- log.push("B'")
340
- })
341
- c.inc()
342
- // Koa-style: A→B→action→B'→A' (inner middleware unwraps first)
343
- expect(log).toEqual(['A', 'B', "B'", "A'"])
344
- })
345
-
346
- it('unsub removes the middleware', () => {
347
- const c = Counter.create()
348
- const log: string[] = []
349
- const unsub = addMiddleware(c, (call, next) => {
350
- log.push(call.name)
351
- return next(call)
352
- })
353
- unsub()
354
- c.inc()
355
- expect(log).toHaveLength(0)
356
- })
357
- })
358
-
359
- // ─── patch.ts trackedSignal coverage ─────────────────────────────────────────
360
-
361
- describe('trackedSignal extra paths', () => {
362
- it('subscribe on a state signal works (trackedSignal.subscribe)', () => {
363
- const c = Counter.create()
364
- const calls: number[] = []
365
- const unsub = c.count.subscribe(() => {
366
- calls.push(c.count())
367
- })
368
- c.inc()
369
- expect(calls).toContain(1)
370
- unsub()
371
- c.inc()
372
- // After unsub, no more notifications
373
- expect(calls).toHaveLength(1)
374
- })
375
-
376
- it('update on a state signal uses the updater function (trackedSignal.update)', () => {
377
- const c = Counter.create({ count: 5 })
378
- c.count.update((n: number) => n * 2)
379
- expect(c.count()).toBe(10)
380
- })
381
-
382
- it('peek on a state signal reads without tracking (trackedSignal.peek)', () => {
383
- const c = Counter.create({ count: 42 })
384
- expect(c.count.peek()).toBe(42)
385
- })
386
- })
387
-
388
- // ─── patch.ts snapshotValue coverage ─────────────────────────────────────────
389
-
390
- describe('patch snapshotValue', () => {
391
- it('emits a snapshot (not a live instance) when setting a nested model signal', () => {
392
- // When a nested model instance is set as a value and a patch listener is active,
393
- // snapshotValue should recursively serialize the nested model.
394
- const Inner = model({
395
- state: { x: 10, y: 20 },
396
- actions: (self) => ({
397
- setX: (v: number) => self.x.set(v),
398
- }),
399
- })
400
-
401
- const Outer = model({
402
- state: { child: Inner, label: 'hi' },
403
- actions: (self) => ({
404
- replaceChild: (newChild: any) => self.child.set(newChild),
405
- }),
406
- })
407
-
408
- const outer = Outer.create()
409
- const patches: Patch[] = []
410
- onPatch(outer, (p) => patches.push(p))
411
-
412
- // Create a new inner instance and set it — triggers snapshotValue on a model instance
413
- const newInner = Inner.create({ x: 99, y: 42 })
414
- outer.replaceChild(newInner)
415
-
416
- expect(patches).toHaveLength(1)
417
- expect(patches[0]!.op).toBe('replace')
418
- expect(patches[0]!.path).toBe('/child')
419
- // The value should be a plain snapshot, not the live model instance
420
- expect(patches[0]!.value).toEqual({ x: 99, y: 42 })
421
- expect(patches[0]!.value).not.toBe(newInner)
422
- })
423
-
424
- it('snapshotValue recursively serializes deeply nested model instances', () => {
425
- const Leaf = model({
426
- state: { val: 0 },
427
- })
428
- const Mid = model({
429
- state: { leaf: Leaf, tag: 'mid' },
430
- })
431
- const Root = model({
432
- state: { mid: Mid, name: 'root' },
433
- actions: (self) => ({
434
- replaceMid: (m: any) => self.mid.set(m),
435
- }),
436
- })
437
-
438
- const root = Root.create()
439
- const patches: Patch[] = []
440
- onPatch(root, (p) => patches.push(p))
441
-
442
- const newMid = Mid.create({ leaf: { val: 77 }, tag: 'new' })
443
- root.replaceMid(newMid)
444
-
445
- expect(patches[0]!.value).toEqual({ leaf: { val: 77 }, tag: 'new' })
446
- })
447
-
448
- it('snapshotValue returns the object as-is when it has no meta (!meta branch)', () => {
449
- // To trigger the !meta branch in snapshotValue, we need isModelInstance to return true
450
- // for an object that has no actual meta. We do this by temporarily registering a
451
- // fake object in instanceMeta, making it pass isModelInstance, then deleting the meta
452
- // before the snapshot is taken... Actually, we can register a fake object in instanceMeta
453
- // to make isModelInstance true, then set it as a signal value.
454
- //
455
- // Simpler: register an object in instanceMeta with stateKeys pointing to missing props.
456
- const Inner = model({
457
- state: { x: 10 },
458
- })
459
-
460
- const Outer = model({
461
- state: { child: Inner },
462
- actions: (self) => ({
463
- replaceChild: (c: any) => self.child.set(c),
464
- }),
465
- })
466
-
467
- const outer = Outer.create()
468
- const patches: Patch[] = []
469
- onPatch(outer, (p) => patches.push(p))
470
-
471
- // Create a fake "model instance" — register in instanceMeta so isModelInstance returns true,
472
- // but with stateKeys that reference properties that don't exist on the object (!sig branch)
473
- const fakeInstance = {} as any
474
- instanceMeta.set(fakeInstance, {
475
- stateKeys: ['missing'],
476
- patchListeners: new Set(),
477
- middlewares: [],
478
- emitPatch: () => {
479
- /* noop */
480
- },
481
- })
482
-
483
- outer.replaceChild(fakeInstance)
484
- // snapshotValue is called, meta exists, iterates stateKeys ["missing"],
485
- // sig = fakeInstance["missing"] = undefined → !sig → continue → returns {}
486
- expect(patches[0]!.value).toEqual({})
487
- })
488
-
489
- it('snapshotValue handles stateKey with nested model value recursively in patches', () => {
490
- // Ensure the recursive path in snapshotValue is covered:
491
- // when a stateKey's peek() returns a model instance, it recurses.
492
- const Leaf = model({ state: { v: 1 } })
493
- const Branch = model({
494
- state: { leaf: Leaf, tag: 'a' },
495
- })
496
- const Root = model({
497
- state: { branch: Branch },
498
- actions: (self) => ({
499
- replaceBranch: (b: any) => self.branch.set(b),
500
- }),
501
- })
502
-
503
- const root = Root.create()
504
- const patches: Patch[] = []
505
- onPatch(root, (p) => patches.push(p))
506
-
507
- const newBranch = Branch.create({ leaf: { v: 99 }, tag: 'b' })
508
- root.replaceBranch(newBranch)
509
-
510
- expect(patches[0]!.value).toEqual({ leaf: { v: 99 }, tag: 'b' })
511
- })
512
- })
513
-
514
- // ─── middleware.ts edge cases ────────────────────────────────────────────────
515
-
516
- describe('middleware edge cases', () => {
517
- it('action runs directly when middleware array is empty (idx >= length branch)', () => {
518
- const c = Counter.create()
519
- // No middleware added — dispatch(0, call) hits idx >= meta.middlewares.length immediately
520
- c.inc()
521
- expect(c.count()).toBe(1)
522
- })
523
-
524
- it('skips falsy middleware entries (!mw branch)', () => {
525
- const c = Counter.create()
526
- // Add a real middleware so the array is non-empty
527
- addMiddleware(c, (call, next) => next(call))
528
- // Inject a falsy entry at the beginning of the middlewares array
529
- const meta = instanceMeta.get(c)!
530
- meta.middlewares.unshift(undefined as any)
531
- // Action should still run — the !mw guard falls through to fn(...c.args)
532
- c.inc()
533
- expect(c.count()).toBe(1)
534
- })
535
-
536
- it('double-unsub is a no-op (indexOf returns -1 branch)', () => {
537
- const c = Counter.create()
538
- const unsub = addMiddleware(c, (call, next) => next(call))
539
- unsub()
540
- // Second unsub — middleware already removed, indexOf returns -1
541
- expect(() => unsub()).not.toThrow()
542
- })
543
- })
544
-
545
- // ─── snapshot.ts edge cases ──────────────────────────────────────────────────
546
-
547
- describe('snapshot edge cases', () => {
548
- it('getSnapshot skips keys whose signal does not exist (!sig branch)', () => {
549
- // Create an instance then tamper with it to have a missing signal for a state key
550
- const c = Counter.create({ count: 5 })
551
- // Delete the signal to simulate the !sig branch
552
- delete (c as any).count
553
- const snap = getSnapshot(c)
554
- // The key should be skipped — snapshot is empty
555
- expect(snap).toEqual({})
556
- })
557
-
558
- it('applySnapshot skips keys not present in the snapshot object', () => {
559
- const M = model({ state: { a: 1, b: 2, c: 3 } })
560
- const m = M.create({ a: 10, b: 20, c: 30 })
561
- // Only apply 'b' — 'a' and 'c' should remain unchanged
562
- applySnapshot(m, { b: 99 })
563
- expect(m.a()).toBe(10)
564
- expect(m.b()).toBe(99)
565
- expect(m.c()).toBe(30)
566
- })
567
-
568
- it('applySnapshot skips keys whose signal does not exist (!sig branch)', () => {
569
- const c = Counter.create({ count: 5 })
570
- // Delete the signal to simulate the !sig branch in applySnapshot
571
- delete (c as any).count
572
- // Should not throw — just skip the key
573
- expect(() => applySnapshot(c, { count: 0 })).not.toThrow()
574
- })
575
- })
576
-
577
- // ─── Nested models ────────────────────────────────────────────────────────────
578
-
579
- describe('nested models', () => {
580
- it('creates nested instance from snapshot', () => {
581
- const app = App.create({
582
- profile: { name: 'Alice', bio: 'dev' },
583
- title: 'App',
584
- })
585
- expect(app.profile().name()).toBe('Alice')
586
- })
587
-
588
- it('nested instance has its own actions', () => {
589
- const app = App.create({
590
- profile: { name: 'Alice', bio: '' },
591
- title: 'App',
592
- })
593
- app.profile().rename('Bob')
594
- expect(app.profile().name()).toBe('Bob')
595
- })
596
-
597
- it('nested defaults used when no snapshot provided', () => {
598
- const app = App.create()
599
- expect(app.profile().name()).toBe('')
600
- expect(app.title()).toBe('My App')
601
- })
602
-
603
- it('getSnapshot recursively serializes nested instances', () => {
604
- const app = App.create({
605
- profile: { name: 'Alice', bio: 'dev' },
606
- title: 'Hello',
607
- })
608
- const snap = getSnapshot(app)
609
- expect(snap).toEqual({
610
- profile: { name: 'Alice', bio: 'dev' },
611
- title: 'Hello',
612
- })
613
- })
614
-
615
- it('onPatch emits nested path for nested state change', () => {
616
- const app = App.create({ profile: { name: 'Alice', bio: '' }, title: '' })
617
- const patches: Patch[] = []
618
- onPatch(app, (p) => patches.push(p))
619
- app.profile().rename('Bob')
620
- expect(patches).toHaveLength(1)
621
- expect(patches[0]).toEqual({
622
- op: 'replace',
623
- path: '/profile/name',
624
- value: 'Bob',
625
- })
626
- })
627
-
628
- it('applySnapshot restores nested state', () => {
629
- const app = App.create({
630
- profile: { name: 'Alice', bio: '' },
631
- title: 'old',
632
- })
633
- applySnapshot(app, { profile: { name: 'Carol', bio: 'new' }, title: 'new' })
634
- expect(app.profile().name()).toBe('Carol')
635
- expect(app.title()).toBe('new')
636
- })
637
- })
638
-
639
- // ─── applyPatch ──────────────────────────────────────────────────────────────
640
-
641
- describe('applyPatch', () => {
642
- it('applies a single replace patch', () => {
643
- const c = Counter.create({ count: 0 })
644
- applyPatch(c, { op: 'replace', path: '/count', value: 42 })
645
- expect(c.count()).toBe(42)
646
- })
647
-
648
- it('applies an array of patches', () => {
649
- const c = Counter.create({ count: 0 })
650
- applyPatch(c, [
651
- { op: 'replace', path: '/count', value: 1 },
652
- { op: 'replace', path: '/count', value: 2 },
653
- { op: 'replace', path: '/count', value: 3 },
654
- ])
655
- expect(c.count()).toBe(3)
656
- })
657
-
658
- it('applies patch to nested model instance', () => {
659
- const app = App.create({
660
- profile: { name: 'Alice', bio: '' },
661
- title: 'old',
662
- })
663
- applyPatch(app, { op: 'replace', path: '/profile/name', value: 'Bob' })
664
- expect(app.profile().name()).toBe('Bob')
665
- })
666
-
667
- it('roundtrips with onPatch — record and replay', () => {
668
- const c = Counter.create({ count: 0 })
669
- const patches: Patch[] = []
670
- onPatch(c, (p) => patches.push({ ...p }))
671
-
672
- c.inc()
673
- c.inc()
674
- c.add(10)
675
- expect(c.count()).toBe(12)
676
- expect(patches).toHaveLength(3)
677
- // Verify patches contain the final values at each step
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
-
682
- // Replay on a fresh instance
683
- const c2 = Counter.create({ count: 0 })
684
- applyPatch(c2, patches)
685
- expect(c2.count()).toBe(12)
686
- })
687
-
688
- it('throws for non-model instance', () => {
689
- expect(() => applyPatch({}, { op: 'replace', path: '/x', value: 1 })).toThrow(
690
- 'not a model instance',
691
- )
692
- })
693
-
694
- it('throws for empty path', () => {
695
- const c = Counter.create({ count: 0 })
696
- expect(() => applyPatch(c, { op: 'replace', path: '', value: 1 })).toThrow('empty path')
697
- })
698
-
699
- it('throws for unknown state key', () => {
700
- const c = Counter.create({ count: 0 })
701
- expect(() => applyPatch(c, { op: 'replace', path: '/nonexistent', value: 1 })).toThrow(
702
- 'unknown state key',
703
- )
704
- })
705
-
706
- it('throws for unsupported op', () => {
707
- const c = Counter.create({ count: 0 })
708
- expect(() => applyPatch(c, { op: 'add' as any, path: '/count', value: 1 })).toThrow(
709
- 'unsupported op "add"',
710
- )
711
- })
712
- })