@pyreon/state-tree 0.11.5 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -63
- package/lib/devtools.js.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +1 -1
- package/package.json +14 -14
- package/src/devtools.ts +1 -1
- package/src/index.ts +6 -6
- package/src/instance.ts +8 -8
- package/src/middleware.ts +3 -3
- package/src/model.ts +4 -4
- package/src/patch.ts +14 -14
- package/src/registry.ts +2 -2
- package/src/snapshot.ts +6 -6
- package/src/tests/comprehensive.test.ts +109 -109
- package/src/tests/devtools.test.ts +43 -43
- package/src/tests/edge-cases.test.ts +138 -138
- package/src/tests/model.test.ts +157 -157
- package/src/types.ts +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { computed, effect } from
|
|
2
|
-
import type { Patch } from
|
|
1
|
+
import { computed, effect } from '@pyreon/reactivity'
|
|
2
|
+
import type { Patch } from '../index'
|
|
3
3
|
import {
|
|
4
4
|
addMiddleware,
|
|
5
5
|
applyPatch,
|
|
@@ -8,12 +8,12 @@ import {
|
|
|
8
8
|
model,
|
|
9
9
|
onPatch,
|
|
10
10
|
resetAllHooks,
|
|
11
|
-
} from
|
|
11
|
+
} from '../index'
|
|
12
12
|
|
|
13
13
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
14
14
|
|
|
15
15
|
const Profile = model({
|
|
16
|
-
state: { name:
|
|
16
|
+
state: { name: '', bio: '' },
|
|
17
17
|
actions: (self) => ({
|
|
18
18
|
rename: (n: string) => self.name.set(n),
|
|
19
19
|
setBio: (b: string) => self.bio.set(b),
|
|
@@ -21,7 +21,7 @@ const Profile = model({
|
|
|
21
21
|
})
|
|
22
22
|
|
|
23
23
|
const App = model({
|
|
24
|
-
state: { profile: Profile, title:
|
|
24
|
+
state: { profile: Profile, title: 'My App' },
|
|
25
25
|
actions: (self) => ({
|
|
26
26
|
setTitle: (t: string) => self.title.set(t),
|
|
27
27
|
replaceProfile: (p: any) => self.profile.set(p),
|
|
@@ -42,8 +42,8 @@ const Counter = model({
|
|
|
42
42
|
|
|
43
43
|
// ─── 1. Nested model deletion ────────────────────────────────────────────────
|
|
44
44
|
|
|
45
|
-
describe(
|
|
46
|
-
it(
|
|
45
|
+
describe('nested model deletion', () => {
|
|
46
|
+
it('replacing a nested child model updates snapshot correctly', () => {
|
|
47
47
|
const Child = model({
|
|
48
48
|
state: { value: 0 },
|
|
49
49
|
actions: (self) => ({
|
|
@@ -52,7 +52,7 @@ describe("nested model deletion", () => {
|
|
|
52
52
|
})
|
|
53
53
|
|
|
54
54
|
const Parent = model({
|
|
55
|
-
state: { child: Child, label:
|
|
55
|
+
state: { child: Child, label: 'parent' },
|
|
56
56
|
actions: (self) => ({
|
|
57
57
|
replaceChild: (c: any) => self.child.set(c),
|
|
58
58
|
setLabel: (l: string) => self.label.set(l),
|
|
@@ -61,12 +61,12 @@ describe("nested model deletion", () => {
|
|
|
61
61
|
|
|
62
62
|
const parent = Parent.create({
|
|
63
63
|
child: { value: 10 },
|
|
64
|
-
label:
|
|
64
|
+
label: 'original',
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
expect(getSnapshot(parent)).toEqual({
|
|
68
68
|
child: { value: 10 },
|
|
69
|
-
label:
|
|
69
|
+
label: 'original',
|
|
70
70
|
})
|
|
71
71
|
|
|
72
72
|
// Replace child with a new instance
|
|
@@ -76,11 +76,11 @@ describe("nested model deletion", () => {
|
|
|
76
76
|
// Snapshot should reflect the new child
|
|
77
77
|
expect(getSnapshot(parent)).toEqual({
|
|
78
78
|
child: { value: 99 },
|
|
79
|
-
label:
|
|
79
|
+
label: 'original',
|
|
80
80
|
})
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
it(
|
|
83
|
+
it('nested patches stop propagating after child replacement', () => {
|
|
84
84
|
const Child = model({
|
|
85
85
|
state: { x: 0 },
|
|
86
86
|
actions: (self) => ({
|
|
@@ -104,20 +104,20 @@ describe("nested model deletion", () => {
|
|
|
104
104
|
// Mutate old child — should propagate
|
|
105
105
|
oldChild.setX(2)
|
|
106
106
|
expect(patches).toHaveLength(1)
|
|
107
|
-
expect(patches[0]).toEqual({ op:
|
|
107
|
+
expect(patches[0]).toEqual({ op: 'replace', path: '/child/x', value: 2 })
|
|
108
108
|
|
|
109
109
|
// Replace child
|
|
110
110
|
const newChild = Child.create({ x: 50 })
|
|
111
111
|
parent.replaceChild(newChild)
|
|
112
112
|
expect(patches).toHaveLength(2)
|
|
113
|
-
expect(patches[1]!.path).toBe(
|
|
113
|
+
expect(patches[1]!.path).toBe('/child')
|
|
114
114
|
})
|
|
115
115
|
})
|
|
116
116
|
|
|
117
117
|
// ─── 2. Snapshot edge cases ──────────────────────────────────────────────────
|
|
118
118
|
|
|
119
|
-
describe(
|
|
120
|
-
it(
|
|
119
|
+
describe('snapshot edge cases', () => {
|
|
120
|
+
it('handles null values in state', () => {
|
|
121
121
|
const M = model({
|
|
122
122
|
state: { data: null as string | null },
|
|
123
123
|
actions: (self) => ({
|
|
@@ -128,14 +128,14 @@ describe("snapshot edge cases", () => {
|
|
|
128
128
|
const m = M.create()
|
|
129
129
|
expect(getSnapshot(m)).toEqual({ data: null })
|
|
130
130
|
|
|
131
|
-
m.setData(
|
|
132
|
-
expect(getSnapshot(m)).toEqual({ data:
|
|
131
|
+
m.setData('hello')
|
|
132
|
+
expect(getSnapshot(m)).toEqual({ data: 'hello' })
|
|
133
133
|
|
|
134
134
|
m.setData(null)
|
|
135
135
|
expect(getSnapshot(m)).toEqual({ data: null })
|
|
136
136
|
})
|
|
137
137
|
|
|
138
|
-
it(
|
|
138
|
+
it('handles empty arrays in state', () => {
|
|
139
139
|
const M = model({
|
|
140
140
|
state: { items: [] as number[] },
|
|
141
141
|
actions: (self) => ({
|
|
@@ -150,8 +150,8 @@ describe("snapshot edge cases", () => {
|
|
|
150
150
|
expect(getSnapshot(m)).toEqual({ items: [1, 2, 3] })
|
|
151
151
|
})
|
|
152
152
|
|
|
153
|
-
it(
|
|
154
|
-
const now = new Date(
|
|
153
|
+
it('handles Date objects in state', () => {
|
|
154
|
+
const now = new Date('2025-01-01T00:00:00Z')
|
|
155
155
|
const M = model({
|
|
156
156
|
state: { createdAt: now },
|
|
157
157
|
})
|
|
@@ -159,23 +159,23 @@ describe("snapshot edge cases", () => {
|
|
|
159
159
|
const m = M.create()
|
|
160
160
|
const snap = getSnapshot(m)
|
|
161
161
|
expect(snap.createdAt).toBeInstanceOf(Date)
|
|
162
|
-
expect((snap.createdAt as Date).toISOString()).toBe(
|
|
162
|
+
expect((snap.createdAt as Date).toISOString()).toBe('2025-01-01T00:00:00.000Z')
|
|
163
163
|
})
|
|
164
164
|
|
|
165
|
-
it(
|
|
165
|
+
it('handles undefined initial values by falling back to defaults', () => {
|
|
166
166
|
const M = model({
|
|
167
|
-
state: { x: 10, y:
|
|
167
|
+
state: { x: 10, y: 'hello', z: true },
|
|
168
168
|
})
|
|
169
169
|
|
|
170
170
|
// Pass undefined for all — should use defaults
|
|
171
171
|
const m = M.create()
|
|
172
|
-
expect(getSnapshot(m)).toEqual({ x: 10, y:
|
|
172
|
+
expect(getSnapshot(m)).toEqual({ x: 10, y: 'hello', z: true })
|
|
173
173
|
})
|
|
174
174
|
|
|
175
|
-
it(
|
|
175
|
+
it('handles complex nested objects in state', () => {
|
|
176
176
|
const M = model({
|
|
177
177
|
state: {
|
|
178
|
-
config: { theme:
|
|
178
|
+
config: { theme: 'dark', fontSize: 14, plugins: ['a', 'b'] },
|
|
179
179
|
},
|
|
180
180
|
actions: (self) => ({
|
|
181
181
|
setConfig: (c: any) => self.config.set(c),
|
|
@@ -184,22 +184,22 @@ describe("snapshot edge cases", () => {
|
|
|
184
184
|
|
|
185
185
|
const m = M.create()
|
|
186
186
|
expect(getSnapshot(m)).toEqual({
|
|
187
|
-
config: { theme:
|
|
187
|
+
config: { theme: 'dark', fontSize: 14, plugins: ['a', 'b'] },
|
|
188
188
|
})
|
|
189
189
|
|
|
190
|
-
m.setConfig({ theme:
|
|
190
|
+
m.setConfig({ theme: 'light', fontSize: 16, plugins: [] })
|
|
191
191
|
expect(getSnapshot(m)).toEqual({
|
|
192
|
-
config: { theme:
|
|
192
|
+
config: { theme: 'light', fontSize: 16, plugins: [] },
|
|
193
193
|
})
|
|
194
194
|
})
|
|
195
195
|
})
|
|
196
196
|
|
|
197
197
|
// ─── 3. Patch replay ─────────────────────────────────────────────────────────
|
|
198
198
|
|
|
199
|
-
describe(
|
|
200
|
-
it(
|
|
199
|
+
describe('patch replay', () => {
|
|
200
|
+
it('replaying recorded patches on a fresh instance reproduces final state', () => {
|
|
201
201
|
const M = model({
|
|
202
|
-
state: { a: 0, b:
|
|
202
|
+
state: { a: 0, b: '' },
|
|
203
203
|
actions: (self) => ({
|
|
204
204
|
setA: (v: number) => self.a.set(v),
|
|
205
205
|
setB: (v: string) => self.b.set(v),
|
|
@@ -211,9 +211,9 @@ describe("patch replay", () => {
|
|
|
211
211
|
onPatch(original, (p) => patches.push({ ...p }))
|
|
212
212
|
|
|
213
213
|
original.setA(1)
|
|
214
|
-
original.setB(
|
|
214
|
+
original.setB('hello')
|
|
215
215
|
original.setA(2)
|
|
216
|
-
original.setB(
|
|
216
|
+
original.setB('world')
|
|
217
217
|
original.setA(42)
|
|
218
218
|
|
|
219
219
|
expect(patches).toHaveLength(5)
|
|
@@ -224,10 +224,10 @@ describe("patch replay", () => {
|
|
|
224
224
|
|
|
225
225
|
expect(getSnapshot(replica)).toEqual(getSnapshot(original))
|
|
226
226
|
expect(replica.a()).toBe(42)
|
|
227
|
-
expect(replica.b()).toBe(
|
|
227
|
+
expect(replica.b()).toBe('world')
|
|
228
228
|
})
|
|
229
229
|
|
|
230
|
-
it(
|
|
230
|
+
it('replaying patches preserves intermediate state transitions', () => {
|
|
231
231
|
const c = Counter.create()
|
|
232
232
|
const patches: Patch[] = []
|
|
233
233
|
onPatch(c, (p) => patches.push({ ...p }))
|
|
@@ -248,8 +248,8 @@ describe("patch replay", () => {
|
|
|
248
248
|
|
|
249
249
|
// ─── 4. Patch with nested operations ─────────────────────────────────────────
|
|
250
250
|
|
|
251
|
-
describe(
|
|
252
|
-
it(
|
|
251
|
+
describe('patch with nested operations', () => {
|
|
252
|
+
it('applies replace on deeply nested model property', () => {
|
|
253
253
|
const Leaf = model({
|
|
254
254
|
state: { value: 0 },
|
|
255
255
|
actions: (self) => ({
|
|
@@ -258,168 +258,168 @@ describe("patch with nested operations", () => {
|
|
|
258
258
|
})
|
|
259
259
|
|
|
260
260
|
const Branch = model({
|
|
261
|
-
state: { leaf: Leaf, tag:
|
|
261
|
+
state: { leaf: Leaf, tag: '' },
|
|
262
262
|
actions: (self) => ({
|
|
263
263
|
setTag: (t: string) => self.tag.set(t),
|
|
264
264
|
}),
|
|
265
265
|
})
|
|
266
266
|
|
|
267
267
|
const Root = model({
|
|
268
|
-
state: { branch: Branch, name:
|
|
268
|
+
state: { branch: Branch, name: 'root' },
|
|
269
269
|
actions: (self) => ({
|
|
270
270
|
setName: (n: string) => self.name.set(n),
|
|
271
271
|
}),
|
|
272
272
|
})
|
|
273
273
|
|
|
274
274
|
const root = Root.create({
|
|
275
|
-
branch: { leaf: { value: 1 }, tag:
|
|
276
|
-
name:
|
|
275
|
+
branch: { leaf: { value: 1 }, tag: 'a' },
|
|
276
|
+
name: 'root',
|
|
277
277
|
})
|
|
278
278
|
|
|
279
279
|
// Apply patch to deeply nested leaf
|
|
280
|
-
applyPatch(root, { op:
|
|
280
|
+
applyPatch(root, { op: 'replace', path: '/branch/leaf/value', value: 999 })
|
|
281
281
|
expect(root.branch().leaf().value()).toBe(999)
|
|
282
282
|
|
|
283
283
|
// Apply patch to intermediate level
|
|
284
|
-
applyPatch(root, { op:
|
|
285
|
-
expect(root.branch().tag()).toBe(
|
|
284
|
+
applyPatch(root, { op: 'replace', path: '/branch/tag', value: 'updated' })
|
|
285
|
+
expect(root.branch().tag()).toBe('updated')
|
|
286
286
|
|
|
287
287
|
// Apply patch to top level
|
|
288
|
-
applyPatch(root, { op:
|
|
289
|
-
expect(root.name()).toBe(
|
|
288
|
+
applyPatch(root, { op: 'replace', path: '/name', value: 'new-root' })
|
|
289
|
+
expect(root.name()).toBe('new-root')
|
|
290
290
|
})
|
|
291
291
|
|
|
292
|
-
it(
|
|
292
|
+
it('records nested patches with correct paths', () => {
|
|
293
293
|
const app = App.create({
|
|
294
|
-
profile: { name:
|
|
295
|
-
title:
|
|
294
|
+
profile: { name: 'Alice', bio: 'dev' },
|
|
295
|
+
title: 'Test',
|
|
296
296
|
})
|
|
297
297
|
|
|
298
298
|
const patches: Patch[] = []
|
|
299
299
|
onPatch(app, (p) => patches.push({ ...p }))
|
|
300
300
|
|
|
301
|
-
app.profile().rename(
|
|
302
|
-
app.profile().setBio(
|
|
303
|
-
app.setTitle(
|
|
301
|
+
app.profile().rename('Bob')
|
|
302
|
+
app.profile().setBio('engineer')
|
|
303
|
+
app.setTitle('New Title')
|
|
304
304
|
|
|
305
305
|
expect(patches).toEqual([
|
|
306
|
-
{ op:
|
|
307
|
-
{ op:
|
|
308
|
-
{ op:
|
|
306
|
+
{ op: 'replace', path: '/profile/name', value: 'Bob' },
|
|
307
|
+
{ op: 'replace', path: '/profile/bio', value: 'engineer' },
|
|
308
|
+
{ op: 'replace', path: '/title', value: 'New Title' },
|
|
309
309
|
])
|
|
310
310
|
})
|
|
311
311
|
|
|
312
|
-
it(
|
|
312
|
+
it('replays nested patches on fresh instance', () => {
|
|
313
313
|
const app = App.create({
|
|
314
|
-
profile: { name:
|
|
315
|
-
title:
|
|
314
|
+
profile: { name: 'Alice', bio: '' },
|
|
315
|
+
title: 'v1',
|
|
316
316
|
})
|
|
317
317
|
|
|
318
318
|
const patches: Patch[] = []
|
|
319
319
|
onPatch(app, (p) => patches.push({ ...p }))
|
|
320
320
|
|
|
321
|
-
app.profile().rename(
|
|
322
|
-
app.setTitle(
|
|
321
|
+
app.profile().rename('Carol')
|
|
322
|
+
app.setTitle('v2')
|
|
323
323
|
|
|
324
324
|
const fresh = App.create({
|
|
325
|
-
profile: { name:
|
|
326
|
-
title:
|
|
325
|
+
profile: { name: 'Alice', bio: '' },
|
|
326
|
+
title: 'v1',
|
|
327
327
|
})
|
|
328
328
|
applyPatch(fresh, patches)
|
|
329
329
|
|
|
330
|
-
expect(fresh.profile().name()).toBe(
|
|
331
|
-
expect(fresh.title()).toBe(
|
|
330
|
+
expect(fresh.profile().name()).toBe('Carol')
|
|
331
|
+
expect(fresh.title()).toBe('v2')
|
|
332
332
|
})
|
|
333
333
|
})
|
|
334
334
|
|
|
335
335
|
// ─── 5. Middleware error handling ─────────────────────────────────────────────
|
|
336
336
|
|
|
337
|
-
describe(
|
|
338
|
-
it(
|
|
337
|
+
describe('middleware error handling', () => {
|
|
338
|
+
it('throwing middleware does not corrupt state', () => {
|
|
339
339
|
const c = Counter.create({ count: 5 })
|
|
340
340
|
|
|
341
341
|
addMiddleware(c, (_call, _next) => {
|
|
342
|
-
throw new Error(
|
|
342
|
+
throw new Error('middleware boom')
|
|
343
343
|
})
|
|
344
344
|
|
|
345
|
-
expect(() => c.inc()).toThrow(
|
|
345
|
+
expect(() => c.inc()).toThrow('middleware boom')
|
|
346
346
|
// State should remain unchanged since the action never ran
|
|
347
347
|
expect(c.count()).toBe(5)
|
|
348
348
|
})
|
|
349
349
|
|
|
350
|
-
it(
|
|
350
|
+
it('error in middleware after next() does not undo the action', () => {
|
|
351
351
|
const c = Counter.create({ count: 0 })
|
|
352
352
|
|
|
353
353
|
addMiddleware(c, (call, next) => {
|
|
354
354
|
next(call)
|
|
355
|
-
throw new Error(
|
|
355
|
+
throw new Error('post-action error')
|
|
356
356
|
})
|
|
357
357
|
|
|
358
|
-
expect(() => c.inc()).toThrow(
|
|
358
|
+
expect(() => c.inc()).toThrow('post-action error')
|
|
359
359
|
// The action DID run before the error
|
|
360
360
|
expect(c.count()).toBe(1)
|
|
361
361
|
})
|
|
362
362
|
|
|
363
|
-
it(
|
|
363
|
+
it('error in one middleware prevents subsequent middlewares from running', () => {
|
|
364
364
|
const c = Counter.create()
|
|
365
365
|
const log: string[] = []
|
|
366
366
|
|
|
367
367
|
addMiddleware(c, (_call, _next) => {
|
|
368
|
-
log.push(
|
|
369
|
-
throw new Error(
|
|
368
|
+
log.push('first')
|
|
369
|
+
throw new Error('first fails')
|
|
370
370
|
})
|
|
371
371
|
|
|
372
372
|
addMiddleware(c, (call, next) => {
|
|
373
|
-
log.push(
|
|
373
|
+
log.push('second')
|
|
374
374
|
return next(call)
|
|
375
375
|
})
|
|
376
376
|
|
|
377
|
-
expect(() => c.inc()).toThrow(
|
|
377
|
+
expect(() => c.inc()).toThrow('first fails')
|
|
378
378
|
// Only the first middleware ran (Koa-style: first wraps second)
|
|
379
|
-
expect(log).toEqual([
|
|
379
|
+
expect(log).toEqual(['first'])
|
|
380
380
|
expect(c.count()).toBe(0)
|
|
381
381
|
})
|
|
382
382
|
})
|
|
383
383
|
|
|
384
384
|
// ─── 6. Middleware chain order ────────────────────────────────────────────────
|
|
385
385
|
|
|
386
|
-
describe(
|
|
387
|
-
it(
|
|
386
|
+
describe('middleware chain order', () => {
|
|
387
|
+
it('middlewares fire in registration order (Koa-style onion)', () => {
|
|
388
388
|
const c = Counter.create()
|
|
389
389
|
const log: string[] = []
|
|
390
390
|
|
|
391
391
|
addMiddleware(c, (call, next) => {
|
|
392
|
-
log.push(
|
|
392
|
+
log.push('A:before')
|
|
393
393
|
const result = next(call)
|
|
394
|
-
log.push(
|
|
394
|
+
log.push('A:after')
|
|
395
395
|
return result
|
|
396
396
|
})
|
|
397
397
|
|
|
398
398
|
addMiddleware(c, (call, next) => {
|
|
399
|
-
log.push(
|
|
399
|
+
log.push('B:before')
|
|
400
400
|
const result = next(call)
|
|
401
|
-
log.push(
|
|
401
|
+
log.push('B:after')
|
|
402
402
|
return result
|
|
403
403
|
})
|
|
404
404
|
|
|
405
405
|
addMiddleware(c, (call, next) => {
|
|
406
|
-
log.push(
|
|
406
|
+
log.push('C:before')
|
|
407
407
|
const result = next(call)
|
|
408
|
-
log.push(
|
|
408
|
+
log.push('C:after')
|
|
409
409
|
return result
|
|
410
410
|
})
|
|
411
411
|
|
|
412
412
|
c.inc()
|
|
413
413
|
|
|
414
|
-
expect(log).toEqual([
|
|
414
|
+
expect(log).toEqual(['A:before', 'B:before', 'C:before', 'C:after', 'B:after', 'A:after'])
|
|
415
415
|
})
|
|
416
416
|
|
|
417
|
-
it(
|
|
417
|
+
it('middleware can modify action args before passing to next', () => {
|
|
418
418
|
const c = Counter.create()
|
|
419
419
|
|
|
420
420
|
addMiddleware(c, (call, next) => {
|
|
421
421
|
// Double the argument to add()
|
|
422
|
-
if (call.name ===
|
|
422
|
+
if (call.name === 'add') {
|
|
423
423
|
return next({ ...call, args: [(call.args[0] as number) * 2] })
|
|
424
424
|
}
|
|
425
425
|
return next(call)
|
|
@@ -429,9 +429,9 @@ describe("middleware chain order", () => {
|
|
|
429
429
|
expect(c.count()).toBe(10)
|
|
430
430
|
})
|
|
431
431
|
|
|
432
|
-
it(
|
|
432
|
+
it('middleware can replace action result', () => {
|
|
433
433
|
const M = model({
|
|
434
|
-
state: { value:
|
|
434
|
+
state: { value: '' },
|
|
435
435
|
actions: (self) => ({
|
|
436
436
|
getValue: () => {
|
|
437
437
|
return self.value()
|
|
@@ -441,28 +441,28 @@ describe("middleware chain order", () => {
|
|
|
441
441
|
})
|
|
442
442
|
|
|
443
443
|
const m = M.create()
|
|
444
|
-
m.setValue(
|
|
444
|
+
m.setValue('original')
|
|
445
445
|
|
|
446
446
|
addMiddleware(m, (call, next) => {
|
|
447
447
|
const result = next(call)
|
|
448
|
-
if (call.name ===
|
|
448
|
+
if (call.name === 'getValue') {
|
|
449
449
|
return `intercepted:${result}`
|
|
450
450
|
}
|
|
451
451
|
return result
|
|
452
452
|
})
|
|
453
453
|
|
|
454
|
-
expect(m.getValue()).toBe(
|
|
454
|
+
expect(m.getValue()).toBe('intercepted:original')
|
|
455
455
|
})
|
|
456
456
|
})
|
|
457
457
|
|
|
458
458
|
// ─── 7. Hook singleton behavior ──────────────────────────────────────────────
|
|
459
459
|
|
|
460
|
-
describe(
|
|
460
|
+
describe('hook singleton behavior', () => {
|
|
461
461
|
afterEach(() => resetAllHooks())
|
|
462
462
|
|
|
463
|
-
it(
|
|
464
|
-
const useCounter1 = Counter.asHook(
|
|
465
|
-
const useCounter2 = Counter.asHook(
|
|
463
|
+
it('asHook returns the same hook function for the same id', () => {
|
|
464
|
+
const useCounter1 = Counter.asHook('singleton-test')
|
|
465
|
+
const useCounter2 = Counter.asHook('singleton-test')
|
|
466
466
|
|
|
467
467
|
// The hook functions may be different references but the instance they return is the same
|
|
468
468
|
const instance1 = useCounter1()
|
|
@@ -470,9 +470,9 @@ describe("hook singleton behavior", () => {
|
|
|
470
470
|
expect(instance1).toBe(instance2)
|
|
471
471
|
})
|
|
472
472
|
|
|
473
|
-
it(
|
|
474
|
-
const useA = Counter.asHook(
|
|
475
|
-
const useB = Counter.asHook(
|
|
473
|
+
it('mutations via one hook reference are visible via another', () => {
|
|
474
|
+
const useA = Counter.asHook('shared-hook')
|
|
475
|
+
const useB = Counter.asHook('shared-hook')
|
|
476
476
|
|
|
477
477
|
useA().inc()
|
|
478
478
|
useA().inc()
|
|
@@ -481,18 +481,18 @@ describe("hook singleton behavior", () => {
|
|
|
481
481
|
expect(useA().doubled()).toBe(4)
|
|
482
482
|
})
|
|
483
483
|
|
|
484
|
-
it(
|
|
484
|
+
it('different models with same hook id share the same registry entry', () => {
|
|
485
485
|
// First model claims the id
|
|
486
|
-
const useFirst = Counter.asHook(
|
|
486
|
+
const useFirst = Counter.asHook('conflict-id')
|
|
487
487
|
const instance = useFirst()
|
|
488
488
|
|
|
489
489
|
// Second model with same id returns the already-created instance (Counter, not Profile)
|
|
490
|
-
const useSecond = Profile.asHook(
|
|
490
|
+
const useSecond = Profile.asHook('conflict-id')
|
|
491
491
|
expect(useSecond()).toBe(instance)
|
|
492
492
|
})
|
|
493
493
|
|
|
494
|
-
it(
|
|
495
|
-
const useC = Counter.asHook(
|
|
494
|
+
it('resetAllHooks makes subsequent calls create fresh instances', () => {
|
|
495
|
+
const useC = Counter.asHook('fresh-test')
|
|
496
496
|
const old = useC()
|
|
497
497
|
old.add(100)
|
|
498
498
|
|
|
@@ -506,18 +506,18 @@ describe("hook singleton behavior", () => {
|
|
|
506
506
|
|
|
507
507
|
// ─── 8. Model with no actions ────────────────────────────────────────────────
|
|
508
508
|
|
|
509
|
-
describe(
|
|
510
|
-
it(
|
|
509
|
+
describe('model with no actions', () => {
|
|
510
|
+
it('creates instance with only state', () => {
|
|
511
511
|
const ReadOnly = model({
|
|
512
|
-
state: { x: 10, y:
|
|
512
|
+
state: { x: 10, y: 'hello' },
|
|
513
513
|
})
|
|
514
514
|
|
|
515
515
|
const r = ReadOnly.create()
|
|
516
516
|
expect(r.x()).toBe(10)
|
|
517
|
-
expect(r.y()).toBe(
|
|
517
|
+
expect(r.y()).toBe('hello')
|
|
518
518
|
})
|
|
519
519
|
|
|
520
|
-
it(
|
|
520
|
+
it('supports views without actions', () => {
|
|
521
521
|
const Derived = model({
|
|
522
522
|
state: { a: 3, b: 4 },
|
|
523
523
|
views: (self) => ({
|
|
@@ -531,20 +531,20 @@ describe("model with no actions", () => {
|
|
|
531
531
|
expect(d.product()).toBe(12)
|
|
532
532
|
})
|
|
533
533
|
|
|
534
|
-
it(
|
|
534
|
+
it('snapshots work on action-less models', () => {
|
|
535
535
|
const M = model({ state: { val: 42 } })
|
|
536
536
|
const m = M.create({ val: 100 })
|
|
537
537
|
expect(getSnapshot(m)).toEqual({ val: 100 })
|
|
538
538
|
})
|
|
539
539
|
|
|
540
|
-
it(
|
|
540
|
+
it('applySnapshot works on action-less models', () => {
|
|
541
541
|
const M = model({ state: { val: 0 } })
|
|
542
542
|
const m = M.create()
|
|
543
543
|
applySnapshot(m, { val: 999 })
|
|
544
544
|
expect(m.val()).toBe(999)
|
|
545
545
|
})
|
|
546
546
|
|
|
547
|
-
it(
|
|
547
|
+
it('onPatch works on action-less models when signals are set directly', () => {
|
|
548
548
|
const M = model({ state: { val: 0 } })
|
|
549
549
|
const m = M.create()
|
|
550
550
|
const patches: Patch[] = []
|
|
@@ -553,10 +553,10 @@ describe("model with no actions", () => {
|
|
|
553
553
|
// Set via tracked signal directly
|
|
554
554
|
m.val.set(5)
|
|
555
555
|
expect(patches).toHaveLength(1)
|
|
556
|
-
expect(patches[0]).toEqual({ op:
|
|
556
|
+
expect(patches[0]).toEqual({ op: 'replace', path: '/val', value: 5 })
|
|
557
557
|
})
|
|
558
558
|
|
|
559
|
-
it(
|
|
559
|
+
it('middleware can be added to action-less models (no-op since no actions)', () => {
|
|
560
560
|
const M = model({ state: { val: 0 } })
|
|
561
561
|
const m = M.create()
|
|
562
562
|
const log: string[] = []
|
|
@@ -577,32 +577,32 @@ describe("model with no actions", () => {
|
|
|
577
577
|
|
|
578
578
|
// ─── 9. applySnapshot with partial data ──────────────────────────────────────
|
|
579
579
|
|
|
580
|
-
describe(
|
|
581
|
-
it(
|
|
580
|
+
describe('applySnapshot with partial data', () => {
|
|
581
|
+
it('only updates specified fields, keeps others unchanged', () => {
|
|
582
582
|
const M = model({
|
|
583
|
-
state: { name:
|
|
583
|
+
state: { name: 'default', age: 0, active: false },
|
|
584
584
|
actions: (self) => ({
|
|
585
585
|
setName: (n: string) => self.name.set(n),
|
|
586
586
|
setAge: (a: number) => self.age.set(a),
|
|
587
587
|
}),
|
|
588
588
|
})
|
|
589
589
|
|
|
590
|
-
const m = M.create({ name:
|
|
590
|
+
const m = M.create({ name: 'Alice', age: 30, active: true })
|
|
591
591
|
|
|
592
592
|
// Only update name
|
|
593
|
-
applySnapshot(m, { name:
|
|
594
|
-
expect(m.name()).toBe(
|
|
593
|
+
applySnapshot(m, { name: 'Bob' })
|
|
594
|
+
expect(m.name()).toBe('Bob')
|
|
595
595
|
expect(m.age()).toBe(30)
|
|
596
596
|
expect(m.active()).toBe(true)
|
|
597
597
|
|
|
598
598
|
// Only update age
|
|
599
599
|
applySnapshot(m, { age: 25 })
|
|
600
|
-
expect(m.name()).toBe(
|
|
600
|
+
expect(m.name()).toBe('Bob')
|
|
601
601
|
expect(m.age()).toBe(25)
|
|
602
602
|
expect(m.active()).toBe(true)
|
|
603
603
|
})
|
|
604
604
|
|
|
605
|
-
it(
|
|
605
|
+
it('empty snapshot changes nothing', () => {
|
|
606
606
|
const M = model({ state: { x: 1, y: 2 } })
|
|
607
607
|
const m = M.create()
|
|
608
608
|
|
|
@@ -611,20 +611,20 @@ describe("applySnapshot with partial data", () => {
|
|
|
611
611
|
expect(m.y()).toBe(2)
|
|
612
612
|
})
|
|
613
613
|
|
|
614
|
-
it(
|
|
614
|
+
it('partial nested snapshot updates only specified nested fields', () => {
|
|
615
615
|
const app = App.create({
|
|
616
|
-
profile: { name:
|
|
617
|
-
title:
|
|
616
|
+
profile: { name: 'Alice', bio: 'dev' },
|
|
617
|
+
title: 'Old',
|
|
618
618
|
})
|
|
619
619
|
|
|
620
620
|
// Update only the nested profile name, leave bio unchanged
|
|
621
|
-
applySnapshot(app, { profile: { name:
|
|
622
|
-
expect(app.profile().name()).toBe(
|
|
623
|
-
expect(app.profile().bio()).toBe(
|
|
624
|
-
expect(app.title()).toBe(
|
|
621
|
+
applySnapshot(app, { profile: { name: 'Bob' } } as any)
|
|
622
|
+
expect(app.profile().name()).toBe('Bob')
|
|
623
|
+
expect(app.profile().bio()).toBe('dev')
|
|
624
|
+
expect(app.title()).toBe('Old')
|
|
625
625
|
})
|
|
626
626
|
|
|
627
|
-
it(
|
|
627
|
+
it('applySnapshot batches updates — effect fires once', () => {
|
|
628
628
|
const M = model({ state: { a: 0, b: 0, c: 0 } })
|
|
629
629
|
const m = M.create()
|
|
630
630
|
|
|
@@ -644,8 +644,8 @@ describe("applySnapshot with partial data", () => {
|
|
|
644
644
|
|
|
645
645
|
// ─── 10. onPatch listener cleanup ────────────────────────────────────────────
|
|
646
646
|
|
|
647
|
-
describe(
|
|
648
|
-
it(
|
|
647
|
+
describe('onPatch listener cleanup', () => {
|
|
648
|
+
it('unsubscribe prevents further callbacks', () => {
|
|
649
649
|
const c = Counter.create()
|
|
650
650
|
const patches: Patch[] = []
|
|
651
651
|
const unsub = onPatch(c, (p) => patches.push(p))
|
|
@@ -660,7 +660,7 @@ describe("onPatch listener cleanup", () => {
|
|
|
660
660
|
expect(patches).toHaveLength(1) // No new patches after unsub
|
|
661
661
|
})
|
|
662
662
|
|
|
663
|
-
it(
|
|
663
|
+
it('multiple listeners can be independently unsubscribed', () => {
|
|
664
664
|
const c = Counter.create()
|
|
665
665
|
const logA: Patch[] = []
|
|
666
666
|
const logB: Patch[] = []
|
|
@@ -685,7 +685,7 @@ describe("onPatch listener cleanup", () => {
|
|
|
685
685
|
expect(logB).toHaveLength(2)
|
|
686
686
|
})
|
|
687
687
|
|
|
688
|
-
it(
|
|
688
|
+
it('double unsubscribe is safe (no-op)', () => {
|
|
689
689
|
const c = Counter.create()
|
|
690
690
|
const unsub = onPatch(c, () => {})
|
|
691
691
|
|
|
@@ -693,7 +693,7 @@ describe("onPatch listener cleanup", () => {
|
|
|
693
693
|
expect(() => unsub()).not.toThrow()
|
|
694
694
|
})
|
|
695
695
|
|
|
696
|
-
it(
|
|
696
|
+
it('listener added after unsub receives only new patches', () => {
|
|
697
697
|
const c = Counter.create()
|
|
698
698
|
const log1: Patch[] = []
|
|
699
699
|
const log2: Patch[] = []
|