@pyreon/state-tree 0.10.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.
- package/lib/devtools.js.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/types/devtools.d.ts.map +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +11 -6
- package/src/devtools.ts +2 -4
- package/src/index.ts +6 -6
- package/src/instance.ts +11 -17
- package/src/middleware.ts +4 -8
- package/src/model.ts +8 -18
- package/src/patch.ts +22 -39
- package/src/registry.ts +2 -6
- package/src/snapshot.ts +7 -11
- package/src/tests/devtools.test.ts +43 -43
- package/src/tests/edge-cases.test.ts +715 -0
- package/src/tests/model.test.ts +161 -167
- package/src/types.ts +5 -9
|
@@ -0,0 +1,715 @@
|
|
|
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
|
+
} from "../index"
|
|
12
|
+
|
|
13
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const Profile = model({
|
|
16
|
+
state: { name: "", bio: "" },
|
|
17
|
+
actions: (self) => ({
|
|
18
|
+
rename: (n: string) => self.name.set(n),
|
|
19
|
+
setBio: (b: string) => self.bio.set(b),
|
|
20
|
+
}),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const App = model({
|
|
24
|
+
state: { profile: Profile, title: "My App" },
|
|
25
|
+
actions: (self) => ({
|
|
26
|
+
setTitle: (t: string) => self.title.set(t),
|
|
27
|
+
replaceProfile: (p: any) => self.profile.set(p),
|
|
28
|
+
}),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const Counter = model({
|
|
32
|
+
state: { count: 0 },
|
|
33
|
+
views: (self) => ({
|
|
34
|
+
doubled: computed(() => self.count() * 2),
|
|
35
|
+
}),
|
|
36
|
+
actions: (self) => ({
|
|
37
|
+
inc: () => self.count.update((c: number) => c + 1),
|
|
38
|
+
add: (n: number) => self.count.update((c: number) => c + n),
|
|
39
|
+
reset: () => self.count.set(0),
|
|
40
|
+
}),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// ─── 1. Nested model deletion ────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe("nested model deletion", () => {
|
|
46
|
+
it("replacing a nested child model updates snapshot correctly", () => {
|
|
47
|
+
const Child = model({
|
|
48
|
+
state: { value: 0 },
|
|
49
|
+
actions: (self) => ({
|
|
50
|
+
setValue: (v: number) => self.value.set(v),
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const Parent = model({
|
|
55
|
+
state: { child: Child, label: "parent" },
|
|
56
|
+
actions: (self) => ({
|
|
57
|
+
replaceChild: (c: any) => self.child.set(c),
|
|
58
|
+
setLabel: (l: string) => self.label.set(l),
|
|
59
|
+
}),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const parent = Parent.create({
|
|
63
|
+
child: { value: 10 },
|
|
64
|
+
label: "original",
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(getSnapshot(parent)).toEqual({
|
|
68
|
+
child: { value: 10 },
|
|
69
|
+
label: "original",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Replace child with a new instance
|
|
73
|
+
const newChild = Child.create({ value: 99 })
|
|
74
|
+
parent.replaceChild(newChild)
|
|
75
|
+
|
|
76
|
+
// Snapshot should reflect the new child
|
|
77
|
+
expect(getSnapshot(parent)).toEqual({
|
|
78
|
+
child: { value: 99 },
|
|
79
|
+
label: "original",
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("nested patches stop propagating after child replacement", () => {
|
|
84
|
+
const Child = model({
|
|
85
|
+
state: { x: 0 },
|
|
86
|
+
actions: (self) => ({
|
|
87
|
+
setX: (v: number) => self.x.set(v),
|
|
88
|
+
}),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const Parent = model({
|
|
92
|
+
state: { child: Child },
|
|
93
|
+
actions: (self) => ({
|
|
94
|
+
replaceChild: (c: any) => self.child.set(c),
|
|
95
|
+
}),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const parent = Parent.create({ child: { x: 1 } })
|
|
99
|
+
const oldChild = parent.child()
|
|
100
|
+
|
|
101
|
+
const patches: Patch[] = []
|
|
102
|
+
onPatch(parent, (p) => patches.push(p))
|
|
103
|
+
|
|
104
|
+
// Mutate old child — should propagate
|
|
105
|
+
oldChild.setX(2)
|
|
106
|
+
expect(patches).toHaveLength(1)
|
|
107
|
+
expect(patches[0]).toEqual({ op: "replace", path: "/child/x", value: 2 })
|
|
108
|
+
|
|
109
|
+
// Replace child
|
|
110
|
+
const newChild = Child.create({ x: 50 })
|
|
111
|
+
parent.replaceChild(newChild)
|
|
112
|
+
expect(patches).toHaveLength(2)
|
|
113
|
+
expect(patches[1]!.path).toBe("/child")
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ─── 2. Snapshot edge cases ──────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("snapshot edge cases", () => {
|
|
120
|
+
it("handles null values in state", () => {
|
|
121
|
+
const M = model({
|
|
122
|
+
state: { data: null as string | null },
|
|
123
|
+
actions: (self) => ({
|
|
124
|
+
setData: (v: string | null) => self.data.set(v),
|
|
125
|
+
}),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const m = M.create()
|
|
129
|
+
expect(getSnapshot(m)).toEqual({ data: null })
|
|
130
|
+
|
|
131
|
+
m.setData("hello")
|
|
132
|
+
expect(getSnapshot(m)).toEqual({ data: "hello" })
|
|
133
|
+
|
|
134
|
+
m.setData(null)
|
|
135
|
+
expect(getSnapshot(m)).toEqual({ data: null })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("handles empty arrays in state", () => {
|
|
139
|
+
const M = model({
|
|
140
|
+
state: { items: [] as number[] },
|
|
141
|
+
actions: (self) => ({
|
|
142
|
+
setItems: (v: number[]) => self.items.set(v),
|
|
143
|
+
}),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const m = M.create()
|
|
147
|
+
expect(getSnapshot(m)).toEqual({ items: [] })
|
|
148
|
+
|
|
149
|
+
m.setItems([1, 2, 3])
|
|
150
|
+
expect(getSnapshot(m)).toEqual({ items: [1, 2, 3] })
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("handles Date objects in state", () => {
|
|
154
|
+
const now = new Date("2025-01-01T00:00:00Z")
|
|
155
|
+
const M = model({
|
|
156
|
+
state: { createdAt: now },
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const m = M.create()
|
|
160
|
+
const snap = getSnapshot(m)
|
|
161
|
+
expect(snap.createdAt).toBeInstanceOf(Date)
|
|
162
|
+
expect((snap.createdAt as Date).toISOString()).toBe("2025-01-01T00:00:00.000Z")
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("handles undefined initial values by falling back to defaults", () => {
|
|
166
|
+
const M = model({
|
|
167
|
+
state: { x: 10, y: "hello", z: true },
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// Pass undefined for all — should use defaults
|
|
171
|
+
const m = M.create()
|
|
172
|
+
expect(getSnapshot(m)).toEqual({ x: 10, y: "hello", z: true })
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("handles complex nested objects in state", () => {
|
|
176
|
+
const M = model({
|
|
177
|
+
state: {
|
|
178
|
+
config: { theme: "dark", fontSize: 14, plugins: ["a", "b"] },
|
|
179
|
+
},
|
|
180
|
+
actions: (self) => ({
|
|
181
|
+
setConfig: (c: any) => self.config.set(c),
|
|
182
|
+
}),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const m = M.create()
|
|
186
|
+
expect(getSnapshot(m)).toEqual({
|
|
187
|
+
config: { theme: "dark", fontSize: 14, plugins: ["a", "b"] },
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
m.setConfig({ theme: "light", fontSize: 16, plugins: [] })
|
|
191
|
+
expect(getSnapshot(m)).toEqual({
|
|
192
|
+
config: { theme: "light", fontSize: 16, plugins: [] },
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// ─── 3. Patch replay ─────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
describe("patch replay", () => {
|
|
200
|
+
it("replaying recorded patches on a fresh instance reproduces final state", () => {
|
|
201
|
+
const M = model({
|
|
202
|
+
state: { a: 0, b: "" },
|
|
203
|
+
actions: (self) => ({
|
|
204
|
+
setA: (v: number) => self.a.set(v),
|
|
205
|
+
setB: (v: string) => self.b.set(v),
|
|
206
|
+
}),
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const original = M.create()
|
|
210
|
+
const patches: Patch[] = []
|
|
211
|
+
onPatch(original, (p) => patches.push({ ...p }))
|
|
212
|
+
|
|
213
|
+
original.setA(1)
|
|
214
|
+
original.setB("hello")
|
|
215
|
+
original.setA(2)
|
|
216
|
+
original.setB("world")
|
|
217
|
+
original.setA(42)
|
|
218
|
+
|
|
219
|
+
expect(patches).toHaveLength(5)
|
|
220
|
+
|
|
221
|
+
// Replay on fresh instance
|
|
222
|
+
const replica = M.create()
|
|
223
|
+
applyPatch(replica, patches)
|
|
224
|
+
|
|
225
|
+
expect(getSnapshot(replica)).toEqual(getSnapshot(original))
|
|
226
|
+
expect(replica.a()).toBe(42)
|
|
227
|
+
expect(replica.b()).toBe("world")
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("replaying patches preserves intermediate state transitions", () => {
|
|
231
|
+
const c = Counter.create()
|
|
232
|
+
const patches: Patch[] = []
|
|
233
|
+
onPatch(c, (p) => patches.push({ ...p }))
|
|
234
|
+
|
|
235
|
+
c.inc()
|
|
236
|
+
c.inc()
|
|
237
|
+
c.inc()
|
|
238
|
+
c.add(10)
|
|
239
|
+
c.reset()
|
|
240
|
+
c.add(5)
|
|
241
|
+
|
|
242
|
+
// Final state: 5
|
|
243
|
+
const fresh = Counter.create()
|
|
244
|
+
applyPatch(fresh, patches)
|
|
245
|
+
expect(fresh.count()).toBe(5)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// ─── 4. Patch with nested operations ─────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
describe("patch with nested operations", () => {
|
|
252
|
+
it("applies replace on deeply nested model property", () => {
|
|
253
|
+
const Leaf = model({
|
|
254
|
+
state: { value: 0 },
|
|
255
|
+
actions: (self) => ({
|
|
256
|
+
setValue: (v: number) => self.value.set(v),
|
|
257
|
+
}),
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const Branch = model({
|
|
261
|
+
state: { leaf: Leaf, tag: "" },
|
|
262
|
+
actions: (self) => ({
|
|
263
|
+
setTag: (t: string) => self.tag.set(t),
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const Root = model({
|
|
268
|
+
state: { branch: Branch, name: "root" },
|
|
269
|
+
actions: (self) => ({
|
|
270
|
+
setName: (n: string) => self.name.set(n),
|
|
271
|
+
}),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const root = Root.create({
|
|
275
|
+
branch: { leaf: { value: 1 }, tag: "a" },
|
|
276
|
+
name: "root",
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// Apply patch to deeply nested leaf
|
|
280
|
+
applyPatch(root, { op: "replace", path: "/branch/leaf/value", value: 999 })
|
|
281
|
+
expect(root.branch().leaf().value()).toBe(999)
|
|
282
|
+
|
|
283
|
+
// Apply patch to intermediate level
|
|
284
|
+
applyPatch(root, { op: "replace", path: "/branch/tag", value: "updated" })
|
|
285
|
+
expect(root.branch().tag()).toBe("updated")
|
|
286
|
+
|
|
287
|
+
// Apply patch to top level
|
|
288
|
+
applyPatch(root, { op: "replace", path: "/name", value: "new-root" })
|
|
289
|
+
expect(root.name()).toBe("new-root")
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it("records nested patches with correct paths", () => {
|
|
293
|
+
const app = App.create({
|
|
294
|
+
profile: { name: "Alice", bio: "dev" },
|
|
295
|
+
title: "Test",
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
const patches: Patch[] = []
|
|
299
|
+
onPatch(app, (p) => patches.push({ ...p }))
|
|
300
|
+
|
|
301
|
+
app.profile().rename("Bob")
|
|
302
|
+
app.profile().setBio("engineer")
|
|
303
|
+
app.setTitle("New Title")
|
|
304
|
+
|
|
305
|
+
expect(patches).toEqual([
|
|
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
|
+
])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it("replays nested patches on fresh instance", () => {
|
|
313
|
+
const app = App.create({
|
|
314
|
+
profile: { name: "Alice", bio: "" },
|
|
315
|
+
title: "v1",
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const patches: Patch[] = []
|
|
319
|
+
onPatch(app, (p) => patches.push({ ...p }))
|
|
320
|
+
|
|
321
|
+
app.profile().rename("Carol")
|
|
322
|
+
app.setTitle("v2")
|
|
323
|
+
|
|
324
|
+
const fresh = App.create({
|
|
325
|
+
profile: { name: "Alice", bio: "" },
|
|
326
|
+
title: "v1",
|
|
327
|
+
})
|
|
328
|
+
applyPatch(fresh, patches)
|
|
329
|
+
|
|
330
|
+
expect(fresh.profile().name()).toBe("Carol")
|
|
331
|
+
expect(fresh.title()).toBe("v2")
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// ─── 5. Middleware error handling ─────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
describe("middleware error handling", () => {
|
|
338
|
+
it("throwing middleware does not corrupt state", () => {
|
|
339
|
+
const c = Counter.create({ count: 5 })
|
|
340
|
+
|
|
341
|
+
addMiddleware(c, (_call, _next) => {
|
|
342
|
+
throw new Error("middleware boom")
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
expect(() => c.inc()).toThrow("middleware boom")
|
|
346
|
+
// State should remain unchanged since the action never ran
|
|
347
|
+
expect(c.count()).toBe(5)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it("error in middleware after next() does not undo the action", () => {
|
|
351
|
+
const c = Counter.create({ count: 0 })
|
|
352
|
+
|
|
353
|
+
addMiddleware(c, (call, next) => {
|
|
354
|
+
next(call)
|
|
355
|
+
throw new Error("post-action error")
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
expect(() => c.inc()).toThrow("post-action error")
|
|
359
|
+
// The action DID run before the error
|
|
360
|
+
expect(c.count()).toBe(1)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it("error in one middleware prevents subsequent middlewares from running", () => {
|
|
364
|
+
const c = Counter.create()
|
|
365
|
+
const log: string[] = []
|
|
366
|
+
|
|
367
|
+
addMiddleware(c, (_call, _next) => {
|
|
368
|
+
log.push("first")
|
|
369
|
+
throw new Error("first fails")
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
addMiddleware(c, (call, next) => {
|
|
373
|
+
log.push("second")
|
|
374
|
+
return next(call)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
expect(() => c.inc()).toThrow("first fails")
|
|
378
|
+
// Only the first middleware ran (Koa-style: first wraps second)
|
|
379
|
+
expect(log).toEqual(["first"])
|
|
380
|
+
expect(c.count()).toBe(0)
|
|
381
|
+
})
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// ─── 6. Middleware chain order ────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
describe("middleware chain order", () => {
|
|
387
|
+
it("middlewares fire in registration order (Koa-style onion)", () => {
|
|
388
|
+
const c = Counter.create()
|
|
389
|
+
const log: string[] = []
|
|
390
|
+
|
|
391
|
+
addMiddleware(c, (call, next) => {
|
|
392
|
+
log.push("A:before")
|
|
393
|
+
const result = next(call)
|
|
394
|
+
log.push("A:after")
|
|
395
|
+
return result
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
addMiddleware(c, (call, next) => {
|
|
399
|
+
log.push("B:before")
|
|
400
|
+
const result = next(call)
|
|
401
|
+
log.push("B:after")
|
|
402
|
+
return result
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
addMiddleware(c, (call, next) => {
|
|
406
|
+
log.push("C:before")
|
|
407
|
+
const result = next(call)
|
|
408
|
+
log.push("C:after")
|
|
409
|
+
return result
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
c.inc()
|
|
413
|
+
|
|
414
|
+
expect(log).toEqual(["A:before", "B:before", "C:before", "C:after", "B:after", "A:after"])
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it("middleware can modify action args before passing to next", () => {
|
|
418
|
+
const c = Counter.create()
|
|
419
|
+
|
|
420
|
+
addMiddleware(c, (call, next) => {
|
|
421
|
+
// Double the argument to add()
|
|
422
|
+
if (call.name === "add") {
|
|
423
|
+
return next({ ...call, args: [(call.args[0] as number) * 2] })
|
|
424
|
+
}
|
|
425
|
+
return next(call)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
c.add(5)
|
|
429
|
+
expect(c.count()).toBe(10)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it("middleware can replace action result", () => {
|
|
433
|
+
const M = model({
|
|
434
|
+
state: { value: "" },
|
|
435
|
+
actions: (self) => ({
|
|
436
|
+
getValue: () => {
|
|
437
|
+
return self.value()
|
|
438
|
+
},
|
|
439
|
+
setValue: (v: string) => self.value.set(v),
|
|
440
|
+
}),
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const m = M.create()
|
|
444
|
+
m.setValue("original")
|
|
445
|
+
|
|
446
|
+
addMiddleware(m, (call, next) => {
|
|
447
|
+
const result = next(call)
|
|
448
|
+
if (call.name === "getValue") {
|
|
449
|
+
return `intercepted:${result}`
|
|
450
|
+
}
|
|
451
|
+
return result
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
expect(m.getValue()).toBe("intercepted:original")
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
// ─── 7. Hook singleton behavior ──────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
describe("hook singleton behavior", () => {
|
|
461
|
+
afterEach(() => resetAllHooks())
|
|
462
|
+
|
|
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
|
+
|
|
467
|
+
// The hook functions may be different references but the instance they return is the same
|
|
468
|
+
const instance1 = useCounter1()
|
|
469
|
+
const instance2 = useCounter2()
|
|
470
|
+
expect(instance1).toBe(instance2)
|
|
471
|
+
})
|
|
472
|
+
|
|
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
|
+
|
|
477
|
+
useA().inc()
|
|
478
|
+
useA().inc()
|
|
479
|
+
|
|
480
|
+
expect(useB().count()).toBe(2)
|
|
481
|
+
expect(useA().doubled()).toBe(4)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it("different models with same hook id share the same registry entry", () => {
|
|
485
|
+
// First model claims the id
|
|
486
|
+
const useFirst = Counter.asHook("conflict-id")
|
|
487
|
+
const instance = useFirst()
|
|
488
|
+
|
|
489
|
+
// Second model with same id returns the already-created instance (Counter, not Profile)
|
|
490
|
+
const useSecond = Profile.asHook("conflict-id")
|
|
491
|
+
expect(useSecond()).toBe(instance)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it("resetAllHooks makes subsequent calls create fresh instances", () => {
|
|
495
|
+
const useC = Counter.asHook("fresh-test")
|
|
496
|
+
const old = useC()
|
|
497
|
+
old.add(100)
|
|
498
|
+
|
|
499
|
+
resetAllHooks()
|
|
500
|
+
|
|
501
|
+
const fresh = useC()
|
|
502
|
+
expect(fresh).not.toBe(old)
|
|
503
|
+
expect(fresh.count()).toBe(0)
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
// ─── 8. Model with no actions ────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
describe("model with no actions", () => {
|
|
510
|
+
it("creates instance with only state", () => {
|
|
511
|
+
const ReadOnly = model({
|
|
512
|
+
state: { x: 10, y: "hello" },
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
const r = ReadOnly.create()
|
|
516
|
+
expect(r.x()).toBe(10)
|
|
517
|
+
expect(r.y()).toBe("hello")
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it("supports views without actions", () => {
|
|
521
|
+
const Derived = model({
|
|
522
|
+
state: { a: 3, b: 4 },
|
|
523
|
+
views: (self) => ({
|
|
524
|
+
sum: computed(() => self.a() + self.b()),
|
|
525
|
+
product: computed(() => self.a() * self.b()),
|
|
526
|
+
}),
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
const d = Derived.create()
|
|
530
|
+
expect(d.sum()).toBe(7)
|
|
531
|
+
expect(d.product()).toBe(12)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it("snapshots work on action-less models", () => {
|
|
535
|
+
const M = model({ state: { val: 42 } })
|
|
536
|
+
const m = M.create({ val: 100 })
|
|
537
|
+
expect(getSnapshot(m)).toEqual({ val: 100 })
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it("applySnapshot works on action-less models", () => {
|
|
541
|
+
const M = model({ state: { val: 0 } })
|
|
542
|
+
const m = M.create()
|
|
543
|
+
applySnapshot(m, { val: 999 })
|
|
544
|
+
expect(m.val()).toBe(999)
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it("onPatch works on action-less models when signals are set directly", () => {
|
|
548
|
+
const M = model({ state: { val: 0 } })
|
|
549
|
+
const m = M.create()
|
|
550
|
+
const patches: Patch[] = []
|
|
551
|
+
onPatch(m, (p) => patches.push(p))
|
|
552
|
+
|
|
553
|
+
// Set via tracked signal directly
|
|
554
|
+
m.val.set(5)
|
|
555
|
+
expect(patches).toHaveLength(1)
|
|
556
|
+
expect(patches[0]).toEqual({ op: "replace", path: "/val", value: 5 })
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
it("middleware can be added to action-less models (no-op since no actions)", () => {
|
|
560
|
+
const M = model({ state: { val: 0 } })
|
|
561
|
+
const m = M.create()
|
|
562
|
+
const log: string[] = []
|
|
563
|
+
|
|
564
|
+
const unsub = addMiddleware(m, (call, next) => {
|
|
565
|
+
log.push(call.name)
|
|
566
|
+
return next(call)
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
// No actions to call, so middleware never fires
|
|
570
|
+
m.val.set(10) // Direct signal set bypasses middleware
|
|
571
|
+
expect(log).toHaveLength(0)
|
|
572
|
+
expect(m.val()).toBe(10)
|
|
573
|
+
|
|
574
|
+
unsub()
|
|
575
|
+
})
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
// ─── 9. applySnapshot with partial data ──────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
describe("applySnapshot with partial data", () => {
|
|
581
|
+
it("only updates specified fields, keeps others unchanged", () => {
|
|
582
|
+
const M = model({
|
|
583
|
+
state: { name: "default", age: 0, active: false },
|
|
584
|
+
actions: (self) => ({
|
|
585
|
+
setName: (n: string) => self.name.set(n),
|
|
586
|
+
setAge: (a: number) => self.age.set(a),
|
|
587
|
+
}),
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
const m = M.create({ name: "Alice", age: 30, active: true })
|
|
591
|
+
|
|
592
|
+
// Only update name
|
|
593
|
+
applySnapshot(m, { name: "Bob" })
|
|
594
|
+
expect(m.name()).toBe("Bob")
|
|
595
|
+
expect(m.age()).toBe(30)
|
|
596
|
+
expect(m.active()).toBe(true)
|
|
597
|
+
|
|
598
|
+
// Only update age
|
|
599
|
+
applySnapshot(m, { age: 25 })
|
|
600
|
+
expect(m.name()).toBe("Bob")
|
|
601
|
+
expect(m.age()).toBe(25)
|
|
602
|
+
expect(m.active()).toBe(true)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it("empty snapshot changes nothing", () => {
|
|
606
|
+
const M = model({ state: { x: 1, y: 2 } })
|
|
607
|
+
const m = M.create()
|
|
608
|
+
|
|
609
|
+
applySnapshot(m, {})
|
|
610
|
+
expect(m.x()).toBe(1)
|
|
611
|
+
expect(m.y()).toBe(2)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it("partial nested snapshot updates only specified nested fields", () => {
|
|
615
|
+
const app = App.create({
|
|
616
|
+
profile: { name: "Alice", bio: "dev" },
|
|
617
|
+
title: "Old",
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
// Update only the nested profile name, leave bio unchanged
|
|
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
|
+
})
|
|
626
|
+
|
|
627
|
+
it("applySnapshot batches updates — effect fires once", () => {
|
|
628
|
+
const M = model({ state: { a: 0, b: 0, c: 0 } })
|
|
629
|
+
const m = M.create()
|
|
630
|
+
|
|
631
|
+
let effectRuns = 0
|
|
632
|
+
effect(() => {
|
|
633
|
+
m.a()
|
|
634
|
+
m.b()
|
|
635
|
+
m.c()
|
|
636
|
+
effectRuns++
|
|
637
|
+
})
|
|
638
|
+
effectRuns = 0
|
|
639
|
+
|
|
640
|
+
applySnapshot(m, { a: 1, b: 2, c: 3 })
|
|
641
|
+
expect(effectRuns).toBe(1)
|
|
642
|
+
})
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
// ─── 10. onPatch listener cleanup ────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
describe("onPatch listener cleanup", () => {
|
|
648
|
+
it("unsubscribe prevents further callbacks", () => {
|
|
649
|
+
const c = Counter.create()
|
|
650
|
+
const patches: Patch[] = []
|
|
651
|
+
const unsub = onPatch(c, (p) => patches.push(p))
|
|
652
|
+
|
|
653
|
+
c.inc()
|
|
654
|
+
expect(patches).toHaveLength(1)
|
|
655
|
+
|
|
656
|
+
unsub()
|
|
657
|
+
|
|
658
|
+
c.inc()
|
|
659
|
+
c.inc()
|
|
660
|
+
expect(patches).toHaveLength(1) // No new patches after unsub
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it("multiple listeners can be independently unsubscribed", () => {
|
|
664
|
+
const c = Counter.create()
|
|
665
|
+
const logA: Patch[] = []
|
|
666
|
+
const logB: Patch[] = []
|
|
667
|
+
|
|
668
|
+
const unsubA = onPatch(c, (p) => logA.push(p))
|
|
669
|
+
const unsubB = onPatch(c, (p) => logB.push(p))
|
|
670
|
+
|
|
671
|
+
c.inc()
|
|
672
|
+
expect(logA).toHaveLength(1)
|
|
673
|
+
expect(logB).toHaveLength(1)
|
|
674
|
+
|
|
675
|
+
// Unsub A, B still receives
|
|
676
|
+
unsubA()
|
|
677
|
+
c.inc()
|
|
678
|
+
expect(logA).toHaveLength(1)
|
|
679
|
+
expect(logB).toHaveLength(2)
|
|
680
|
+
|
|
681
|
+
// Unsub B
|
|
682
|
+
unsubB()
|
|
683
|
+
c.inc()
|
|
684
|
+
expect(logA).toHaveLength(1)
|
|
685
|
+
expect(logB).toHaveLength(2)
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
it("double unsubscribe is safe (no-op)", () => {
|
|
689
|
+
const c = Counter.create()
|
|
690
|
+
const unsub = onPatch(c, () => {})
|
|
691
|
+
|
|
692
|
+
unsub()
|
|
693
|
+
expect(() => unsub()).not.toThrow()
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
it("listener added after unsub receives only new patches", () => {
|
|
697
|
+
const c = Counter.create()
|
|
698
|
+
const log1: Patch[] = []
|
|
699
|
+
const log2: Patch[] = []
|
|
700
|
+
|
|
701
|
+
const unsub1 = onPatch(c, (p) => log1.push(p))
|
|
702
|
+
c.inc() // log1: 1 patch
|
|
703
|
+
|
|
704
|
+
unsub1()
|
|
705
|
+
c.inc() // No listener
|
|
706
|
+
|
|
707
|
+
onPatch(c, (p) => log2.push(p))
|
|
708
|
+
c.inc() // log2: 1 patch
|
|
709
|
+
|
|
710
|
+
expect(log1).toHaveLength(1)
|
|
711
|
+
expect(log2).toHaveLength(1)
|
|
712
|
+
expect(log1[0]!.value).toBe(1) // First inc
|
|
713
|
+
expect(log2[0]!.value).toBe(3) // Third inc
|
|
714
|
+
})
|
|
715
|
+
})
|