@pyreon/state-tree 0.9.0 → 0.11.0

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