@pyreon/core 0.11.3 → 0.11.5

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.
@@ -0,0 +1,208 @@
1
+ import {
2
+ getCurrentHooks,
3
+ onErrorCaptured,
4
+ onMount,
5
+ onUnmount,
6
+ onUpdate,
7
+ setCurrentHooks,
8
+ } from "../lifecycle"
9
+ import type { LifecycleHooks } from "../types"
10
+
11
+ describe("setCurrentHooks / getCurrentHooks", () => {
12
+ afterEach(() => {
13
+ setCurrentHooks(null)
14
+ })
15
+
16
+ test("getCurrentHooks returns null by default", () => {
17
+ expect(getCurrentHooks()).toBeNull()
18
+ })
19
+
20
+ test("setCurrentHooks sets the current hooks context", () => {
21
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
22
+ setCurrentHooks(hooks)
23
+ expect(getCurrentHooks()).toBe(hooks)
24
+ })
25
+
26
+ test("setCurrentHooks(null) clears the context", () => {
27
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
28
+ setCurrentHooks(hooks)
29
+ expect(getCurrentHooks()).toBe(hooks)
30
+ setCurrentHooks(null)
31
+ expect(getCurrentHooks()).toBeNull()
32
+ })
33
+ })
34
+
35
+ describe("onMount", () => {
36
+ afterEach(() => {
37
+ setCurrentHooks(null)
38
+ })
39
+
40
+ test("registers callback on current hooks", () => {
41
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
42
+ setCurrentHooks(hooks)
43
+ const fn = () => undefined
44
+ onMount(fn)
45
+ expect(hooks.mount).toHaveLength(1)
46
+ expect(hooks.mount[0]).toBe(fn)
47
+ })
48
+
49
+ test("multiple onMount calls accumulate", () => {
50
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
51
+ setCurrentHooks(hooks)
52
+ onMount(() => undefined)
53
+ onMount(() => undefined)
54
+ onMount(() => undefined)
55
+ expect(hooks.mount).toHaveLength(3)
56
+ })
57
+
58
+ test("warns when called outside component setup", () => {
59
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
60
+ onMount(() => {})
61
+ expect(warnSpy).toHaveBeenCalledWith(
62
+ expect.stringContaining("onMount() called outside component setup"),
63
+ )
64
+ warnSpy.mockRestore()
65
+ })
66
+
67
+ test("is a no-op outside component setup (no crash)", () => {
68
+ expect(() => onMount(() => {})).not.toThrow()
69
+ })
70
+
71
+ test("accepts callback returning cleanup function", () => {
72
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
73
+ setCurrentHooks(hooks)
74
+ const cleanup = () => {}
75
+ onMount(() => cleanup)
76
+ expect(hooks.mount).toHaveLength(1)
77
+ expect(hooks.mount[0]!()).toBe(cleanup)
78
+ })
79
+
80
+ test("accepts callback returning void", () => {
81
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
82
+ setCurrentHooks(hooks)
83
+ onMount(() => {})
84
+ expect(hooks.mount).toHaveLength(1)
85
+ expect(hooks.mount[0]!()).toBeUndefined()
86
+ })
87
+ })
88
+
89
+ describe("onUnmount", () => {
90
+ afterEach(() => {
91
+ setCurrentHooks(null)
92
+ })
93
+
94
+ test("registers callback on current hooks", () => {
95
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
96
+ setCurrentHooks(hooks)
97
+ const fn = () => {}
98
+ onUnmount(fn)
99
+ expect(hooks.unmount).toHaveLength(1)
100
+ expect(hooks.unmount[0]).toBe(fn)
101
+ })
102
+
103
+ test("warns when called outside component setup", () => {
104
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
105
+ onUnmount(() => {})
106
+ expect(warnSpy).toHaveBeenCalledWith(
107
+ expect.stringContaining("onUnmount() called outside component setup"),
108
+ )
109
+ warnSpy.mockRestore()
110
+ })
111
+ })
112
+
113
+ describe("onUpdate", () => {
114
+ afterEach(() => {
115
+ setCurrentHooks(null)
116
+ })
117
+
118
+ test("registers callback on current hooks", () => {
119
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
120
+ setCurrentHooks(hooks)
121
+ const fn = () => {}
122
+ onUpdate(fn)
123
+ expect(hooks.update).toHaveLength(1)
124
+ expect(hooks.update[0]).toBe(fn)
125
+ })
126
+
127
+ test("warns when called outside component setup", () => {
128
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
129
+ onUpdate(() => {})
130
+ expect(warnSpy).toHaveBeenCalledWith(
131
+ expect.stringContaining("onUpdate() called outside component setup"),
132
+ )
133
+ warnSpy.mockRestore()
134
+ })
135
+ })
136
+
137
+ describe("onErrorCaptured", () => {
138
+ afterEach(() => {
139
+ setCurrentHooks(null)
140
+ })
141
+
142
+ test("registers callback on current hooks", () => {
143
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
144
+ setCurrentHooks(hooks)
145
+ const fn = () => true
146
+ onErrorCaptured(fn)
147
+ expect(hooks.error).toHaveLength(1)
148
+ expect(hooks.error[0]).toBe(fn)
149
+ })
150
+
151
+ test("warns when called outside component setup", () => {
152
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
153
+ onErrorCaptured(() => true)
154
+ expect(warnSpy).toHaveBeenCalledWith(
155
+ expect.stringContaining("onErrorCaptured() called outside component setup"),
156
+ )
157
+ warnSpy.mockRestore()
158
+ })
159
+
160
+ test("registered handler receives the error", () => {
161
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
162
+ setCurrentHooks(hooks)
163
+ let captured: unknown = null
164
+ onErrorCaptured((err) => {
165
+ captured = err
166
+ return true
167
+ })
168
+ // Simulate calling the handler
169
+ const testError = new Error("test")
170
+ hooks.error[0]!(testError)
171
+ expect(captured).toBe(testError)
172
+ })
173
+ })
174
+
175
+ describe("lifecycle hooks interaction", () => {
176
+ afterEach(() => {
177
+ setCurrentHooks(null)
178
+ })
179
+
180
+ test("all hook types can be registered in same context", () => {
181
+ const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
182
+ setCurrentHooks(hooks)
183
+
184
+ onMount(() => undefined)
185
+ onUnmount(() => {})
186
+ onUpdate(() => {})
187
+ onErrorCaptured(() => true)
188
+
189
+ expect(hooks.mount).toHaveLength(1)
190
+ expect(hooks.unmount).toHaveLength(1)
191
+ expect(hooks.update).toHaveLength(1)
192
+ expect(hooks.error).toHaveLength(1)
193
+ })
194
+
195
+ test("hooks from different setCurrentHooks calls go to different stores", () => {
196
+ const hooks1: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
197
+ const hooks2: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
198
+
199
+ setCurrentHooks(hooks1)
200
+ onMount(() => undefined)
201
+ setCurrentHooks(hooks2)
202
+ onMount(() => undefined)
203
+ onMount(() => undefined)
204
+
205
+ expect(hooks1.mount).toHaveLength(1)
206
+ expect(hooks2.mount).toHaveLength(2)
207
+ })
208
+ })
@@ -0,0 +1,313 @@
1
+ import { mapArray } from "../map-array"
2
+
3
+ describe("mapArray", () => {
4
+ describe("basic mapping", () => {
5
+ test("maps all items on first call", () => {
6
+ const mapped = mapArray(
7
+ () => [1, 2, 3],
8
+ (item) => item,
9
+ (item) => item * 10,
10
+ )
11
+ expect(mapped()).toEqual([10, 20, 30])
12
+ })
13
+
14
+ test("returns empty array for empty source", () => {
15
+ const mapped = mapArray(
16
+ () => [],
17
+ (item: number) => item,
18
+ (item) => item * 10,
19
+ )
20
+ expect(mapped()).toEqual([])
21
+ })
22
+
23
+ test("maps single item", () => {
24
+ const mapped = mapArray(
25
+ () => [42],
26
+ (item) => item,
27
+ (item) => `value-${item}`,
28
+ )
29
+ expect(mapped()).toEqual(["value-42"])
30
+ })
31
+ })
32
+
33
+ describe("caching behavior", () => {
34
+ test("caches results — map function called once per key", () => {
35
+ let callCount = 0
36
+ const items = [1, 2, 3]
37
+ const mapped = mapArray(
38
+ () => items,
39
+ (item) => item,
40
+ (item) => {
41
+ callCount++
42
+ return item * 10
43
+ },
44
+ )
45
+
46
+ mapped()
47
+ expect(callCount).toBe(3)
48
+
49
+ // Second call — all cached
50
+ mapped()
51
+ expect(callCount).toBe(3)
52
+
53
+ // Third call — still cached
54
+ mapped()
55
+ expect(callCount).toBe(3)
56
+ })
57
+
58
+ test("only maps new keys when items are added", () => {
59
+ let callCount = 0
60
+ let items = [1, 2, 3]
61
+ const mapped = mapArray(
62
+ () => items,
63
+ (item) => item,
64
+ (item) => {
65
+ callCount++
66
+ return item * 10
67
+ },
68
+ )
69
+
70
+ mapped()
71
+ expect(callCount).toBe(3)
72
+
73
+ items = [1, 2, 3, 4, 5]
74
+ mapped()
75
+ expect(callCount).toBe(5) // only 4 and 5 are new
76
+ })
77
+
78
+ test("does not re-map when items are removed", () => {
79
+ let callCount = 0
80
+ let items = [1, 2, 3, 4, 5]
81
+ const mapped = mapArray(
82
+ () => items,
83
+ (item) => item,
84
+ (item) => {
85
+ callCount++
86
+ return item * 10
87
+ },
88
+ )
89
+
90
+ mapped()
91
+ expect(callCount).toBe(5)
92
+
93
+ items = [1, 3, 5] // remove 2 and 4
94
+ const result = mapped()
95
+ expect(result).toEqual([10, 30, 50])
96
+ expect(callCount).toBe(5) // no new calls
97
+ })
98
+ })
99
+
100
+ describe("key eviction", () => {
101
+ test("evicted keys are re-mapped when they return", () => {
102
+ let callCount = 0
103
+ let items = [1, 2, 3]
104
+ const mapped = mapArray(
105
+ () => items,
106
+ (item) => item,
107
+ (item) => {
108
+ callCount++
109
+ return item * 10
110
+ },
111
+ )
112
+
113
+ mapped()
114
+ expect(callCount).toBe(3)
115
+
116
+ // Remove key 2
117
+ items = [1, 3]
118
+ mapped()
119
+ expect(callCount).toBe(3) // no new mapping
120
+
121
+ // Re-add key 2 — should re-map since it was evicted
122
+ items = [1, 2, 3]
123
+ mapped()
124
+ expect(callCount).toBe(4) // key 2 re-mapped
125
+ })
126
+
127
+ test("evicts all keys when source becomes empty", () => {
128
+ let callCount = 0
129
+ let items: number[] = [1, 2, 3]
130
+ const mapped = mapArray(
131
+ () => items,
132
+ (item) => item,
133
+ (item) => {
134
+ callCount++
135
+ return item * 10
136
+ },
137
+ )
138
+
139
+ mapped()
140
+ expect(callCount).toBe(3)
141
+
142
+ items = []
143
+ mapped()
144
+ expect(callCount).toBe(3)
145
+
146
+ // All keys were evicted — re-adding requires re-mapping
147
+ items = [1, 2, 3]
148
+ mapped()
149
+ expect(callCount).toBe(6)
150
+ })
151
+ })
152
+
153
+ describe("reordering", () => {
154
+ test("reordered items use cached values (no re-mapping)", () => {
155
+ let callCount = 0
156
+ let items = [1, 2, 3]
157
+ const mapped = mapArray(
158
+ () => items,
159
+ (item) => item,
160
+ (item) => {
161
+ callCount++
162
+ return item * 10
163
+ },
164
+ )
165
+
166
+ mapped()
167
+ expect(callCount).toBe(3)
168
+
169
+ items = [3, 1, 2]
170
+ const result = mapped()
171
+ expect(result).toEqual([30, 10, 20])
172
+ expect(callCount).toBe(3) // no new calls
173
+ })
174
+
175
+ test("reverse order uses cached values", () => {
176
+ let callCount = 0
177
+ let items = [1, 2, 3, 4]
178
+ const mapped = mapArray(
179
+ () => items,
180
+ (item) => item,
181
+ (item) => {
182
+ callCount++
183
+ return `item-${item}`
184
+ },
185
+ )
186
+
187
+ mapped()
188
+ items = [4, 3, 2, 1]
189
+ const result = mapped()
190
+ expect(result).toEqual(["item-4", "item-3", "item-2", "item-1"])
191
+ expect(callCount).toBe(4) // initial 4 only
192
+ })
193
+ })
194
+
195
+ describe("string keys", () => {
196
+ test("works with string keys from objects", () => {
197
+ interface User {
198
+ id: string
199
+ name: string
200
+ }
201
+ let callCount = 0
202
+ let users: User[] = [
203
+ { id: "a", name: "Alice" },
204
+ { id: "b", name: "Bob" },
205
+ ]
206
+ const mapped = mapArray(
207
+ () => users,
208
+ (u) => u.id,
209
+ (u) => {
210
+ callCount++
211
+ return u.name.toUpperCase()
212
+ },
213
+ )
214
+
215
+ expect(mapped()).toEqual(["ALICE", "BOB"])
216
+ expect(callCount).toBe(2)
217
+
218
+ // Add new user
219
+ users = [
220
+ { id: "a", name: "Alice" },
221
+ { id: "b", name: "Bob" },
222
+ { id: "c", name: "Charlie" },
223
+ ]
224
+ expect(mapped()).toEqual(["ALICE", "BOB", "CHARLIE"])
225
+ expect(callCount).toBe(3)
226
+ })
227
+ })
228
+
229
+ describe("mixed additions and removals", () => {
230
+ test("simultaneous add and remove", () => {
231
+ let callCount = 0
232
+ let items = [1, 2, 3]
233
+ const mapped = mapArray(
234
+ () => items,
235
+ (item) => item,
236
+ (item) => {
237
+ callCount++
238
+ return item * 10
239
+ },
240
+ )
241
+
242
+ mapped()
243
+ expect(callCount).toBe(3)
244
+
245
+ // Remove 2, add 4
246
+ items = [1, 3, 4]
247
+ const result = mapped()
248
+ expect(result).toEqual([10, 30, 40])
249
+ expect(callCount).toBe(4) // only key 4 is new
250
+ })
251
+
252
+ test("complete replacement of all items", () => {
253
+ let callCount = 0
254
+ let items = [1, 2, 3]
255
+ const mapped = mapArray(
256
+ () => items,
257
+ (item) => item,
258
+ (item) => {
259
+ callCount++
260
+ return item * 10
261
+ },
262
+ )
263
+
264
+ mapped()
265
+ expect(callCount).toBe(3)
266
+
267
+ items = [4, 5, 6]
268
+ const result = mapped()
269
+ expect(result).toEqual([40, 50, 60])
270
+ expect(callCount).toBe(6) // all new
271
+ })
272
+ })
273
+
274
+ describe("duplicate keys", () => {
275
+ test("duplicate keys in source share the same cached value", () => {
276
+ let callCount = 0
277
+ const mapped = mapArray(
278
+ () => [1, 1, 2],
279
+ (item) => item,
280
+ (item) => {
281
+ callCount++
282
+ return item * 10
283
+ },
284
+ )
285
+
286
+ const result = mapped()
287
+ // Key 1 mapped once, key 2 mapped once
288
+ expect(callCount).toBe(2)
289
+ // Both occurrences of key 1 get the same cached value
290
+ expect(result).toEqual([10, 10, 20])
291
+ })
292
+ })
293
+
294
+ describe("map function receives correct item", () => {
295
+ test("map receives the item, not the key", () => {
296
+ const received: Array<{ id: number; val: string }> = []
297
+ const items = [
298
+ { id: 1, val: "a" },
299
+ { id: 2, val: "b" },
300
+ ]
301
+ const mapped = mapArray(
302
+ () => items,
303
+ (item) => item.id,
304
+ (item) => {
305
+ received.push(item)
306
+ return item.val
307
+ },
308
+ )
309
+ mapped()
310
+ expect(received).toEqual(items)
311
+ })
312
+ })
313
+ })
@@ -0,0 +1,48 @@
1
+ import { h } from "../h"
2
+ import { Portal, PortalSymbol } from "../portal"
3
+ import type { VNode } from "../types"
4
+
5
+ describe("Portal", () => {
6
+ test("returns VNode with PortalSymbol type", () => {
7
+ const fakeTarget = {} as Element
8
+ const node = Portal({ target: fakeTarget, children: h("div", null) })
9
+ expect(node.type).toBe(PortalSymbol)
10
+ })
11
+
12
+ test("VNode has null key", () => {
13
+ const node = Portal({ target: {} as Element, children: "content" })
14
+ expect(node.key).toBeNull()
15
+ })
16
+
17
+ test("VNode has empty children array", () => {
18
+ const node = Portal({ target: {} as Element, children: "content" })
19
+ expect(node.children).toEqual([])
20
+ })
21
+
22
+ test("props contain target and children", () => {
23
+ const fakeTarget = {} as Element
24
+ const child = h("span", null, "content")
25
+ const node = Portal({ target: fakeTarget, children: child })
26
+ const props = node.props as unknown as { target: Element; children: VNode }
27
+ expect(props.target).toBe(fakeTarget)
28
+ expect(props.children).toBe(child)
29
+ })
30
+
31
+ test("PortalSymbol is a unique symbol", () => {
32
+ expect(typeof PortalSymbol).toBe("symbol")
33
+ expect(PortalSymbol.toString()).toContain("pyreon.Portal")
34
+ })
35
+
36
+ test("string children are stored in props", () => {
37
+ const node = Portal({ target: {} as Element, children: "text content" })
38
+ const props = node.props as unknown as { children: string }
39
+ expect(props.children).toBe("text content")
40
+ })
41
+
42
+ test("multiple VNode children via fragment", () => {
43
+ const children = h("div", null, h("span", null, "a"), h("span", null, "b"))
44
+ const node = Portal({ target: {} as Element, children })
45
+ const props = node.props as unknown as { children: VNode }
46
+ expect((props.children as VNode).type).toBe("div")
47
+ })
48
+ })