@pyreon/runtime-dom 0.1.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/LICENSE +21 -0
- package/README.md +65 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +1909 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +1845 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +355 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +48 -0
- package/src/devtools.ts +304 -0
- package/src/hydrate.ts +385 -0
- package/src/hydration-debug.ts +39 -0
- package/src/index.ts +43 -0
- package/src/keep-alive.ts +71 -0
- package/src/mount.ts +367 -0
- package/src/nodes.ts +741 -0
- package/src/props.ts +328 -0
- package/src/template.ts +81 -0
- package/src/tests/coverage-gaps.test.ts +2488 -0
- package/src/tests/coverage.test.ts +1123 -0
- package/src/tests/mount.test.ts +3098 -0
- package/src/tests/setup.ts +3 -0
- package/src/transition-group.ts +264 -0
- package/src/transition.ts +184 -0
|
@@ -0,0 +1,2488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Additional targeted tests to push branch coverage to >= 95%.
|
|
3
|
+
* Focuses on specific uncovered branches in:
|
|
4
|
+
* - devtools.ts (lines 139, 149-165, 226)
|
|
5
|
+
* - hydrate.ts (lines 162-183)
|
|
6
|
+
* - keep-alive.ts (lines 48, 55)
|
|
7
|
+
* - nodes.ts (lines 175-178, 338, 385)
|
|
8
|
+
* - props.ts (lines 182, 190, 273-277)
|
|
9
|
+
* - transition.ts (lines 111-113)
|
|
10
|
+
* - transition-group.ts (lines 209-218)
|
|
11
|
+
* - mount.ts (lines 204-206)
|
|
12
|
+
* - hydration-debug.ts (line 35)
|
|
13
|
+
*/
|
|
14
|
+
import type { ComponentFn, VNodeChild } from "@pyreon/core"
|
|
15
|
+
import { createRef, defineComponent, For, Fragment, h, onMount, onUnmount } from "@pyreon/core"
|
|
16
|
+
import { signal } from "@pyreon/reactivity"
|
|
17
|
+
import {
|
|
18
|
+
installDevTools,
|
|
19
|
+
onOverlayClick,
|
|
20
|
+
onOverlayMouseMove,
|
|
21
|
+
registerComponent,
|
|
22
|
+
unregisterComponent,
|
|
23
|
+
} from "../devtools"
|
|
24
|
+
import { warnHydrationMismatch } from "../hydration-debug"
|
|
25
|
+
import {
|
|
26
|
+
KeepAlive as _KeepAlive,
|
|
27
|
+
Transition as _Transition,
|
|
28
|
+
TransitionGroup as _TransitionGroup,
|
|
29
|
+
_tpl,
|
|
30
|
+
disableHydrationWarnings,
|
|
31
|
+
enableHydrationWarnings,
|
|
32
|
+
hydrateRoot,
|
|
33
|
+
mount,
|
|
34
|
+
sanitizeHtml,
|
|
35
|
+
setSanitizer,
|
|
36
|
+
} from "../index"
|
|
37
|
+
import { mountChild } from "../mount"
|
|
38
|
+
import { applyProp } from "../props"
|
|
39
|
+
|
|
40
|
+
const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>>
|
|
41
|
+
const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
|
|
42
|
+
const KeepAlive = _KeepAlive as unknown as ComponentFn<Record<string, unknown>>
|
|
43
|
+
|
|
44
|
+
function container(): HTMLElement {
|
|
45
|
+
const el = document.createElement("div")
|
|
46
|
+
document.body.appendChild(el)
|
|
47
|
+
return el
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── hydration-debug.ts — line 35: _enabled=false early return ──────────────
|
|
51
|
+
|
|
52
|
+
describe("hydration-debug — disabled warnings branch", () => {
|
|
53
|
+
test("warnHydrationMismatch does nothing when warnings disabled", () => {
|
|
54
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
55
|
+
disableHydrationWarnings()
|
|
56
|
+
warnHydrationMismatch("tag", "div", "span", "root > test")
|
|
57
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
58
|
+
enableHydrationWarnings()
|
|
59
|
+
warnSpy.mockRestore()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("warnHydrationMismatch emits when warnings enabled", () => {
|
|
63
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
64
|
+
enableHydrationWarnings()
|
|
65
|
+
warnHydrationMismatch("tag", "div", "span", "root > test")
|
|
66
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Hydration mismatch"))
|
|
67
|
+
warnSpy.mockRestore()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ─── devtools.ts — line 139: tooltip below element when rect.top < 35 ──────
|
|
72
|
+
|
|
73
|
+
describe("devtools — tooltip repositioning and click paths", () => {
|
|
74
|
+
beforeAll(() => {
|
|
75
|
+
installDevTools()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("highlight with valid element applies and removes outline", async () => {
|
|
79
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
80
|
+
highlight: (id: string) => void
|
|
81
|
+
}
|
|
82
|
+
const target = document.createElement("div")
|
|
83
|
+
document.body.appendChild(target)
|
|
84
|
+
registerComponent("highlight-test", "HighlightComp", target, null)
|
|
85
|
+
|
|
86
|
+
devtools.highlight("highlight-test")
|
|
87
|
+
expect((target as HTMLElement).style.outline).toContain("#00b4d8")
|
|
88
|
+
|
|
89
|
+
// Wait for the timeout to clear the outline (line 226)
|
|
90
|
+
await new Promise<void>((r) => setTimeout(r, 1600))
|
|
91
|
+
expect((target as HTMLElement).style.outline).toBe("")
|
|
92
|
+
|
|
93
|
+
unregisterComponent("highlight-test")
|
|
94
|
+
target.remove()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("highlight with nonexistent id does nothing", () => {
|
|
98
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
99
|
+
highlight: (id: string) => void
|
|
100
|
+
}
|
|
101
|
+
// Should not throw
|
|
102
|
+
devtools.highlight("nonexistent-id")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("highlight with no element (el: null) does nothing", () => {
|
|
106
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
107
|
+
highlight: (id: string) => void
|
|
108
|
+
}
|
|
109
|
+
registerComponent("no-el", "NoElComp", null, null)
|
|
110
|
+
// entry exists but el is null — should early return (line 221)
|
|
111
|
+
devtools.highlight("no-el")
|
|
112
|
+
unregisterComponent("no-el")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("onComponentMount listener fires on register", () => {
|
|
116
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
117
|
+
onComponentMount: (cb: (entry: unknown) => void) => () => void
|
|
118
|
+
onComponentUnmount: (cb: (id: string) => void) => () => void
|
|
119
|
+
}
|
|
120
|
+
let mountedEntry: unknown = null
|
|
121
|
+
const unsub = devtools.onComponentMount((entry) => {
|
|
122
|
+
mountedEntry = entry
|
|
123
|
+
})
|
|
124
|
+
registerComponent("listener-test", "ListenerComp", null, null)
|
|
125
|
+
expect(mountedEntry).not.toBeNull()
|
|
126
|
+
|
|
127
|
+
let unmountedId: string | null = null
|
|
128
|
+
const unsub2 = devtools.onComponentUnmount((id) => {
|
|
129
|
+
unmountedId = id
|
|
130
|
+
})
|
|
131
|
+
unregisterComponent("listener-test")
|
|
132
|
+
expect(unmountedId).toBe("listener-test")
|
|
133
|
+
|
|
134
|
+
// Unsubscribe
|
|
135
|
+
unsub()
|
|
136
|
+
unsub2()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("unregisterComponent with non-existent id does nothing", () => {
|
|
140
|
+
// Should not throw — early return on line 61
|
|
141
|
+
unregisterComponent("does-not-exist")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("unregisterComponent with no parent does not try to update parent", () => {
|
|
145
|
+
registerComponent("orphan", "Orphan", null, null)
|
|
146
|
+
// parentId is null — should skip parent.childIds update
|
|
147
|
+
unregisterComponent("orphan")
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("onComponentMount unsubscribe with already-removed listener is safe", () => {
|
|
151
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
152
|
+
onComponentMount: (cb: (entry: unknown) => void) => () => void
|
|
153
|
+
}
|
|
154
|
+
const cb = () => {}
|
|
155
|
+
const unsub = devtools.onComponentMount(cb)
|
|
156
|
+
unsub()
|
|
157
|
+
// Second unsub — indexOf returns -1, should not splice
|
|
158
|
+
unsub()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("onComponentUnmount unsubscribe with already-removed listener is safe", () => {
|
|
162
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
163
|
+
onComponentUnmount: (cb: (id: string) => void) => () => void
|
|
164
|
+
}
|
|
165
|
+
const cb = () => {}
|
|
166
|
+
const unsub = devtools.onComponentUnmount(cb)
|
|
167
|
+
unsub()
|
|
168
|
+
unsub()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test("installDevTools called again is noop (already installed)", () => {
|
|
172
|
+
// Second call should return early (line 205: _installed = true)
|
|
173
|
+
installDevTools()
|
|
174
|
+
expect((window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__).toBeDefined()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("overlay click path — entry found with parentId triggers parent log", () => {
|
|
178
|
+
// We need to actually exercise the onOverlayClick code paths.
|
|
179
|
+
// In happy-dom, elementFromPoint may return null, so let's test the
|
|
180
|
+
// code path by directly triggering click events and checking no errors.
|
|
181
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
182
|
+
enableOverlay: () => void
|
|
183
|
+
disableOverlay: () => void
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const parentEl = document.createElement("div")
|
|
187
|
+
parentEl.style.cssText = "width:200px;height:200px;position:fixed;top:0;left:0;"
|
|
188
|
+
document.body.appendChild(parentEl)
|
|
189
|
+
|
|
190
|
+
const childEl = document.createElement("span")
|
|
191
|
+
childEl.style.cssText = "width:50px;height:50px;"
|
|
192
|
+
parentEl.appendChild(childEl)
|
|
193
|
+
|
|
194
|
+
registerComponent("click-p", "ClickParent", parentEl, null)
|
|
195
|
+
registerComponent("click-c", "ClickChild", childEl, "click-p")
|
|
196
|
+
|
|
197
|
+
devtools.enableOverlay()
|
|
198
|
+
|
|
199
|
+
// Simulate click
|
|
200
|
+
const event = new MouseEvent("click", { clientX: 25, clientY: 25, bubbles: true })
|
|
201
|
+
document.dispatchEvent(event)
|
|
202
|
+
|
|
203
|
+
devtools.disableOverlay()
|
|
204
|
+
unregisterComponent("click-c")
|
|
205
|
+
unregisterComponent("click-p")
|
|
206
|
+
parentEl.remove()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test("overlay click on null target returns early", () => {
|
|
210
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
211
|
+
enableOverlay: () => void
|
|
212
|
+
disableOverlay: () => void
|
|
213
|
+
}
|
|
214
|
+
devtools.enableOverlay()
|
|
215
|
+
// dispatch click; in happy-dom elementFromPoint may return null → line 148 return
|
|
216
|
+
const event = new MouseEvent("click", { clientX: -1, clientY: -1, bubbles: true })
|
|
217
|
+
document.dispatchEvent(event)
|
|
218
|
+
devtools.disableOverlay()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test("overlay mousemove on overlay/tooltip element itself is ignored", () => {
|
|
222
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
223
|
+
enableOverlay: () => void
|
|
224
|
+
disableOverlay: () => void
|
|
225
|
+
}
|
|
226
|
+
devtools.enableOverlay()
|
|
227
|
+
// The overlay div itself should be ignored (line 107)
|
|
228
|
+
const overlayEl = document.getElementById("__pyreon-overlay")
|
|
229
|
+
if (overlayEl) {
|
|
230
|
+
const event = new MouseEvent("mousemove", { clientX: 0, clientY: 0, bubbles: true })
|
|
231
|
+
overlayEl.dispatchEvent(event)
|
|
232
|
+
}
|
|
233
|
+
devtools.disableOverlay()
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// ─── keep-alive.ts — line 48 (no container) and line 55 (no children) ───────
|
|
238
|
+
|
|
239
|
+
describe("KeepAlive — edge cases", () => {
|
|
240
|
+
test("KeepAlive with no active prop defaults to visible", () => {
|
|
241
|
+
const el = container()
|
|
242
|
+
const unmount = mount(h(KeepAlive, {}, h("span", null, "always-visible")), el)
|
|
243
|
+
// Should render and be visible (active defaults to true)
|
|
244
|
+
expect(el.querySelector("span")?.textContent).toBe("always-visible")
|
|
245
|
+
unmount()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test("KeepAlive toggles visibility without remounting", () => {
|
|
249
|
+
const el = container()
|
|
250
|
+
const active = signal(true)
|
|
251
|
+
const unmount = mount(h(KeepAlive, { active: () => active() }, h("span", null, "toggle")), el)
|
|
252
|
+
expect(el.querySelector("span")?.textContent).toBe("toggle")
|
|
253
|
+
|
|
254
|
+
// Hide
|
|
255
|
+
active.set(false)
|
|
256
|
+
const wrapper = el.querySelector("div") as HTMLElement
|
|
257
|
+
expect(wrapper?.style.display).toBe("none")
|
|
258
|
+
|
|
259
|
+
// Show again — same element, not remounted
|
|
260
|
+
active.set(true)
|
|
261
|
+
expect(wrapper?.style.display).toBe("")
|
|
262
|
+
|
|
263
|
+
unmount()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test("KeepAlive with no children mounts empty container", () => {
|
|
267
|
+
const el = container()
|
|
268
|
+
const unmount = mount(h(KeepAlive, { active: () => true }), el)
|
|
269
|
+
// Container div exists but no children
|
|
270
|
+
expect(el.querySelector("div")).not.toBeNull()
|
|
271
|
+
unmount()
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// ─── nodes.ts — lines 175-178 (LIS typed array growth), line 338 (dev dup key warn) ─────
|
|
276
|
+
|
|
277
|
+
describe("nodes.ts — LIS array growth and dev warnings", () => {
|
|
278
|
+
test("mountFor with > 16 items triggers typed array growth (lines 175-178)", () => {
|
|
279
|
+
const el = container()
|
|
280
|
+
// Create > 16 items to trigger array growth in LIS path
|
|
281
|
+
const initial = Array.from({ length: 20 }, (_, i) => ({ id: i, label: `item-${i}` }))
|
|
282
|
+
const items = signal(initial)
|
|
283
|
+
|
|
284
|
+
mount(
|
|
285
|
+
h(
|
|
286
|
+
"div",
|
|
287
|
+
null,
|
|
288
|
+
For({
|
|
289
|
+
each: items,
|
|
290
|
+
by: (r: { id: number }) => r.id,
|
|
291
|
+
children: (r: { id: number; label: string }) => h("span", null, r.label),
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
el,
|
|
295
|
+
)
|
|
296
|
+
expect(el.querySelectorAll("span").length).toBe(20)
|
|
297
|
+
|
|
298
|
+
// Reverse to trigger full LIS reorder with > 16 entries
|
|
299
|
+
const reversed = [...initial].reverse()
|
|
300
|
+
items.set(reversed)
|
|
301
|
+
expect(el.querySelectorAll("span").length).toBe(20)
|
|
302
|
+
|
|
303
|
+
el.remove()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test("mountFor duplicate key warning in dev mode (line 338)", () => {
|
|
307
|
+
const el = container()
|
|
308
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
309
|
+
|
|
310
|
+
const items = signal([{ id: 1 }, { id: 1 }]) // duplicate keys
|
|
311
|
+
|
|
312
|
+
mount(
|
|
313
|
+
h(
|
|
314
|
+
"div",
|
|
315
|
+
null,
|
|
316
|
+
For({
|
|
317
|
+
each: items,
|
|
318
|
+
by: (r: { id: number }) => r.id,
|
|
319
|
+
children: (r: { id: number }) => h("span", null, String(r.id)),
|
|
320
|
+
}),
|
|
321
|
+
),
|
|
322
|
+
el,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Duplicate key"))
|
|
326
|
+
warnSpy.mockRestore()
|
|
327
|
+
el.remove()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test("mountFor duplicate key warning on update (line 385)", () => {
|
|
331
|
+
const el = container()
|
|
332
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
333
|
+
|
|
334
|
+
const items = signal([{ id: 1 }, { id: 2 }])
|
|
335
|
+
|
|
336
|
+
mount(
|
|
337
|
+
h(
|
|
338
|
+
"div",
|
|
339
|
+
null,
|
|
340
|
+
For({
|
|
341
|
+
each: items,
|
|
342
|
+
by: (r: { id: number }) => r.id,
|
|
343
|
+
children: (r: { id: number }) => h("span", null, String(r.id)),
|
|
344
|
+
}),
|
|
345
|
+
),
|
|
346
|
+
el,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
// Update with duplicate keys
|
|
350
|
+
items.set([{ id: 3 }, { id: 3 }])
|
|
351
|
+
|
|
352
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Duplicate key"))
|
|
353
|
+
warnSpy.mockRestore()
|
|
354
|
+
el.remove()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test("mountFor clear path — items reduced to 0", () => {
|
|
358
|
+
const el = container()
|
|
359
|
+
const items = signal([{ id: 1 }, { id: 2 }, { id: 3 }])
|
|
360
|
+
|
|
361
|
+
mount(
|
|
362
|
+
h(
|
|
363
|
+
"div",
|
|
364
|
+
null,
|
|
365
|
+
For({
|
|
366
|
+
each: items,
|
|
367
|
+
by: (r: { id: number }) => r.id,
|
|
368
|
+
children: (r: { id: number }) => h("span", null, String(r.id)),
|
|
369
|
+
}),
|
|
370
|
+
),
|
|
371
|
+
el,
|
|
372
|
+
)
|
|
373
|
+
expect(el.querySelectorAll("span").length).toBe(3)
|
|
374
|
+
|
|
375
|
+
// Clear — fast clear path
|
|
376
|
+
items.set([])
|
|
377
|
+
expect(el.querySelectorAll("span").length).toBe(0)
|
|
378
|
+
|
|
379
|
+
el.remove()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test("mountFor cleanup return — cleanupCount > 0 path in final cleanup", () => {
|
|
383
|
+
const el = container()
|
|
384
|
+
let cleanupCalled = 0
|
|
385
|
+
const items = signal([{ id: 1 }])
|
|
386
|
+
|
|
387
|
+
const Comp = defineComponent(() => {
|
|
388
|
+
onUnmount(() => {
|
|
389
|
+
cleanupCalled++
|
|
390
|
+
})
|
|
391
|
+
return h("span", null, "has-cleanup")
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
const unmount = mount(
|
|
395
|
+
h(
|
|
396
|
+
"div",
|
|
397
|
+
null,
|
|
398
|
+
For({
|
|
399
|
+
each: items,
|
|
400
|
+
by: (r: { id: number }) => r.id,
|
|
401
|
+
children: (r: { id: number }) => h(Comp, { key: r.id }),
|
|
402
|
+
}),
|
|
403
|
+
),
|
|
404
|
+
el,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
// Unmount the whole thing — exercises the final cleanup with cleanupCount > 0
|
|
408
|
+
unmount()
|
|
409
|
+
expect(cleanupCalled).toBe(1)
|
|
410
|
+
el.remove()
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test("mountFor with NativeItem entries", () => {
|
|
414
|
+
const el = container()
|
|
415
|
+
const items = signal([
|
|
416
|
+
{ id: 1, label: "a" },
|
|
417
|
+
{ id: 2, label: "b" },
|
|
418
|
+
])
|
|
419
|
+
|
|
420
|
+
mount(
|
|
421
|
+
h(
|
|
422
|
+
"div",
|
|
423
|
+
null,
|
|
424
|
+
For({
|
|
425
|
+
each: items,
|
|
426
|
+
by: (r: { id: number }) => r.id,
|
|
427
|
+
children: (r: { id: number; label: string }) => {
|
|
428
|
+
const native = _tpl("<b></b>", (root) => {
|
|
429
|
+
root.textContent = r.label
|
|
430
|
+
return null
|
|
431
|
+
})
|
|
432
|
+
return native as unknown as ReturnType<typeof h>
|
|
433
|
+
},
|
|
434
|
+
}),
|
|
435
|
+
),
|
|
436
|
+
el,
|
|
437
|
+
)
|
|
438
|
+
expect(el.querySelectorAll("b").length).toBe(2)
|
|
439
|
+
|
|
440
|
+
// Add a new NativeItem entry — step 3 mount new entries with NativeItem
|
|
441
|
+
items.set([
|
|
442
|
+
{ id: 1, label: "a" },
|
|
443
|
+
{ id: 2, label: "b" },
|
|
444
|
+
{ id: 3, label: "c" },
|
|
445
|
+
])
|
|
446
|
+
expect(el.querySelectorAll("b").length).toBe(3)
|
|
447
|
+
|
|
448
|
+
el.remove()
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test("mountFor step 3 NativeItem with cleanup", () => {
|
|
452
|
+
const el = container()
|
|
453
|
+
let _cleanupCount = 0
|
|
454
|
+
const items = signal([{ id: 1, label: "a" }])
|
|
455
|
+
|
|
456
|
+
mount(
|
|
457
|
+
h(
|
|
458
|
+
"div",
|
|
459
|
+
null,
|
|
460
|
+
For({
|
|
461
|
+
each: items,
|
|
462
|
+
by: (r: { id: number }) => r.id,
|
|
463
|
+
children: (r: { id: number; label: string }) => {
|
|
464
|
+
const native = _tpl("<b></b>", (root) => {
|
|
465
|
+
root.textContent = r.label
|
|
466
|
+
return () => {
|
|
467
|
+
_cleanupCount++
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
return native as unknown as ReturnType<typeof h>
|
|
471
|
+
},
|
|
472
|
+
}),
|
|
473
|
+
),
|
|
474
|
+
el,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
// Add new NativeItem with cleanup — exercises step 3 NativeItem cleanup path
|
|
478
|
+
items.set([
|
|
479
|
+
{ id: 1, label: "a" },
|
|
480
|
+
{ id: 2, label: "new" },
|
|
481
|
+
])
|
|
482
|
+
expect(el.querySelectorAll("b").length).toBe(2)
|
|
483
|
+
|
|
484
|
+
el.remove()
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
test("mountReactive — __DEV__ warning when accessor returns function", () => {
|
|
488
|
+
const el = container()
|
|
489
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
490
|
+
|
|
491
|
+
// Reactive accessor that returns a function (not a value) — dev warning
|
|
492
|
+
const badAccessor = () => (() => "oops") as unknown as VNodeChild
|
|
493
|
+
mount(h("div", null, badAccessor as VNodeChild), el)
|
|
494
|
+
|
|
495
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
496
|
+
expect.stringContaining("returned a function instead of a value"),
|
|
497
|
+
)
|
|
498
|
+
warnSpy.mockRestore()
|
|
499
|
+
el.remove()
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// ─── props.ts — lines 182 (Sanitizer API), 190 (no DOMParser), 273-277 (n-show) ─────
|
|
504
|
+
|
|
505
|
+
describe("props.ts — Sanitizer API branch and n-show", () => {
|
|
506
|
+
test("sanitizeHtml fallback — strips unsafe tags via DOMParser", () => {
|
|
507
|
+
setSanitizer(null)
|
|
508
|
+
// _nativeSanitizer is undefined (happy-dom has no Sanitizer API)
|
|
509
|
+
// Falls through to DOMParser-based fallback sanitizer
|
|
510
|
+
const result = sanitizeHtml("<b>bold</b><script>bad</script>")
|
|
511
|
+
// <script> should be stripped, <b> should remain
|
|
512
|
+
expect(result).toContain("<b>")
|
|
513
|
+
expect(result).not.toContain("<script>")
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test("sanitizeHtml fallback strips event handler attributes", () => {
|
|
517
|
+
setSanitizer(null)
|
|
518
|
+
const result = sanitizeHtml('<div onclick="alert(1)">test</div>')
|
|
519
|
+
expect(result).not.toContain("onclick")
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test("sanitizeHtml fallback blocks javascript: URLs", () => {
|
|
523
|
+
setSanitizer(null)
|
|
524
|
+
const result = sanitizeHtml('<a href="javascript:alert(1)">click</a>')
|
|
525
|
+
expect(result).not.toContain("javascript:")
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
test("n-show prop toggles display via renderEffect (lines 273-277)", () => {
|
|
529
|
+
const el = container()
|
|
530
|
+
const visible = signal(true)
|
|
531
|
+
|
|
532
|
+
mount(h("div", { "n-show": () => visible() }), el)
|
|
533
|
+
const div = el.querySelector("div") as HTMLElement
|
|
534
|
+
expect(div.style.display).toBe("")
|
|
535
|
+
|
|
536
|
+
visible.set(false)
|
|
537
|
+
expect(div.style.display).toBe("none")
|
|
538
|
+
|
|
539
|
+
visible.set(true)
|
|
540
|
+
expect(div.style.display).toBe("")
|
|
541
|
+
|
|
542
|
+
el.remove()
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test("blocked unsafe URL in href attribute", () => {
|
|
546
|
+
const el = document.createElement("a")
|
|
547
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
548
|
+
|
|
549
|
+
applyProp(el, "href", "javascript:alert(1)")
|
|
550
|
+
expect(el.getAttribute("href")).toBeNull()
|
|
551
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Blocked unsafe"))
|
|
552
|
+
|
|
553
|
+
warnSpy.mockRestore()
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
test("blocked unsafe data: URL in src attribute", () => {
|
|
557
|
+
const el = document.createElement("img")
|
|
558
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
559
|
+
|
|
560
|
+
applyProp(el, "src", "data:text/html,<script>bad</script>")
|
|
561
|
+
expect(el.getAttribute("src")).toBeNull()
|
|
562
|
+
|
|
563
|
+
warnSpy.mockRestore()
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test("style as object applies via Object.assign", () => {
|
|
567
|
+
const el = container()
|
|
568
|
+
mount(h("div", { style: { color: "red", fontSize: "14px" } }), el)
|
|
569
|
+
const div = el.querySelector("div") as HTMLElement
|
|
570
|
+
expect(div.style.color).toBe("red")
|
|
571
|
+
expect(div.style.fontSize).toBe("14px")
|
|
572
|
+
el.remove()
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
test("boolean attribute false removes attribute", () => {
|
|
576
|
+
const el = document.createElement("input")
|
|
577
|
+
applyProp(el, "disabled", true)
|
|
578
|
+
expect(el.hasAttribute("disabled")).toBe(true)
|
|
579
|
+
applyProp(el, "disabled", false)
|
|
580
|
+
expect(el.hasAttribute("disabled")).toBe(false)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
test("null value removes attribute", () => {
|
|
584
|
+
const el = document.createElement("div")
|
|
585
|
+
el.setAttribute("data-x", "value")
|
|
586
|
+
applyProp(el, "data-x", null)
|
|
587
|
+
expect(el.hasAttribute("data-x")).toBe(false)
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
test("DOM property is set directly when key exists on element", () => {
|
|
591
|
+
const el = document.createElement("input")
|
|
592
|
+
applyProp(el, "value", "hello")
|
|
593
|
+
expect(el.value).toBe("hello")
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
test("className alias sets class attribute", () => {
|
|
597
|
+
const el = document.createElement("div")
|
|
598
|
+
applyProp(el, "className", "my-class")
|
|
599
|
+
expect(el.getAttribute("class")).toBe("my-class")
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test("class with null value sets empty string", () => {
|
|
603
|
+
const el = document.createElement("div")
|
|
604
|
+
applyProp(el, "class", null)
|
|
605
|
+
expect(el.getAttribute("class")).toBe("")
|
|
606
|
+
})
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
// ─── hydrate.ts — lines 162-183 (For with/without markers, afterEnd paths) ──
|
|
610
|
+
|
|
611
|
+
describe("hydrate.ts — For and reactive accessor branches", () => {
|
|
612
|
+
test("For hydration without markers and no domNode (null)", () => {
|
|
613
|
+
const el = container()
|
|
614
|
+
// Empty container — domNode will be null
|
|
615
|
+
el.innerHTML = ""
|
|
616
|
+
const items = signal([{ id: 1, label: "a" }])
|
|
617
|
+
const cleanup = hydrateRoot(
|
|
618
|
+
el,
|
|
619
|
+
For({
|
|
620
|
+
each: items,
|
|
621
|
+
by: (r: { id: number }) => r.id,
|
|
622
|
+
children: (r: { id: number; label: string }) => h("li", null, r.label),
|
|
623
|
+
}),
|
|
624
|
+
)
|
|
625
|
+
cleanup()
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
test("hydrate reactive accessor returning null/false inserts marker before domNode", () => {
|
|
629
|
+
const el = container()
|
|
630
|
+
el.innerHTML = "<p>existing</p>"
|
|
631
|
+
const content = signal<VNodeChild>(null)
|
|
632
|
+
const cleanup = hydrateRoot(el, (() => content()) as unknown as VNodeChild)
|
|
633
|
+
// Accessor returns null — comment marker inserted before existing <p>
|
|
634
|
+
cleanup()
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
test("hydrate reactive accessor returning null with no domNode appends marker", () => {
|
|
638
|
+
const el = container()
|
|
639
|
+
el.innerHTML = ""
|
|
640
|
+
const content = signal<VNodeChild>(null)
|
|
641
|
+
const cleanup = hydrateRoot(el, (() => content()) as unknown as VNodeChild)
|
|
642
|
+
cleanup()
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
test("hydrate reactive text with DOM mismatch (not a text node)", () => {
|
|
646
|
+
const el = container()
|
|
647
|
+
el.innerHTML = "<div>not text</div>" // Element instead of text node
|
|
648
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
649
|
+
const text = signal("hello")
|
|
650
|
+
const cleanup = hydrateRoot(el, (() => text()) as unknown as VNodeChild)
|
|
651
|
+
// Should hit the mismatch path (line 119)
|
|
652
|
+
cleanup()
|
|
653
|
+
warnSpy.mockRestore()
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
test("hydrate reactive VNode with no domNode appends marker", () => {
|
|
657
|
+
const el = container()
|
|
658
|
+
el.innerHTML = ""
|
|
659
|
+
const content = signal<VNodeChild>(h("span", null, "dynamic"))
|
|
660
|
+
const cleanup = hydrateRoot(el, (() => content()) as unknown as VNodeChild)
|
|
661
|
+
cleanup()
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
test("hydrate static text with non-text domNode (mismatch)", () => {
|
|
665
|
+
const el = container()
|
|
666
|
+
el.innerHTML = "<div>wrong</div>" // Element where text expected
|
|
667
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
668
|
+
const cleanup = hydrateRoot(el, "plain text")
|
|
669
|
+
cleanup()
|
|
670
|
+
warnSpy.mockRestore()
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
test("hydrate element with ref sets ref.current", () => {
|
|
674
|
+
const el = container()
|
|
675
|
+
el.innerHTML = "<div>with ref</div>"
|
|
676
|
+
const ref = createRef<Element>()
|
|
677
|
+
const cleanup = hydrateRoot(el, h("div", { ref }, "with ref"))
|
|
678
|
+
expect(ref.current).not.toBeNull()
|
|
679
|
+
expect(ref.current?.textContent).toBe("with ref")
|
|
680
|
+
cleanup()
|
|
681
|
+
expect(ref.current).toBeNull()
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
test("hydrate component that throws during setup", () => {
|
|
685
|
+
const el = container()
|
|
686
|
+
el.innerHTML = "<span>error</span>"
|
|
687
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
688
|
+
const BadComp = defineComponent(() => {
|
|
689
|
+
throw new Error("hydrate setup error")
|
|
690
|
+
})
|
|
691
|
+
const cleanup = hydrateRoot(el, h(BadComp, null))
|
|
692
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
693
|
+
expect.stringContaining("Error hydrating component"),
|
|
694
|
+
expect.any(Error),
|
|
695
|
+
)
|
|
696
|
+
cleanup()
|
|
697
|
+
errorSpy.mockRestore()
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
test("hydrate array of children", () => {
|
|
701
|
+
const el = container()
|
|
702
|
+
el.innerHTML = "<span>a</span><span>b</span>"
|
|
703
|
+
const cleanup = hydrateRoot(el, h(Fragment, null, h("span", null, "a"), h("span", null, "b")))
|
|
704
|
+
cleanup()
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
test("hydrate skips comments and whitespace-only text nodes", () => {
|
|
708
|
+
const el = container()
|
|
709
|
+
// HTML with comments and whitespace between elements
|
|
710
|
+
el.innerHTML = "<!-- comment --> <div>real</div>"
|
|
711
|
+
const cleanup = hydrateRoot(el, h("div", null, "real"))
|
|
712
|
+
cleanup()
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
test("hydrate children with reactive text (reuses text node)", () => {
|
|
716
|
+
const el = container()
|
|
717
|
+
el.innerHTML = "<div>hello</div>"
|
|
718
|
+
const text = signal("hello")
|
|
719
|
+
const cleanup = hydrateRoot(el, h("div", null, (() => text()) as unknown as VNodeChild))
|
|
720
|
+
// Text should be reactive
|
|
721
|
+
text.set("world")
|
|
722
|
+
cleanup()
|
|
723
|
+
})
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
// ─── transition.ts — lines 111-113 (pendingLeaveCancel in applyLeave rAF) ──
|
|
727
|
+
|
|
728
|
+
describe("Transition — leave cancel and re-enter", () => {
|
|
729
|
+
test("re-enter during leave cancels pending leave animation (lines 110-113)", async () => {
|
|
730
|
+
const el = container()
|
|
731
|
+
const visible = signal(true)
|
|
732
|
+
let beforeEnterCount = 0
|
|
733
|
+
let beforeLeaveCount = 0
|
|
734
|
+
|
|
735
|
+
mount(
|
|
736
|
+
h(Transition, {
|
|
737
|
+
show: visible,
|
|
738
|
+
name: "fade",
|
|
739
|
+
onBeforeEnter: () => {
|
|
740
|
+
beforeEnterCount++
|
|
741
|
+
},
|
|
742
|
+
onBeforeLeave: () => {
|
|
743
|
+
beforeLeaveCount++
|
|
744
|
+
},
|
|
745
|
+
children: h("div", { id: "reenter-test" }, "content"),
|
|
746
|
+
}),
|
|
747
|
+
el,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
// Start leave
|
|
751
|
+
visible.set(false)
|
|
752
|
+
|
|
753
|
+
// Wait for rAF to set up pendingLeaveCancel
|
|
754
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
755
|
+
|
|
756
|
+
// Re-enter before leave completes — should cancel leave
|
|
757
|
+
visible.set(true)
|
|
758
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
759
|
+
|
|
760
|
+
expect(beforeLeaveCount).toBe(1)
|
|
761
|
+
// beforeEnter fires on re-enter
|
|
762
|
+
expect(beforeEnterCount).toBeGreaterThanOrEqual(1)
|
|
763
|
+
|
|
764
|
+
el.remove()
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
test("Transition appear prop triggers enter animation on initial mount", async () => {
|
|
768
|
+
const el = container()
|
|
769
|
+
let afterEnterCalled = false
|
|
770
|
+
|
|
771
|
+
mount(
|
|
772
|
+
h(Transition, {
|
|
773
|
+
show: () => true,
|
|
774
|
+
name: "fade",
|
|
775
|
+
appear: true,
|
|
776
|
+
onAfterEnter: () => {
|
|
777
|
+
afterEnterCalled = true
|
|
778
|
+
},
|
|
779
|
+
children: h("div", { id: "appear-test" }, "appear"),
|
|
780
|
+
}),
|
|
781
|
+
el,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
785
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
786
|
+
|
|
787
|
+
const target = el.querySelector("#appear-test")
|
|
788
|
+
if (target) {
|
|
789
|
+
target.dispatchEvent(new Event("transitionend"))
|
|
790
|
+
}
|
|
791
|
+
expect(afterEnterCalled).toBe(true)
|
|
792
|
+
|
|
793
|
+
el.remove()
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
test("Transition show starts false then becomes true", async () => {
|
|
797
|
+
const el = container()
|
|
798
|
+
const visible = signal(false)
|
|
799
|
+
|
|
800
|
+
mount(
|
|
801
|
+
h(Transition, {
|
|
802
|
+
show: visible,
|
|
803
|
+
name: "fade",
|
|
804
|
+
children: h("div", { id: "false-start" }, "content"),
|
|
805
|
+
}),
|
|
806
|
+
el,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
// Initially hidden
|
|
810
|
+
expect(el.querySelector("#false-start")).toBeNull()
|
|
811
|
+
|
|
812
|
+
// Show
|
|
813
|
+
visible.set(true)
|
|
814
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
815
|
+
|
|
816
|
+
expect(el.querySelector("#false-start")).not.toBeNull()
|
|
817
|
+
|
|
818
|
+
el.remove()
|
|
819
|
+
})
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
// ─── transition-group.ts — lines 209-218 (FLIP inner rAF) ───────────────────
|
|
823
|
+
|
|
824
|
+
describe("TransitionGroup — leave and enter edge cases", () => {
|
|
825
|
+
test("TransitionGroup with appear triggers enter animation", async () => {
|
|
826
|
+
const el = container()
|
|
827
|
+
let enterCount = 0
|
|
828
|
+
const items = signal([{ id: 1, label: "a" }])
|
|
829
|
+
|
|
830
|
+
mount(
|
|
831
|
+
h(TransitionGroup, {
|
|
832
|
+
tag: "div",
|
|
833
|
+
name: "list",
|
|
834
|
+
appear: true,
|
|
835
|
+
items: () => items(),
|
|
836
|
+
keyFn: (item: { id: number }) => item.id,
|
|
837
|
+
render: (item: { id: number; label: string }) =>
|
|
838
|
+
h("span", { class: "tg-item" }, item.label),
|
|
839
|
+
onBeforeEnter: () => {
|
|
840
|
+
enterCount++
|
|
841
|
+
},
|
|
842
|
+
}),
|
|
843
|
+
el,
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
847
|
+
expect(enterCount).toBe(1)
|
|
848
|
+
|
|
849
|
+
el.remove()
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
test("TransitionGroup item removal with no ref.current cleans up without leave animation", async () => {
|
|
853
|
+
const el = container()
|
|
854
|
+
const items = signal([
|
|
855
|
+
{ id: 1, label: "a" },
|
|
856
|
+
{ id: 2, label: "b" },
|
|
857
|
+
])
|
|
858
|
+
|
|
859
|
+
// Use component type child so ref won't be injected (line 171)
|
|
860
|
+
const ItemComp = defineComponent((props: { label: string }) => h("span", null, props.label))
|
|
861
|
+
|
|
862
|
+
mount(
|
|
863
|
+
h(TransitionGroup, {
|
|
864
|
+
tag: "div",
|
|
865
|
+
name: "list",
|
|
866
|
+
items: () => items(),
|
|
867
|
+
keyFn: (item: { id: number }) => item.id,
|
|
868
|
+
render: (item: { id: number; label: string }) =>
|
|
869
|
+
h(ItemComp as unknown as string, { label: item.label }),
|
|
870
|
+
}),
|
|
871
|
+
el,
|
|
872
|
+
)
|
|
873
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
874
|
+
|
|
875
|
+
// Remove an item — ref.current will be null for component children
|
|
876
|
+
items.set([{ id: 1, label: "a" }])
|
|
877
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
878
|
+
|
|
879
|
+
el.remove()
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
test("TransitionGroup leave animation with onBeforeLeave and onAfterLeave", async () => {
|
|
883
|
+
const el = container()
|
|
884
|
+
let beforeLeaveCalled = false
|
|
885
|
+
let afterLeaveCalled = false
|
|
886
|
+
const items = signal([
|
|
887
|
+
{ id: 1, label: "a" },
|
|
888
|
+
{ id: 2, label: "b" },
|
|
889
|
+
])
|
|
890
|
+
|
|
891
|
+
mount(
|
|
892
|
+
h(TransitionGroup, {
|
|
893
|
+
tag: "div",
|
|
894
|
+
name: "list",
|
|
895
|
+
items: () => items(),
|
|
896
|
+
keyFn: (item: { id: number }) => item.id,
|
|
897
|
+
render: (item: { id: number; label: string }) =>
|
|
898
|
+
h("span", { class: "leave-item" }, item.label),
|
|
899
|
+
onBeforeLeave: () => {
|
|
900
|
+
beforeLeaveCalled = true
|
|
901
|
+
},
|
|
902
|
+
onAfterLeave: () => {
|
|
903
|
+
afterLeaveCalled = true
|
|
904
|
+
},
|
|
905
|
+
}),
|
|
906
|
+
el,
|
|
907
|
+
)
|
|
908
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
909
|
+
|
|
910
|
+
// Remove an item
|
|
911
|
+
items.set([{ id: 1, label: "a" }])
|
|
912
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
913
|
+
|
|
914
|
+
expect(beforeLeaveCalled).toBe(true)
|
|
915
|
+
|
|
916
|
+
// Wait for rAF in applyLeave
|
|
917
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
918
|
+
|
|
919
|
+
// Fire transitionend on the leaving element
|
|
920
|
+
const spans = el.querySelectorAll("span.leave-item")
|
|
921
|
+
for (const span of spans) {
|
|
922
|
+
span.dispatchEvent(new Event("transitionend"))
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// afterLeaveCalled should be true after transitionend
|
|
926
|
+
expect(afterLeaveCalled).toBe(true)
|
|
927
|
+
|
|
928
|
+
el.remove()
|
|
929
|
+
})
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
// ─── mount.ts — lines 204-206 (mountElement nested with ref, no propCleanup) ─
|
|
933
|
+
|
|
934
|
+
describe("mount.ts — nested element branches", () => {
|
|
935
|
+
test("nested element with ref but no propCleanup (line 202-207)", () => {
|
|
936
|
+
const el = container()
|
|
937
|
+
const ref = createRef<HTMLElement>()
|
|
938
|
+
|
|
939
|
+
// Inner element has ref but no reactive props
|
|
940
|
+
const unmount = mount(h("div", null, h("span", { ref }, "with-ref-only")), el)
|
|
941
|
+
|
|
942
|
+
expect(ref.current).not.toBeNull()
|
|
943
|
+
expect(ref.current?.textContent).toBe("with-ref-only")
|
|
944
|
+
|
|
945
|
+
unmount()
|
|
946
|
+
// Ref should be cleaned up
|
|
947
|
+
expect(ref.current).toBeNull()
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
test("nested element with childCleanup only (line 196)", () => {
|
|
951
|
+
const el = container()
|
|
952
|
+
const text = signal("dynamic")
|
|
953
|
+
|
|
954
|
+
// Inner element with reactive children but no ref, no propCleanup
|
|
955
|
+
const unmount = mount(
|
|
956
|
+
h(
|
|
957
|
+
"div",
|
|
958
|
+
null,
|
|
959
|
+
h("span", null, () => text()),
|
|
960
|
+
),
|
|
961
|
+
el,
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
expect(el.querySelector("span")?.textContent).toBe("dynamic")
|
|
965
|
+
text.set("updated")
|
|
966
|
+
expect(el.querySelector("span")?.textContent).toBe("updated")
|
|
967
|
+
|
|
968
|
+
unmount()
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
test("mountChild with boolean sample (true) uses text fast path", () => {
|
|
972
|
+
const el = container()
|
|
973
|
+
const flag = signal(true)
|
|
974
|
+
mount(
|
|
975
|
+
h("div", null, () => flag()),
|
|
976
|
+
el,
|
|
977
|
+
)
|
|
978
|
+
expect(el.querySelector("div")?.textContent).toBe("true")
|
|
979
|
+
flag.set(false)
|
|
980
|
+
expect(el.querySelector("div")?.textContent).toBe("")
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
test("mountElement at depth 0 with ref + propCleanup + childCleanup", () => {
|
|
984
|
+
const el = container()
|
|
985
|
+
const ref = createRef<HTMLElement>()
|
|
986
|
+
const cls = signal("cls")
|
|
987
|
+
const text = signal("txt")
|
|
988
|
+
|
|
989
|
+
const unmount = mount(
|
|
990
|
+
h("span", { ref, class: () => cls() }, () => text()),
|
|
991
|
+
el,
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
expect(ref.current).not.toBeNull()
|
|
995
|
+
expect(ref.current?.className).toBe("cls")
|
|
996
|
+
expect(ref.current?.textContent).toBe("txt")
|
|
997
|
+
|
|
998
|
+
unmount()
|
|
999
|
+
expect(ref.current).toBeNull()
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
test("mountChildren with undefined children", () => {
|
|
1003
|
+
const el = container()
|
|
1004
|
+
// Single child that is undefined — should be handled
|
|
1005
|
+
mount(h("div", null, undefined), el)
|
|
1006
|
+
expect(el.querySelector("div")?.textContent).toBe("")
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
test("mountChildren 2-child path with undefined child in slot 0", () => {
|
|
1010
|
+
const el = container()
|
|
1011
|
+
// 2 children where first is undefined — falls through to map path
|
|
1012
|
+
mount(h("div", null, undefined, "text"), el)
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
test("component with onMount returning cleanup", () => {
|
|
1016
|
+
const el = container()
|
|
1017
|
+
let mountCleanupCalled = false
|
|
1018
|
+
|
|
1019
|
+
const Comp = defineComponent(() => {
|
|
1020
|
+
onMount(() => {
|
|
1021
|
+
return () => {
|
|
1022
|
+
mountCleanupCalled = true
|
|
1023
|
+
}
|
|
1024
|
+
})
|
|
1025
|
+
return h("span", null, "with-mount-cleanup")
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
const unmount = mount(h(Comp, null), el)
|
|
1029
|
+
unmount()
|
|
1030
|
+
expect(mountCleanupCalled).toBe(true)
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
test("component onMount hook that throws", () => {
|
|
1034
|
+
const el = container()
|
|
1035
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
1036
|
+
|
|
1037
|
+
const Comp = defineComponent(() => {
|
|
1038
|
+
onMount(() => {
|
|
1039
|
+
throw new Error("mount hook error")
|
|
1040
|
+
})
|
|
1041
|
+
return h("span", null, "error-mount")
|
|
1042
|
+
})
|
|
1043
|
+
|
|
1044
|
+
mount(h(Comp, null), el)
|
|
1045
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
1046
|
+
expect.stringContaining("Error in onMount hook"),
|
|
1047
|
+
expect.any(Error),
|
|
1048
|
+
)
|
|
1049
|
+
errorSpy.mockRestore()
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
test("component onUnmount hook that throws", () => {
|
|
1053
|
+
const el = container()
|
|
1054
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
1055
|
+
|
|
1056
|
+
const Comp = defineComponent(() => {
|
|
1057
|
+
onUnmount(() => {
|
|
1058
|
+
throw new Error("unmount hook error")
|
|
1059
|
+
})
|
|
1060
|
+
return h("span", null, "error-unmount")
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
const unmount = mount(h(Comp, null), el)
|
|
1064
|
+
unmount()
|
|
1065
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
1066
|
+
expect.stringContaining("Error in onUnmount hook"),
|
|
1067
|
+
expect.any(Error),
|
|
1068
|
+
)
|
|
1069
|
+
errorSpy.mockRestore()
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
test("mount with null container in dev mode throws", () => {
|
|
1073
|
+
expect(() => {
|
|
1074
|
+
mount(h("div", null), null as unknown as Element)
|
|
1075
|
+
}).toThrow("mount() called with a null/undefined container")
|
|
1076
|
+
})
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
// ─── nodes.ts — mountKeyedList branches ─────────────────────────────────────
|
|
1080
|
+
|
|
1081
|
+
describe("nodes.ts — mountKeyedList branches", () => {
|
|
1082
|
+
test("mountKeyedList fast clear path", () => {
|
|
1083
|
+
const el = container()
|
|
1084
|
+
const items = signal([h("span", { key: 1 }, "a"), h("span", { key: 2 }, "b")] as VNodeChild)
|
|
1085
|
+
|
|
1086
|
+
mount(h("div", null, (() => items()) as VNodeChild), el)
|
|
1087
|
+
expect(el.querySelectorAll("span").length).toBe(2)
|
|
1088
|
+
|
|
1089
|
+
// Clear all
|
|
1090
|
+
items.set([] as unknown as VNodeChild)
|
|
1091
|
+
expect(el.querySelectorAll("span").length).toBe(0)
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
test("mountKeyedList stale entry removal", () => {
|
|
1095
|
+
const el = container()
|
|
1096
|
+
const items = signal([
|
|
1097
|
+
h("span", { key: 1 }, "a"),
|
|
1098
|
+
h("span", { key: 2 }, "b"),
|
|
1099
|
+
h("span", { key: 3 }, "c"),
|
|
1100
|
+
] as VNodeChild)
|
|
1101
|
+
|
|
1102
|
+
mount(h("div", null, (() => items()) as VNodeChild), el)
|
|
1103
|
+
expect(el.querySelectorAll("span").length).toBe(3)
|
|
1104
|
+
|
|
1105
|
+
// Remove middle item
|
|
1106
|
+
items.set([h("span", { key: 1 }, "a"), h("span", { key: 3 }, "c")] as unknown as VNodeChild)
|
|
1107
|
+
expect(el.querySelectorAll("span").length).toBe(2)
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
test("mountKeyedList reorder with LIS", () => {
|
|
1111
|
+
const el = container()
|
|
1112
|
+
const items = signal([
|
|
1113
|
+
h("span", { key: 1 }, "a"),
|
|
1114
|
+
h("span", { key: 2 }, "b"),
|
|
1115
|
+
h("span", { key: 3 }, "c"),
|
|
1116
|
+
] as VNodeChild)
|
|
1117
|
+
|
|
1118
|
+
mount(h("div", null, (() => items()) as VNodeChild), el)
|
|
1119
|
+
|
|
1120
|
+
// Reverse order — triggers LIS reorder
|
|
1121
|
+
items.set([
|
|
1122
|
+
h("span", { key: 3 }, "c"),
|
|
1123
|
+
h("span", { key: 2 }, "b"),
|
|
1124
|
+
h("span", { key: 1 }, "a"),
|
|
1125
|
+
] as unknown as VNodeChild)
|
|
1126
|
+
|
|
1127
|
+
const spans = el.querySelectorAll("span")
|
|
1128
|
+
expect(spans[0]?.textContent).toBe("c")
|
|
1129
|
+
expect(spans[1]?.textContent).toBe("b")
|
|
1130
|
+
expect(spans[2]?.textContent).toBe("a")
|
|
1131
|
+
})
|
|
1132
|
+
})
|
|
1133
|
+
|
|
1134
|
+
// ─── nodes.ts — mountFor small-k reorder and replace-all paths ──────────────
|
|
1135
|
+
|
|
1136
|
+
describe("nodes.ts — mountFor small-k and clearBetween paths", () => {
|
|
1137
|
+
test("mountFor small-k fast path — few items swapped", () => {
|
|
1138
|
+
const el = container()
|
|
1139
|
+
const items = signal([
|
|
1140
|
+
{ id: 1, label: "a" },
|
|
1141
|
+
{ id: 2, label: "b" },
|
|
1142
|
+
{ id: 3, label: "c" },
|
|
1143
|
+
])
|
|
1144
|
+
|
|
1145
|
+
mount(
|
|
1146
|
+
h(
|
|
1147
|
+
"div",
|
|
1148
|
+
null,
|
|
1149
|
+
For({
|
|
1150
|
+
each: items,
|
|
1151
|
+
by: (r: { id: number }) => r.id,
|
|
1152
|
+
children: (r: { id: number; label: string }) => h("span", null, r.label),
|
|
1153
|
+
}),
|
|
1154
|
+
),
|
|
1155
|
+
el,
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
// Swap first two — small-k path (diffs.length <= SMALL_K)
|
|
1159
|
+
items.set([
|
|
1160
|
+
{ id: 2, label: "b" },
|
|
1161
|
+
{ id: 1, label: "a" },
|
|
1162
|
+
{ id: 3, label: "c" },
|
|
1163
|
+
])
|
|
1164
|
+
|
|
1165
|
+
const spans = el.querySelectorAll("span")
|
|
1166
|
+
expect(spans[0]?.textContent).toBe("b")
|
|
1167
|
+
expect(spans[1]?.textContent).toBe("a")
|
|
1168
|
+
expect(spans[2]?.textContent).toBe("c")
|
|
1169
|
+
|
|
1170
|
+
el.remove()
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
test("mountFor remove stale entries (step 2)", () => {
|
|
1174
|
+
const el = container()
|
|
1175
|
+
const items = signal([
|
|
1176
|
+
{ id: 1, label: "a" },
|
|
1177
|
+
{ id: 2, label: "b" },
|
|
1178
|
+
{ id: 3, label: "c" },
|
|
1179
|
+
])
|
|
1180
|
+
|
|
1181
|
+
mount(
|
|
1182
|
+
h(
|
|
1183
|
+
"div",
|
|
1184
|
+
null,
|
|
1185
|
+
For({
|
|
1186
|
+
each: items,
|
|
1187
|
+
by: (r: { id: number }) => r.id,
|
|
1188
|
+
children: (r: { id: number; label: string }) => h("span", null, r.label),
|
|
1189
|
+
}),
|
|
1190
|
+
),
|
|
1191
|
+
el,
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
// Remove item 2 and add item 4 — keeps some, removes some, adds some
|
|
1195
|
+
items.set([
|
|
1196
|
+
{ id: 1, label: "a" },
|
|
1197
|
+
{ id: 4, label: "d" },
|
|
1198
|
+
{ id: 3, label: "c" },
|
|
1199
|
+
])
|
|
1200
|
+
|
|
1201
|
+
expect(el.querySelectorAll("span").length).toBe(3)
|
|
1202
|
+
|
|
1203
|
+
el.remove()
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
test("mountFor replace-all with clearBetween fallback (not sole children)", () => {
|
|
1207
|
+
const el = container()
|
|
1208
|
+
// Add sibling content so markers are not the sole children
|
|
1209
|
+
const wrapper = document.createElement("div")
|
|
1210
|
+
el.appendChild(wrapper)
|
|
1211
|
+
const before = document.createElement("p")
|
|
1212
|
+
before.textContent = "before"
|
|
1213
|
+
wrapper.appendChild(before)
|
|
1214
|
+
|
|
1215
|
+
const items = signal([{ id: 1, label: "old" }])
|
|
1216
|
+
|
|
1217
|
+
mountChild(
|
|
1218
|
+
For({
|
|
1219
|
+
each: items,
|
|
1220
|
+
by: (r: { id: number }) => r.id,
|
|
1221
|
+
children: (r: { id: number; label: string }) => h("span", null, r.label),
|
|
1222
|
+
}),
|
|
1223
|
+
wrapper,
|
|
1224
|
+
null,
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
// Replace all — wrapper has <p> + markers, so canSwap is false → clearBetween
|
|
1228
|
+
items.set([{ id: 10, label: "new" }])
|
|
1229
|
+
expect(wrapper.querySelector("span")?.textContent).toBe("new")
|
|
1230
|
+
|
|
1231
|
+
el.remove()
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
test("mountFor LIS fallback for large reorder (> SMALL_K diffs)", () => {
|
|
1235
|
+
const el = container()
|
|
1236
|
+
// Create items with enough to exceed SMALL_K (8) diffs
|
|
1237
|
+
const initial = Array.from({ length: 12 }, (_, i) => ({ id: i, label: `item-${i}` }))
|
|
1238
|
+
const items = signal(initial)
|
|
1239
|
+
|
|
1240
|
+
mount(
|
|
1241
|
+
h(
|
|
1242
|
+
"div",
|
|
1243
|
+
null,
|
|
1244
|
+
For({
|
|
1245
|
+
each: items,
|
|
1246
|
+
by: (r: { id: number }) => r.id,
|
|
1247
|
+
children: (r: { id: number; label: string }) => h("span", null, r.label),
|
|
1248
|
+
}),
|
|
1249
|
+
),
|
|
1250
|
+
el,
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
// Reverse all — > SMALL_K diffs triggers LIS fallback
|
|
1254
|
+
items.set([...initial].reverse())
|
|
1255
|
+
|
|
1256
|
+
const spans = el.querySelectorAll("span")
|
|
1257
|
+
expect(spans[0]?.textContent).toBe("item-11")
|
|
1258
|
+
expect(spans[11]?.textContent).toBe("item-0")
|
|
1259
|
+
|
|
1260
|
+
el.remove()
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
test("mountFor clear with non-sole children uses clearBetween", () => {
|
|
1264
|
+
const el = container()
|
|
1265
|
+
const wrapper = document.createElement("div")
|
|
1266
|
+
el.appendChild(wrapper)
|
|
1267
|
+
// Add a sibling so markers are not sole children
|
|
1268
|
+
const sibling = document.createElement("p")
|
|
1269
|
+
sibling.textContent = "sibling"
|
|
1270
|
+
wrapper.appendChild(sibling)
|
|
1271
|
+
|
|
1272
|
+
const items = signal([{ id: 1, label: "a" }])
|
|
1273
|
+
|
|
1274
|
+
mountChild(
|
|
1275
|
+
For({
|
|
1276
|
+
each: items,
|
|
1277
|
+
by: (r: { id: number }) => r.id,
|
|
1278
|
+
children: (r: { id: number; label: string }) => h("span", null, r.label),
|
|
1279
|
+
}),
|
|
1280
|
+
wrapper,
|
|
1281
|
+
null,
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
// Clear — wrapper has <p> + markers so canSwap is false
|
|
1285
|
+
items.set([])
|
|
1286
|
+
expect(wrapper.querySelectorAll("span").length).toBe(0)
|
|
1287
|
+
|
|
1288
|
+
el.remove()
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
test("mountFor size change triggers LIS not small-k", () => {
|
|
1292
|
+
const el = container()
|
|
1293
|
+
const items = signal([
|
|
1294
|
+
{ id: 1, label: "a" },
|
|
1295
|
+
{ id: 2, label: "b" },
|
|
1296
|
+
])
|
|
1297
|
+
|
|
1298
|
+
mount(
|
|
1299
|
+
h(
|
|
1300
|
+
"div",
|
|
1301
|
+
null,
|
|
1302
|
+
For({
|
|
1303
|
+
each: items,
|
|
1304
|
+
by: (r: { id: number }) => r.id,
|
|
1305
|
+
children: (r: { id: number; label: string }) => h("span", null, r.label),
|
|
1306
|
+
}),
|
|
1307
|
+
),
|
|
1308
|
+
el,
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
// Different size — skips small-k, goes to LIS
|
|
1312
|
+
items.set([
|
|
1313
|
+
{ id: 2, label: "b" },
|
|
1314
|
+
{ id: 1, label: "a" },
|
|
1315
|
+
{ id: 3, label: "c" },
|
|
1316
|
+
])
|
|
1317
|
+
|
|
1318
|
+
expect(el.querySelectorAll("span").length).toBe(3)
|
|
1319
|
+
el.remove()
|
|
1320
|
+
})
|
|
1321
|
+
})
|
|
1322
|
+
|
|
1323
|
+
// ─── devtools.ts — overlay with mocked elementFromPoint ──────────────────────
|
|
1324
|
+
// happy-dom's elementFromPoint returns null, so we mock it to exercise the
|
|
1325
|
+
// overlay click and mousemove code paths that depend on finding a target element.
|
|
1326
|
+
|
|
1327
|
+
describe("devtools — overlay paths with mocked elementFromPoint", () => {
|
|
1328
|
+
beforeAll(() => {
|
|
1329
|
+
installDevTools()
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
test("overlay mousemove finds component and positions overlay + tooltip (line 120-141)", () => {
|
|
1333
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1334
|
+
enableOverlay: () => void
|
|
1335
|
+
disableOverlay: () => void
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const target = document.createElement("div")
|
|
1339
|
+
target.style.cssText = "width:100px;height:100px;position:fixed;top:50px;left:50px;"
|
|
1340
|
+
document.body.appendChild(target)
|
|
1341
|
+
registerComponent("mm-comp", "MouseMoveComp", target, null)
|
|
1342
|
+
|
|
1343
|
+
// Mock elementFromPoint to return our target
|
|
1344
|
+
const origElementFromPoint = document.elementFromPoint
|
|
1345
|
+
document.elementFromPoint = () => target
|
|
1346
|
+
|
|
1347
|
+
devtools.enableOverlay()
|
|
1348
|
+
|
|
1349
|
+
// First mousemove — sets _currentHighlight
|
|
1350
|
+
const event1 = new MouseEvent("mousemove", { clientX: 75, clientY: 75, bubbles: true })
|
|
1351
|
+
document.dispatchEvent(event1)
|
|
1352
|
+
|
|
1353
|
+
// Overlay should be visible
|
|
1354
|
+
const overlayEl = document.getElementById("__pyreon-overlay")
|
|
1355
|
+
expect(overlayEl?.style.display).toBe("block")
|
|
1356
|
+
|
|
1357
|
+
// Second mousemove on same element — should early return (line 117: same _currentHighlight)
|
|
1358
|
+
const event2 = new MouseEvent("mousemove", { clientX: 76, clientY: 76, bubbles: true })
|
|
1359
|
+
document.dispatchEvent(event2)
|
|
1360
|
+
|
|
1361
|
+
devtools.disableOverlay()
|
|
1362
|
+
document.elementFromPoint = origElementFromPoint
|
|
1363
|
+
unregisterComponent("mm-comp")
|
|
1364
|
+
target.remove()
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
test("overlay mousemove with tooltip near top of viewport repositions below (line 139)", () => {
|
|
1368
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1369
|
+
enableOverlay: () => void
|
|
1370
|
+
disableOverlay: () => void
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
const target = document.createElement("div")
|
|
1374
|
+
target.style.cssText = "width:100px;height:20px;position:fixed;top:5px;left:10px;"
|
|
1375
|
+
document.body.appendChild(target)
|
|
1376
|
+
registerComponent("top-mm", "TopMouseMove", target, null)
|
|
1377
|
+
|
|
1378
|
+
// Mock getBoundingClientRect to return rect.top < 35
|
|
1379
|
+
const origGetBCR = target.getBoundingClientRect.bind(target)
|
|
1380
|
+
target.getBoundingClientRect = () => ({
|
|
1381
|
+
top: 10,
|
|
1382
|
+
left: 10,
|
|
1383
|
+
width: 100,
|
|
1384
|
+
height: 20,
|
|
1385
|
+
bottom: 30,
|
|
1386
|
+
right: 110,
|
|
1387
|
+
x: 10,
|
|
1388
|
+
y: 10,
|
|
1389
|
+
toJSON: () => {},
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1392
|
+
const origElementFromPoint = document.elementFromPoint
|
|
1393
|
+
document.elementFromPoint = () => target
|
|
1394
|
+
|
|
1395
|
+
devtools.enableOverlay()
|
|
1396
|
+
|
|
1397
|
+
const event = new MouseEvent("mousemove", { clientX: 50, clientY: 15, bubbles: true })
|
|
1398
|
+
document.dispatchEvent(event)
|
|
1399
|
+
|
|
1400
|
+
devtools.disableOverlay()
|
|
1401
|
+
document.elementFromPoint = origElementFromPoint
|
|
1402
|
+
target.getBoundingClientRect = origGetBCR
|
|
1403
|
+
unregisterComponent("top-mm")
|
|
1404
|
+
target.remove()
|
|
1405
|
+
})
|
|
1406
|
+
|
|
1407
|
+
test("overlay mousemove with children count shows plural text (line 132)", () => {
|
|
1408
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1409
|
+
enableOverlay: () => void
|
|
1410
|
+
disableOverlay: () => void
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const target = document.createElement("div")
|
|
1414
|
+
target.style.cssText = "width:100px;height:100px;position:fixed;top:50px;left:50px;"
|
|
1415
|
+
document.body.appendChild(target)
|
|
1416
|
+
|
|
1417
|
+
// Parent with 2 children — triggers plural "components" text
|
|
1418
|
+
registerComponent("multi-parent", "MultiParent", target, null)
|
|
1419
|
+
registerComponent("multi-c1", "Child1", null, "multi-parent")
|
|
1420
|
+
registerComponent("multi-c2", "Child2", null, "multi-parent")
|
|
1421
|
+
|
|
1422
|
+
const origElementFromPoint = document.elementFromPoint
|
|
1423
|
+
document.elementFromPoint = () => target
|
|
1424
|
+
|
|
1425
|
+
devtools.enableOverlay()
|
|
1426
|
+
const event = new MouseEvent("mousemove", { clientX: 75, clientY: 75, bubbles: true })
|
|
1427
|
+
document.dispatchEvent(event)
|
|
1428
|
+
|
|
1429
|
+
devtools.disableOverlay()
|
|
1430
|
+
document.elementFromPoint = origElementFromPoint
|
|
1431
|
+
unregisterComponent("multi-c2")
|
|
1432
|
+
unregisterComponent("multi-c1")
|
|
1433
|
+
unregisterComponent("multi-parent")
|
|
1434
|
+
target.remove()
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
test("overlay mousemove with 1 child shows singular text (line 132)", () => {
|
|
1438
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1439
|
+
enableOverlay: () => void
|
|
1440
|
+
disableOverlay: () => void
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const target = document.createElement("div")
|
|
1444
|
+
target.style.cssText = "width:100px;height:100px;position:fixed;top:50px;left:50px;"
|
|
1445
|
+
document.body.appendChild(target)
|
|
1446
|
+
|
|
1447
|
+
registerComponent("single-parent", "SingleParent", target, null)
|
|
1448
|
+
registerComponent("single-c1", "SingleChild", null, "single-parent")
|
|
1449
|
+
|
|
1450
|
+
const origElementFromPoint = document.elementFromPoint
|
|
1451
|
+
document.elementFromPoint = () => target
|
|
1452
|
+
|
|
1453
|
+
devtools.enableOverlay()
|
|
1454
|
+
const event = new MouseEvent("mousemove", { clientX: 75, clientY: 75, bubbles: true })
|
|
1455
|
+
document.dispatchEvent(event)
|
|
1456
|
+
|
|
1457
|
+
devtools.disableOverlay()
|
|
1458
|
+
document.elementFromPoint = origElementFromPoint
|
|
1459
|
+
unregisterComponent("single-c1")
|
|
1460
|
+
unregisterComponent("single-parent")
|
|
1461
|
+
target.remove()
|
|
1462
|
+
})
|
|
1463
|
+
|
|
1464
|
+
test("overlay mousemove with entry.el null hides overlay (line 110)", () => {
|
|
1465
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1466
|
+
enableOverlay: () => void
|
|
1467
|
+
disableOverlay: () => void
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const target = document.createElement("div")
|
|
1471
|
+
document.body.appendChild(target)
|
|
1472
|
+
// Register component with null element
|
|
1473
|
+
registerComponent("null-el-comp", "NullElComp", null, null)
|
|
1474
|
+
|
|
1475
|
+
// elementFromPoint returns target, but findComponentForElement won't find
|
|
1476
|
+
// a match since none of our registered components have target as their el.
|
|
1477
|
+
// This hits the "no entry found" path (line 110).
|
|
1478
|
+
const origElementFromPoint = document.elementFromPoint
|
|
1479
|
+
document.elementFromPoint = () => target
|
|
1480
|
+
|
|
1481
|
+
devtools.enableOverlay()
|
|
1482
|
+
// First set a highlight by registering a component with the target
|
|
1483
|
+
registerComponent("real-comp", "RealComp", target, null)
|
|
1484
|
+
const event1 = new MouseEvent("mousemove", { clientX: 50, clientY: 50, bubbles: true })
|
|
1485
|
+
document.dispatchEvent(event1)
|
|
1486
|
+
unregisterComponent("real-comp")
|
|
1487
|
+
|
|
1488
|
+
// Now target is not associated with any component, so entry.el won't match
|
|
1489
|
+
// This triggers the "no entry?.el" path
|
|
1490
|
+
const event2 = new MouseEvent("mousemove", { clientX: 51, clientY: 51, bubbles: true })
|
|
1491
|
+
document.dispatchEvent(event2)
|
|
1492
|
+
|
|
1493
|
+
devtools.disableOverlay()
|
|
1494
|
+
document.elementFromPoint = origElementFromPoint
|
|
1495
|
+
unregisterComponent("null-el-comp")
|
|
1496
|
+
target.remove()
|
|
1497
|
+
})
|
|
1498
|
+
|
|
1499
|
+
test("overlay click finds component entry and logs it (lines 149-165)", () => {
|
|
1500
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1501
|
+
enableOverlay: () => void
|
|
1502
|
+
disableOverlay: () => void
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
const target = document.createElement("div")
|
|
1506
|
+
target.style.cssText = "width:100px;height:100px;position:fixed;top:50px;left:50px;"
|
|
1507
|
+
document.body.appendChild(target)
|
|
1508
|
+
|
|
1509
|
+
registerComponent("click-found", "ClickFound", target, null)
|
|
1510
|
+
|
|
1511
|
+
const origElementFromPoint = document.elementFromPoint
|
|
1512
|
+
document.elementFromPoint = () => target
|
|
1513
|
+
|
|
1514
|
+
const groupSpy = vi.spyOn(console, "group").mockImplementation(() => {})
|
|
1515
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
|
1516
|
+
const groupEndSpy = vi.spyOn(console, "groupEnd").mockImplementation(() => {})
|
|
1517
|
+
|
|
1518
|
+
devtools.enableOverlay()
|
|
1519
|
+
|
|
1520
|
+
const event = new MouseEvent("click", { clientX: 75, clientY: 75, bubbles: true })
|
|
1521
|
+
document.dispatchEvent(event)
|
|
1522
|
+
|
|
1523
|
+
// click handler calls console.group, console.log, console.groupEnd
|
|
1524
|
+
expect(groupSpy).toHaveBeenCalled()
|
|
1525
|
+
expect(logSpy).toHaveBeenCalledWith("element:", target)
|
|
1526
|
+
expect(logSpy).toHaveBeenCalledWith("children:", 0)
|
|
1527
|
+
expect(groupEndSpy).toHaveBeenCalled()
|
|
1528
|
+
|
|
1529
|
+
// disableOverlay is called by click handler
|
|
1530
|
+
expect(document.body.style.cursor).toBe("")
|
|
1531
|
+
|
|
1532
|
+
groupSpy.mockRestore()
|
|
1533
|
+
logSpy.mockRestore()
|
|
1534
|
+
groupEndSpy.mockRestore()
|
|
1535
|
+
document.elementFromPoint = origElementFromPoint
|
|
1536
|
+
unregisterComponent("click-found")
|
|
1537
|
+
target.remove()
|
|
1538
|
+
})
|
|
1539
|
+
|
|
1540
|
+
test("overlay click on component with parentId logs parent name (lines 159-162)", () => {
|
|
1541
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1542
|
+
enableOverlay: () => void
|
|
1543
|
+
disableOverlay: () => void
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const parentEl = document.createElement("div")
|
|
1547
|
+
const childEl = document.createElement("span")
|
|
1548
|
+
parentEl.appendChild(childEl)
|
|
1549
|
+
document.body.appendChild(parentEl)
|
|
1550
|
+
|
|
1551
|
+
registerComponent("click-parent-log", "ParentLog", parentEl, null)
|
|
1552
|
+
registerComponent("click-child-log", "ChildLog", childEl, "click-parent-log")
|
|
1553
|
+
|
|
1554
|
+
const origElementFromPoint = document.elementFromPoint
|
|
1555
|
+
document.elementFromPoint = () => childEl
|
|
1556
|
+
|
|
1557
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
|
1558
|
+
const groupSpy = vi.spyOn(console, "group").mockImplementation(() => {})
|
|
1559
|
+
const groupEndSpy = vi.spyOn(console, "groupEnd").mockImplementation(() => {})
|
|
1560
|
+
|
|
1561
|
+
devtools.enableOverlay()
|
|
1562
|
+
const event = new MouseEvent("click", { clientX: 25, clientY: 25, bubbles: true })
|
|
1563
|
+
document.dispatchEvent(event)
|
|
1564
|
+
|
|
1565
|
+
// Should log parent name (line 161)
|
|
1566
|
+
expect(logSpy).toHaveBeenCalledWith("parent:", "<ParentLog>")
|
|
1567
|
+
|
|
1568
|
+
groupSpy.mockRestore()
|
|
1569
|
+
logSpy.mockRestore()
|
|
1570
|
+
groupEndSpy.mockRestore()
|
|
1571
|
+
document.elementFromPoint = origElementFromPoint
|
|
1572
|
+
unregisterComponent("click-child-log")
|
|
1573
|
+
unregisterComponent("click-parent-log")
|
|
1574
|
+
parentEl.remove()
|
|
1575
|
+
})
|
|
1576
|
+
|
|
1577
|
+
test("overlay click on component without entry (no match) just disables (line 149-165 else)", () => {
|
|
1578
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1579
|
+
enableOverlay: () => void
|
|
1580
|
+
disableOverlay: () => void
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const target = document.createElement("div")
|
|
1584
|
+
document.body.appendChild(target)
|
|
1585
|
+
|
|
1586
|
+
// No component registered for this element
|
|
1587
|
+
const origElementFromPoint = document.elementFromPoint
|
|
1588
|
+
document.elementFromPoint = () => target
|
|
1589
|
+
|
|
1590
|
+
devtools.enableOverlay()
|
|
1591
|
+
const event = new MouseEvent("click", { clientX: 50, clientY: 50, bubbles: true })
|
|
1592
|
+
document.dispatchEvent(event)
|
|
1593
|
+
|
|
1594
|
+
// disableOverlay still called — cursor restored
|
|
1595
|
+
expect(document.body.style.cursor).toBe("")
|
|
1596
|
+
|
|
1597
|
+
document.elementFromPoint = origElementFromPoint
|
|
1598
|
+
target.remove()
|
|
1599
|
+
})
|
|
1600
|
+
})
|
|
1601
|
+
|
|
1602
|
+
// ─── devtools.ts — direct handler calls to cover remaining branches ──────────
|
|
1603
|
+
|
|
1604
|
+
describe("devtools — direct handler calls", () => {
|
|
1605
|
+
beforeAll(() => {
|
|
1606
|
+
installDevTools()
|
|
1607
|
+
})
|
|
1608
|
+
|
|
1609
|
+
test("onOverlayMouseMove with rect.top >= 35 does NOT reposition tooltip below (line 138 false)", () => {
|
|
1610
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1611
|
+
enableOverlay: () => void
|
|
1612
|
+
disableOverlay: () => void
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
const target = document.createElement("div")
|
|
1616
|
+
document.body.appendChild(target)
|
|
1617
|
+
registerComponent("direct-mm", "DirectMM", target, null)
|
|
1618
|
+
|
|
1619
|
+
target.getBoundingClientRect = () => ({
|
|
1620
|
+
top: 100,
|
|
1621
|
+
left: 50,
|
|
1622
|
+
width: 100,
|
|
1623
|
+
height: 50,
|
|
1624
|
+
bottom: 150,
|
|
1625
|
+
right: 150,
|
|
1626
|
+
x: 50,
|
|
1627
|
+
y: 100,
|
|
1628
|
+
toJSON: () => {},
|
|
1629
|
+
})
|
|
1630
|
+
|
|
1631
|
+
const origEFP = document.elementFromPoint
|
|
1632
|
+
document.elementFromPoint = () => target
|
|
1633
|
+
|
|
1634
|
+
devtools.enableOverlay()
|
|
1635
|
+
// Call handler directly
|
|
1636
|
+
onOverlayMouseMove(new MouseEvent("mousemove", { clientX: 75, clientY: 125 }))
|
|
1637
|
+
|
|
1638
|
+
const tooltipEl = document.querySelector("[style*='ui-monospace']") as HTMLElement
|
|
1639
|
+
// tooltip top should be rect.top - 30 = 70, NOT repositioned below
|
|
1640
|
+
if (tooltipEl) expect(tooltipEl.style.top).toBe("70px")
|
|
1641
|
+
|
|
1642
|
+
devtools.disableOverlay()
|
|
1643
|
+
document.elementFromPoint = origEFP
|
|
1644
|
+
unregisterComponent("direct-mm")
|
|
1645
|
+
target.remove()
|
|
1646
|
+
})
|
|
1647
|
+
|
|
1648
|
+
test("onOverlayMouseMove with rect.top < 35 repositions tooltip below (line 138 true)", () => {
|
|
1649
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1650
|
+
enableOverlay: () => void
|
|
1651
|
+
disableOverlay: () => void
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
const target = document.createElement("div")
|
|
1655
|
+
document.body.appendChild(target)
|
|
1656
|
+
registerComponent("direct-mm2", "DirectMM2", target, null)
|
|
1657
|
+
|
|
1658
|
+
target.getBoundingClientRect = () => ({
|
|
1659
|
+
top: 10,
|
|
1660
|
+
left: 50,
|
|
1661
|
+
width: 100,
|
|
1662
|
+
height: 20,
|
|
1663
|
+
bottom: 30,
|
|
1664
|
+
right: 150,
|
|
1665
|
+
x: 50,
|
|
1666
|
+
y: 10,
|
|
1667
|
+
toJSON: () => {},
|
|
1668
|
+
})
|
|
1669
|
+
|
|
1670
|
+
const origEFP = document.elementFromPoint
|
|
1671
|
+
document.elementFromPoint = () => target
|
|
1672
|
+
|
|
1673
|
+
devtools.enableOverlay()
|
|
1674
|
+
onOverlayMouseMove(new MouseEvent("mousemove", { clientX: 75, clientY: 15 }))
|
|
1675
|
+
|
|
1676
|
+
devtools.disableOverlay()
|
|
1677
|
+
document.elementFromPoint = origEFP
|
|
1678
|
+
unregisterComponent("direct-mm2")
|
|
1679
|
+
target.remove()
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
test("onOverlayClick with parentId but parent unregistered (line 161 false)", () => {
|
|
1683
|
+
const devtools = (window as unknown as Record<string, unknown>).__PYREON_DEVTOOLS__ as {
|
|
1684
|
+
enableOverlay: () => void
|
|
1685
|
+
disableOverlay: () => void
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const target = document.createElement("div")
|
|
1689
|
+
document.body.appendChild(target)
|
|
1690
|
+
// Register child with parentId pointing to nonexistent parent
|
|
1691
|
+
registerComponent("orphan-child", "OrphanChild", target, "nonexistent-parent")
|
|
1692
|
+
|
|
1693
|
+
const origEFP = document.elementFromPoint
|
|
1694
|
+
document.elementFromPoint = () => target
|
|
1695
|
+
|
|
1696
|
+
const groupSpy = vi.spyOn(console, "group").mockImplementation(() => {})
|
|
1697
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
|
1698
|
+
const groupEndSpy = vi.spyOn(console, "groupEnd").mockImplementation(() => {})
|
|
1699
|
+
|
|
1700
|
+
devtools.enableOverlay()
|
|
1701
|
+
onOverlayClick(new MouseEvent("click", { clientX: 50, clientY: 50 }))
|
|
1702
|
+
|
|
1703
|
+
// entry.parentId is truthy, but _components.get("nonexistent-parent") is undefined
|
|
1704
|
+
// so the `if (parent)` false branch is hit
|
|
1705
|
+
expect(groupSpy).toHaveBeenCalled()
|
|
1706
|
+
expect(logSpy).not.toHaveBeenCalledWith("parent:", expect.anything())
|
|
1707
|
+
|
|
1708
|
+
groupSpy.mockRestore()
|
|
1709
|
+
logSpy.mockRestore()
|
|
1710
|
+
groupEndSpy.mockRestore()
|
|
1711
|
+
document.elementFromPoint = origEFP
|
|
1712
|
+
unregisterComponent("orphan-child")
|
|
1713
|
+
target.remove()
|
|
1714
|
+
})
|
|
1715
|
+
|
|
1716
|
+
test("$p.stats reports component counts (line 284)", () => {
|
|
1717
|
+
const $p = (window as unknown as Record<string, unknown>).$p as {
|
|
1718
|
+
stats: () => { total: number; roots: number }
|
|
1719
|
+
}
|
|
1720
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
|
1721
|
+
const result = $p.stats()
|
|
1722
|
+
expect(result).toHaveProperty("total")
|
|
1723
|
+
expect(result).toHaveProperty("roots")
|
|
1724
|
+
expect(logSpy).toHaveBeenCalled()
|
|
1725
|
+
logSpy.mockRestore()
|
|
1726
|
+
})
|
|
1727
|
+
})
|
|
1728
|
+
|
|
1729
|
+
// ─── hydrate.ts — reactive accessor with complex VNode and no domNode ────────
|
|
1730
|
+
|
|
1731
|
+
describe("hydrate.ts — additional reactive accessor branches", () => {
|
|
1732
|
+
test("hydrate reactive accessor returning null with domNode appends marker before it", () => {
|
|
1733
|
+
const el = container()
|
|
1734
|
+
el.innerHTML = "<span>existing-content</span>"
|
|
1735
|
+
const content = signal<VNodeChild>(null)
|
|
1736
|
+
const cleanup = hydrateRoot(
|
|
1737
|
+
el,
|
|
1738
|
+
h(
|
|
1739
|
+
Fragment,
|
|
1740
|
+
null,
|
|
1741
|
+
(() => content()) as unknown as VNodeChild,
|
|
1742
|
+
h("span", null, "existing-content"),
|
|
1743
|
+
),
|
|
1744
|
+
)
|
|
1745
|
+
cleanup()
|
|
1746
|
+
})
|
|
1747
|
+
|
|
1748
|
+
test("hydrate reactive text matching a text node reuses it (line 110-116)", () => {
|
|
1749
|
+
const el = container()
|
|
1750
|
+
el.innerHTML = "initial text"
|
|
1751
|
+
const text = signal("initial text")
|
|
1752
|
+
const cleanup = hydrateRoot(el, (() => text()) as unknown as VNodeChild)
|
|
1753
|
+
// Should reuse the existing text node and attach effect
|
|
1754
|
+
text.set("updated text")
|
|
1755
|
+
expect(el.textContent).toContain("updated text")
|
|
1756
|
+
cleanup()
|
|
1757
|
+
})
|
|
1758
|
+
|
|
1759
|
+
test("hydrate For without markers and domNode is null (line 187-194)", () => {
|
|
1760
|
+
const el = container()
|
|
1761
|
+
el.innerHTML = "" // empty, so domNode is null
|
|
1762
|
+
const items = signal([{ id: 1, label: "x" }])
|
|
1763
|
+
const cleanup = hydrateRoot(
|
|
1764
|
+
el,
|
|
1765
|
+
For({
|
|
1766
|
+
each: items,
|
|
1767
|
+
by: (r: { id: number }) => r.id,
|
|
1768
|
+
children: (r: { id: number; label: string }) => h("li", null, r.label),
|
|
1769
|
+
}),
|
|
1770
|
+
)
|
|
1771
|
+
cleanup()
|
|
1772
|
+
})
|
|
1773
|
+
|
|
1774
|
+
test("hydrate element with null domNode (no matching DOM) falls back to mount", () => {
|
|
1775
|
+
const el = container()
|
|
1776
|
+
el.innerHTML = "" // nothing to match
|
|
1777
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
1778
|
+
const cleanup = hydrateRoot(el, h("div", null, "fresh"))
|
|
1779
|
+
cleanup()
|
|
1780
|
+
warnSpy.mockRestore()
|
|
1781
|
+
})
|
|
1782
|
+
})
|
|
1783
|
+
|
|
1784
|
+
// ─── transition.ts — additional pendingLeaveCancel branch ────────────────────
|
|
1785
|
+
|
|
1786
|
+
describe("Transition — pendingLeaveCancel exercised in rAF callback (lines 110-113)", () => {
|
|
1787
|
+
test("leave rAF sets pendingLeaveCancel then re-enter cancels it", async () => {
|
|
1788
|
+
const el = container()
|
|
1789
|
+
const visible = signal(true)
|
|
1790
|
+
const _removeListenerCalled = false
|
|
1791
|
+
|
|
1792
|
+
mount(
|
|
1793
|
+
h(Transition, {
|
|
1794
|
+
show: visible,
|
|
1795
|
+
name: "cancel-test",
|
|
1796
|
+
children: h("div", { id: "cancel-target" }, "content"),
|
|
1797
|
+
}),
|
|
1798
|
+
el,
|
|
1799
|
+
)
|
|
1800
|
+
|
|
1801
|
+
// Start leave
|
|
1802
|
+
visible.set(false)
|
|
1803
|
+
|
|
1804
|
+
// Wait for rAF to set pendingLeaveCancel (line 110)
|
|
1805
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
1806
|
+
|
|
1807
|
+
// The target should now have leave classes
|
|
1808
|
+
const target = el.querySelector("#cancel-target")
|
|
1809
|
+
expect(target?.classList.contains("cancel-test-leave-active")).toBe(true)
|
|
1810
|
+
|
|
1811
|
+
// Re-enter — applyEnter calls pendingLeaveCancel (line 80-81)
|
|
1812
|
+
visible.set(true)
|
|
1813
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
1814
|
+
|
|
1815
|
+
// Leave classes should be removed by the cancel
|
|
1816
|
+
// The element should still be visible
|
|
1817
|
+
el.remove()
|
|
1818
|
+
})
|
|
1819
|
+
})
|
|
1820
|
+
|
|
1821
|
+
// ─── mount.ts — mountElement nested paths ────────────────────────────────────
|
|
1822
|
+
|
|
1823
|
+
describe("mount.ts — additional nested element branch paths", () => {
|
|
1824
|
+
test("nested element with ref + propCleanup + childCleanup at depth > 0 (lines 202-207)", () => {
|
|
1825
|
+
const el = container()
|
|
1826
|
+
const ref = createRef<HTMLElement>()
|
|
1827
|
+
const cls = signal("dynamic")
|
|
1828
|
+
const text = signal("inner")
|
|
1829
|
+
|
|
1830
|
+
const unmount = mount(
|
|
1831
|
+
h(
|
|
1832
|
+
"div",
|
|
1833
|
+
null,
|
|
1834
|
+
h("span", { ref, class: () => cls() }, () => text()),
|
|
1835
|
+
),
|
|
1836
|
+
el,
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
expect(ref.current).not.toBeNull()
|
|
1840
|
+
expect(ref.current?.className).toBe("dynamic")
|
|
1841
|
+
expect(ref.current?.textContent).toBe("inner")
|
|
1842
|
+
|
|
1843
|
+
cls.set("changed")
|
|
1844
|
+
text.set("updated")
|
|
1845
|
+
expect(ref.current?.className).toBe("changed")
|
|
1846
|
+
|
|
1847
|
+
unmount()
|
|
1848
|
+
expect(ref.current).toBeNull()
|
|
1849
|
+
})
|
|
1850
|
+
|
|
1851
|
+
test("nested element with childCleanup but no ref and no propCleanup (line 196)", () => {
|
|
1852
|
+
const el = container()
|
|
1853
|
+
const text = signal("reactive-child")
|
|
1854
|
+
|
|
1855
|
+
// span is nested (depth > 0), has reactive child (childCleanup !== noop)
|
|
1856
|
+
// but no ref and no propCleanup
|
|
1857
|
+
const unmount = mount(
|
|
1858
|
+
h(
|
|
1859
|
+
"div",
|
|
1860
|
+
null,
|
|
1861
|
+
h("span", null, () => text()),
|
|
1862
|
+
),
|
|
1863
|
+
el,
|
|
1864
|
+
)
|
|
1865
|
+
|
|
1866
|
+
expect(el.querySelector("span")?.textContent).toBe("reactive-child")
|
|
1867
|
+
text.set("changed-child")
|
|
1868
|
+
expect(el.querySelector("span")?.textContent).toBe("changed-child")
|
|
1869
|
+
unmount()
|
|
1870
|
+
})
|
|
1871
|
+
|
|
1872
|
+
test("nested element with propCleanup but no ref at depth > 0 (line 197)", () => {
|
|
1873
|
+
const el = container()
|
|
1874
|
+
const cls = signal("cls-val")
|
|
1875
|
+
|
|
1876
|
+
// span nested, has reactive prop (propCleanup) but no ref
|
|
1877
|
+
// childCleanup is noop (static text child)
|
|
1878
|
+
const unmount = mount(h("div", null, h("span", { class: () => cls() }, "static-text")), el)
|
|
1879
|
+
|
|
1880
|
+
expect(el.querySelector("span")?.className).toBe("cls-val")
|
|
1881
|
+
cls.set("new-cls")
|
|
1882
|
+
expect(el.querySelector("span")?.className).toBe("new-cls")
|
|
1883
|
+
unmount()
|
|
1884
|
+
})
|
|
1885
|
+
})
|
|
1886
|
+
|
|
1887
|
+
// ─── nodes.ts — mountFor NativeItem without cleanup in step 3 ────────────────
|
|
1888
|
+
|
|
1889
|
+
describe("nodes.ts — mountFor NativeItem edge cases in step 3", () => {
|
|
1890
|
+
test("mountFor step 3 NativeItem without cleanup (cleanupCount unchanged)", () => {
|
|
1891
|
+
const el = container()
|
|
1892
|
+
const items = signal([{ id: 1, label: "a" }])
|
|
1893
|
+
|
|
1894
|
+
mount(
|
|
1895
|
+
h(
|
|
1896
|
+
"div",
|
|
1897
|
+
null,
|
|
1898
|
+
For({
|
|
1899
|
+
each: items,
|
|
1900
|
+
by: (r: { id: number }) => r.id,
|
|
1901
|
+
children: (r: { id: number; label: string }) => {
|
|
1902
|
+
// NativeItem with null cleanup
|
|
1903
|
+
const native = _tpl("<b></b>", (root) => {
|
|
1904
|
+
root.textContent = r.label
|
|
1905
|
+
return null
|
|
1906
|
+
})
|
|
1907
|
+
return native as unknown as ReturnType<typeof h>
|
|
1908
|
+
},
|
|
1909
|
+
}),
|
|
1910
|
+
),
|
|
1911
|
+
el,
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
// Add new item — step 3 mounts NativeItem with null cleanup
|
|
1915
|
+
items.set([
|
|
1916
|
+
{ id: 1, label: "a" },
|
|
1917
|
+
{ id: 2, label: "b" },
|
|
1918
|
+
])
|
|
1919
|
+
expect(el.querySelectorAll("b").length).toBe(2)
|
|
1920
|
+
|
|
1921
|
+
el.remove()
|
|
1922
|
+
})
|
|
1923
|
+
|
|
1924
|
+
test("mountFor step 2 removes stale entry with cleanup (cleanupCount--)", () => {
|
|
1925
|
+
const el = container()
|
|
1926
|
+
const _cleanupCalled = false
|
|
1927
|
+
const items = signal([
|
|
1928
|
+
{ id: 1, label: "a" },
|
|
1929
|
+
{ id: 2, label: "b" },
|
|
1930
|
+
])
|
|
1931
|
+
|
|
1932
|
+
mount(
|
|
1933
|
+
h(
|
|
1934
|
+
"div",
|
|
1935
|
+
null,
|
|
1936
|
+
For({
|
|
1937
|
+
each: items,
|
|
1938
|
+
by: (r: { id: number }) => r.id,
|
|
1939
|
+
children: (r: { id: number; label: string }) => h("span", null, r.label),
|
|
1940
|
+
}),
|
|
1941
|
+
),
|
|
1942
|
+
el,
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
// Remove item 2 but keep item 1 — step 2 removes stale entry
|
|
1946
|
+
items.set([{ id: 1, label: "a" }])
|
|
1947
|
+
expect(el.querySelectorAll("span").length).toBe(1)
|
|
1948
|
+
|
|
1949
|
+
el.remove()
|
|
1950
|
+
})
|
|
1951
|
+
|
|
1952
|
+
test("mountFor step 2 removes stale NativeItem entry with cleanup", () => {
|
|
1953
|
+
const el = container()
|
|
1954
|
+
let cleanupCount = 0
|
|
1955
|
+
const items = signal([
|
|
1956
|
+
{ id: 1, label: "a" },
|
|
1957
|
+
{ id: 2, label: "b" },
|
|
1958
|
+
])
|
|
1959
|
+
|
|
1960
|
+
mount(
|
|
1961
|
+
h(
|
|
1962
|
+
"div",
|
|
1963
|
+
null,
|
|
1964
|
+
For({
|
|
1965
|
+
each: items,
|
|
1966
|
+
by: (r: { id: number }) => r.id,
|
|
1967
|
+
children: (r: { id: number; label: string }) => {
|
|
1968
|
+
const native = _tpl("<b></b>", (root) => {
|
|
1969
|
+
root.textContent = r.label
|
|
1970
|
+
return () => {
|
|
1971
|
+
cleanupCount++
|
|
1972
|
+
}
|
|
1973
|
+
})
|
|
1974
|
+
return native as unknown as ReturnType<typeof h>
|
|
1975
|
+
},
|
|
1976
|
+
}),
|
|
1977
|
+
),
|
|
1978
|
+
el,
|
|
1979
|
+
)
|
|
1980
|
+
|
|
1981
|
+
// Remove item 2 (keeps item 1) — step 2 with entry.cleanup non-null
|
|
1982
|
+
items.set([{ id: 1, label: "a" }])
|
|
1983
|
+
expect(cleanupCount).toBe(1)
|
|
1984
|
+
|
|
1985
|
+
el.remove()
|
|
1986
|
+
})
|
|
1987
|
+
|
|
1988
|
+
test("mountFor clear path with cleanupCount = 0 skips cleanup iteration", () => {
|
|
1989
|
+
const el = container()
|
|
1990
|
+
const items = signal([{ id: 1 }])
|
|
1991
|
+
|
|
1992
|
+
// Use NativeItem with null cleanup so cleanupCount stays 0
|
|
1993
|
+
mount(
|
|
1994
|
+
h(
|
|
1995
|
+
"div",
|
|
1996
|
+
null,
|
|
1997
|
+
For({
|
|
1998
|
+
each: items,
|
|
1999
|
+
by: (r: { id: number }) => r.id,
|
|
2000
|
+
children: (r: { id: number }) => {
|
|
2001
|
+
const native = _tpl("<b></b>", (root) => {
|
|
2002
|
+
root.textContent = String(r.id)
|
|
2003
|
+
return null
|
|
2004
|
+
})
|
|
2005
|
+
return native as unknown as ReturnType<typeof h>
|
|
2006
|
+
},
|
|
2007
|
+
}),
|
|
2008
|
+
),
|
|
2009
|
+
el,
|
|
2010
|
+
)
|
|
2011
|
+
|
|
2012
|
+
// Clear — cleanupCount = 0, so skip cleanup iteration
|
|
2013
|
+
items.set([])
|
|
2014
|
+
expect(el.querySelectorAll("b").length).toBe(0)
|
|
2015
|
+
|
|
2016
|
+
el.remove()
|
|
2017
|
+
})
|
|
2018
|
+
|
|
2019
|
+
test("mountFor replace-all with NativeItem entries having no cleanup", () => {
|
|
2020
|
+
const el = container()
|
|
2021
|
+
const items = signal([{ id: 1, label: "old" }])
|
|
2022
|
+
|
|
2023
|
+
mount(
|
|
2024
|
+
h(
|
|
2025
|
+
"div",
|
|
2026
|
+
null,
|
|
2027
|
+
For({
|
|
2028
|
+
each: items,
|
|
2029
|
+
by: (r: { id: number }) => r.id,
|
|
2030
|
+
children: (r: { id: number; label: string }) => {
|
|
2031
|
+
const native = _tpl("<b></b>", (root) => {
|
|
2032
|
+
root.textContent = r.label
|
|
2033
|
+
return null // no cleanup
|
|
2034
|
+
})
|
|
2035
|
+
return native as unknown as ReturnType<typeof h>
|
|
2036
|
+
},
|
|
2037
|
+
}),
|
|
2038
|
+
),
|
|
2039
|
+
el,
|
|
2040
|
+
)
|
|
2041
|
+
|
|
2042
|
+
// Replace all with completely new keys
|
|
2043
|
+
items.set([{ id: 10, label: "new" }])
|
|
2044
|
+
expect(el.querySelector("b")?.textContent).toBe("new")
|
|
2045
|
+
|
|
2046
|
+
el.remove()
|
|
2047
|
+
})
|
|
2048
|
+
})
|
|
2049
|
+
|
|
2050
|
+
// ─── props.ts — sanitizeHtml SSR fallback (no DOMParser) ─────────────────────
|
|
2051
|
+
|
|
2052
|
+
describe("props.ts — sanitizeHtml edge cases", () => {
|
|
2053
|
+
test("sanitizeHtml with custom sanitizer takes priority over native and fallback", () => {
|
|
2054
|
+
let called = false
|
|
2055
|
+
setSanitizer((html) => {
|
|
2056
|
+
called = true
|
|
2057
|
+
return html.replace(/<[^>]*>/g, "STRIPPED")
|
|
2058
|
+
})
|
|
2059
|
+
const result = sanitizeHtml("<b>test</b>")
|
|
2060
|
+
expect(called).toBe(true)
|
|
2061
|
+
expect(result).toContain("STRIPPED")
|
|
2062
|
+
setSanitizer(null)
|
|
2063
|
+
})
|
|
2064
|
+
|
|
2065
|
+
test("applyProp with reactive function prop creates renderEffect", () => {
|
|
2066
|
+
const el = document.createElement("div")
|
|
2067
|
+
const title = signal("initial")
|
|
2068
|
+
const cleanup = applyProp(el, "title", () => title())
|
|
2069
|
+
expect(el.title).toBe("initial")
|
|
2070
|
+
title.set("updated")
|
|
2071
|
+
expect(el.title).toBe("updated")
|
|
2072
|
+
cleanup?.()
|
|
2073
|
+
})
|
|
2074
|
+
})
|
|
2075
|
+
|
|
2076
|
+
// ─── keep-alive.ts — KeepAlive where container ref not yet set ───────────────
|
|
2077
|
+
|
|
2078
|
+
describe("KeepAlive — container ref edge cases", () => {
|
|
2079
|
+
test("KeepAlive mounts children once and preserves state across hide/show cycles", () => {
|
|
2080
|
+
const el = container()
|
|
2081
|
+
const active = signal(true)
|
|
2082
|
+
const count = signal(0)
|
|
2083
|
+
|
|
2084
|
+
const Inner = defineComponent(() => {
|
|
2085
|
+
return h("span", null, () => String(count()))
|
|
2086
|
+
})
|
|
2087
|
+
|
|
2088
|
+
const unmount = mount(h(KeepAlive, { active: () => active() }, h(Inner, null)), el)
|
|
2089
|
+
|
|
2090
|
+
expect(el.querySelector("span")?.textContent).toBe("0")
|
|
2091
|
+
|
|
2092
|
+
// Update state while visible
|
|
2093
|
+
count.set(5)
|
|
2094
|
+
expect(el.querySelector("span")?.textContent).toBe("5")
|
|
2095
|
+
|
|
2096
|
+
// Hide — state preserved
|
|
2097
|
+
active.set(false)
|
|
2098
|
+
const wrapper = el.querySelector("div[style*='display']") ?? el.querySelector("div")
|
|
2099
|
+
expect(wrapper).not.toBeNull()
|
|
2100
|
+
|
|
2101
|
+
// Show again — state still preserved
|
|
2102
|
+
active.set(true)
|
|
2103
|
+
expect(el.querySelector("span")?.textContent).toBe("5")
|
|
2104
|
+
|
|
2105
|
+
unmount()
|
|
2106
|
+
})
|
|
2107
|
+
|
|
2108
|
+
test("KeepAlive with null children mounts nothing (line 55)", () => {
|
|
2109
|
+
const el = container()
|
|
2110
|
+
const unmount = mount(
|
|
2111
|
+
h(KeepAlive, { active: () => true, children: null as unknown as undefined }),
|
|
2112
|
+
el,
|
|
2113
|
+
)
|
|
2114
|
+
// Container exists but empty
|
|
2115
|
+
const wrapper = el.querySelector("div")
|
|
2116
|
+
expect(wrapper).not.toBeNull()
|
|
2117
|
+
unmount()
|
|
2118
|
+
})
|
|
2119
|
+
})
|
|
2120
|
+
|
|
2121
|
+
// ─── Additional coverage: mountKeyedList LIS array growth (nodes.ts lines 174-178) ──
|
|
2122
|
+
|
|
2123
|
+
describe("nodes.ts — mountKeyedList LIS typed array reallocation", () => {
|
|
2124
|
+
test("mountKeyedList with >16 keyed items triggers LIS array growth", () => {
|
|
2125
|
+
const el = container()
|
|
2126
|
+
// Start with > 16 keyed VNodes to exceed initial Int32Array(16) size
|
|
2127
|
+
const makeItems = (ids: number[]) => ids.map((id) => h("span", { key: id }, String(id)))
|
|
2128
|
+
|
|
2129
|
+
const ids = Array.from({ length: 20 }, (_, i) => i)
|
|
2130
|
+
const items = signal(makeItems(ids) as VNodeChild)
|
|
2131
|
+
|
|
2132
|
+
mount(h("div", null, (() => items()) as VNodeChild), el)
|
|
2133
|
+
expect(el.querySelectorAll("span").length).toBe(20)
|
|
2134
|
+
|
|
2135
|
+
// Reverse to trigger LIS reorder with typed array growth
|
|
2136
|
+
const reversed = [...ids].reverse()
|
|
2137
|
+
items.set(makeItems(reversed) as unknown as VNodeChild)
|
|
2138
|
+
expect(el.querySelectorAll("span").length).toBe(20)
|
|
2139
|
+
expect(el.querySelectorAll("span")[0]?.textContent).toBe("19")
|
|
2140
|
+
|
|
2141
|
+
el.remove()
|
|
2142
|
+
})
|
|
2143
|
+
})
|
|
2144
|
+
|
|
2145
|
+
// ─── Additional coverage: Transition with no children (rawChild is undefined) ──
|
|
2146
|
+
|
|
2147
|
+
describe("Transition — rawChild undefined branch (line 164-165)", () => {
|
|
2148
|
+
test("Transition with no children returns null when mounted", () => {
|
|
2149
|
+
const el = container()
|
|
2150
|
+
const visible = signal(true)
|
|
2151
|
+
|
|
2152
|
+
// No children prop at all — rawChild is undefined
|
|
2153
|
+
mount(h(Transition, { show: visible, name: "fade" }), el)
|
|
2154
|
+
|
|
2155
|
+
// Should not throw; renders nothing meaningful
|
|
2156
|
+
el.remove()
|
|
2157
|
+
})
|
|
2158
|
+
})
|
|
2159
|
+
|
|
2160
|
+
// ─── Additional coverage: anonymous component (mount.ts line 234 || "Anonymous") ──
|
|
2161
|
+
|
|
2162
|
+
describe("mount.ts — anonymous component name fallback", () => {
|
|
2163
|
+
test("anonymous component uses 'Anonymous' fallback name", () => {
|
|
2164
|
+
const el = container()
|
|
2165
|
+
|
|
2166
|
+
// Arrow function has name: "" (empty string) — triggers || "Anonymous"
|
|
2167
|
+
const unmount = mount(h((() => h("span", null, "anon")) as unknown as ComponentFn, null), el)
|
|
2168
|
+
|
|
2169
|
+
expect(el.querySelector("span")?.textContent).toBe("anon")
|
|
2170
|
+
unmount()
|
|
2171
|
+
})
|
|
2172
|
+
})
|
|
2173
|
+
|
|
2174
|
+
// ─── Additional coverage: TransitionGroup FLIP inner rAF (lines 209-218) ──
|
|
2175
|
+
|
|
2176
|
+
describe("TransitionGroup — FLIP inner rAF with mocked getBoundingClientRect", () => {
|
|
2177
|
+
test("FLIP move animation fires inner rAF when positions differ", async () => {
|
|
2178
|
+
const el = container()
|
|
2179
|
+
const items = signal([
|
|
2180
|
+
{ id: 1, label: "a" },
|
|
2181
|
+
{ id: 2, label: "b" },
|
|
2182
|
+
{ id: 3, label: "c" },
|
|
2183
|
+
])
|
|
2184
|
+
|
|
2185
|
+
mount(
|
|
2186
|
+
h(TransitionGroup, {
|
|
2187
|
+
tag: "div",
|
|
2188
|
+
name: "flip",
|
|
2189
|
+
items: () => items(),
|
|
2190
|
+
keyFn: (item: { id: number }) => item.id,
|
|
2191
|
+
render: (item: { id: number; label: string }) =>
|
|
2192
|
+
h("span", { class: "flip-mock" }, item.label),
|
|
2193
|
+
}),
|
|
2194
|
+
el,
|
|
2195
|
+
)
|
|
2196
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
2197
|
+
|
|
2198
|
+
// Mock getBoundingClientRect on each span to return different positions
|
|
2199
|
+
const spans = el.querySelectorAll("span.flip-mock")
|
|
2200
|
+
let callCount = 0
|
|
2201
|
+
for (const span of spans) {
|
|
2202
|
+
const idx = callCount++
|
|
2203
|
+
;(span as HTMLElement).getBoundingClientRect = () => ({
|
|
2204
|
+
top: idx * 30,
|
|
2205
|
+
left: 0,
|
|
2206
|
+
width: 100,
|
|
2207
|
+
height: 25,
|
|
2208
|
+
bottom: idx * 30 + 25,
|
|
2209
|
+
right: 100,
|
|
2210
|
+
x: 0,
|
|
2211
|
+
y: idx * 30,
|
|
2212
|
+
toJSON: () => {},
|
|
2213
|
+
})
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
// Reorder items to trigger FLIP
|
|
2217
|
+
items.set([
|
|
2218
|
+
{ id: 3, label: "c" },
|
|
2219
|
+
{ id: 1, label: "a" },
|
|
2220
|
+
{ id: 2, label: "b" },
|
|
2221
|
+
])
|
|
2222
|
+
|
|
2223
|
+
// Update getBoundingClientRect for new positions
|
|
2224
|
+
const newSpans = el.querySelectorAll("span.flip-mock")
|
|
2225
|
+
let newIdx = 0
|
|
2226
|
+
for (const span of newSpans) {
|
|
2227
|
+
const i = newIdx++
|
|
2228
|
+
;(span as HTMLElement).getBoundingClientRect = () => ({
|
|
2229
|
+
top: i * 30,
|
|
2230
|
+
left: 0,
|
|
2231
|
+
width: 100,
|
|
2232
|
+
height: 25,
|
|
2233
|
+
bottom: i * 30 + 25,
|
|
2234
|
+
right: 100,
|
|
2235
|
+
x: 0,
|
|
2236
|
+
y: i * 30,
|
|
2237
|
+
toJSON: () => {},
|
|
2238
|
+
})
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
2242
|
+
// First rAF: FLIP records positions and applies inverse transform
|
|
2243
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
2244
|
+
// Second rAF: inner rAF applies move class and transitions
|
|
2245
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
2246
|
+
|
|
2247
|
+
// Fire transitionend to clean up move class
|
|
2248
|
+
for (const span of el.querySelectorAll("span.flip-mock")) {
|
|
2249
|
+
span.dispatchEvent(new Event("transitionend"))
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
el.remove()
|
|
2253
|
+
})
|
|
2254
|
+
})
|
|
2255
|
+
|
|
2256
|
+
// ─── Additional coverage: Transition with custom class overrides ──
|
|
2257
|
+
|
|
2258
|
+
describe("Transition — custom class overrides (transition.ts ?? branches)", () => {
|
|
2259
|
+
test("Transition with explicit class overrides uses provided classes", async () => {
|
|
2260
|
+
const el = container()
|
|
2261
|
+
const visible = signal(false)
|
|
2262
|
+
|
|
2263
|
+
mount(
|
|
2264
|
+
h(Transition, {
|
|
2265
|
+
show: visible,
|
|
2266
|
+
name: "custom",
|
|
2267
|
+
enterFrom: "my-enter-from",
|
|
2268
|
+
enterActive: "my-enter-active",
|
|
2269
|
+
enterTo: "my-enter-to",
|
|
2270
|
+
leaveFrom: "my-leave-from",
|
|
2271
|
+
leaveActive: "my-leave-active",
|
|
2272
|
+
leaveTo: "my-leave-to",
|
|
2273
|
+
children: h("div", { id: "custom-cls" }, "custom"),
|
|
2274
|
+
}),
|
|
2275
|
+
el,
|
|
2276
|
+
)
|
|
2277
|
+
|
|
2278
|
+
visible.set(true)
|
|
2279
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
2280
|
+
|
|
2281
|
+
const target = el.querySelector("#custom-cls")
|
|
2282
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
2283
|
+
// After rAF, enter-active and enter-to should be applied
|
|
2284
|
+
expect(target?.classList.contains("my-enter-active")).toBe(true)
|
|
2285
|
+
expect(target?.classList.contains("my-enter-to")).toBe(true)
|
|
2286
|
+
|
|
2287
|
+
// Fire transitionend
|
|
2288
|
+
if (target) target.dispatchEvent(new Event("transitionend"))
|
|
2289
|
+
|
|
2290
|
+
el.remove()
|
|
2291
|
+
})
|
|
2292
|
+
})
|
|
2293
|
+
|
|
2294
|
+
// ─── Additional coverage: TransitionGroup with custom class overrides ──
|
|
2295
|
+
|
|
2296
|
+
describe("TransitionGroup — custom class overrides (transition-group.ts ?? branches)", () => {
|
|
2297
|
+
test("TransitionGroup with explicit class overrides", async () => {
|
|
2298
|
+
const el = container()
|
|
2299
|
+
const items = signal([{ id: 1, label: "a" }])
|
|
2300
|
+
|
|
2301
|
+
mount(
|
|
2302
|
+
h(TransitionGroup, {
|
|
2303
|
+
tag: "ul",
|
|
2304
|
+
name: "tg",
|
|
2305
|
+
items: () => items(),
|
|
2306
|
+
keyFn: (item: { id: number }) => item.id,
|
|
2307
|
+
render: (item: { id: number; label: string }) => h("li", { class: "tg-item" }, item.label),
|
|
2308
|
+
enterFrom: "custom-ef",
|
|
2309
|
+
enterActive: "custom-ea",
|
|
2310
|
+
enterTo: "custom-et",
|
|
2311
|
+
leaveFrom: "custom-lf",
|
|
2312
|
+
leaveActive: "custom-la",
|
|
2313
|
+
leaveTo: "custom-lt",
|
|
2314
|
+
moveClass: "custom-move",
|
|
2315
|
+
}),
|
|
2316
|
+
el,
|
|
2317
|
+
)
|
|
2318
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
2319
|
+
|
|
2320
|
+
// Add item to trigger enter
|
|
2321
|
+
items.set([
|
|
2322
|
+
{ id: 1, label: "a" },
|
|
2323
|
+
{ id: 2, label: "b" },
|
|
2324
|
+
])
|
|
2325
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
2326
|
+
|
|
2327
|
+
el.remove()
|
|
2328
|
+
})
|
|
2329
|
+
})
|
|
2330
|
+
|
|
2331
|
+
// ─── transition.ts — component child warning (line 170) ─────────────────────
|
|
2332
|
+
|
|
2333
|
+
describe("transition.ts — component child warning", () => {
|
|
2334
|
+
test("Transition warns when child is a component (line 170)", async () => {
|
|
2335
|
+
const el = document.createElement("div")
|
|
2336
|
+
document.body.appendChild(el)
|
|
2337
|
+
const show = signal(true)
|
|
2338
|
+
|
|
2339
|
+
function Inner() {
|
|
2340
|
+
return h("span", null, "inner")
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
2344
|
+
|
|
2345
|
+
mount(
|
|
2346
|
+
h(Transition, {
|
|
2347
|
+
name: "fade",
|
|
2348
|
+
show: () => show(),
|
|
2349
|
+
children: h(Inner, null),
|
|
2350
|
+
}),
|
|
2351
|
+
el,
|
|
2352
|
+
)
|
|
2353
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
2354
|
+
|
|
2355
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Transition child is a component"))
|
|
2356
|
+
warn.mockRestore()
|
|
2357
|
+
el.remove()
|
|
2358
|
+
})
|
|
2359
|
+
|
|
2360
|
+
test("Transition with non-VNode children returns rawChild ?? null (line 165)", async () => {
|
|
2361
|
+
const el = document.createElement("div")
|
|
2362
|
+
document.body.appendChild(el)
|
|
2363
|
+
const show = signal(true)
|
|
2364
|
+
|
|
2365
|
+
mount(
|
|
2366
|
+
h(Transition, {
|
|
2367
|
+
name: "fade",
|
|
2368
|
+
show: () => show(),
|
|
2369
|
+
children: "just text",
|
|
2370
|
+
}),
|
|
2371
|
+
el,
|
|
2372
|
+
)
|
|
2373
|
+
await new Promise<void>((r) => queueMicrotask(r))
|
|
2374
|
+
expect(el.textContent).toContain("just text")
|
|
2375
|
+
el.remove()
|
|
2376
|
+
})
|
|
2377
|
+
})
|
|
2378
|
+
|
|
2379
|
+
// ─── devtools.ts — overlay handlers (lines 128-169, 284) ────────────────────
|
|
2380
|
+
|
|
2381
|
+
describe("devtools.ts — $p console helper branches", () => {
|
|
2382
|
+
test("$p.highlight with unknown id does nothing", () => {
|
|
2383
|
+
installDevTools()
|
|
2384
|
+
const p = (window as unknown as Record<string, unknown>).$p as Record<
|
|
2385
|
+
string,
|
|
2386
|
+
(...args: unknown[]) => unknown
|
|
2387
|
+
>
|
|
2388
|
+
// Should not throw
|
|
2389
|
+
p.highlight?.("nonexistent-id-12345")
|
|
2390
|
+
})
|
|
2391
|
+
|
|
2392
|
+
test("$p.help prints usage (line 291+)", () => {
|
|
2393
|
+
installDevTools()
|
|
2394
|
+
const p = (window as unknown as Record<string, unknown>).$p as Record<
|
|
2395
|
+
string,
|
|
2396
|
+
(...args: unknown[]) => unknown
|
|
2397
|
+
>
|
|
2398
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
|
2399
|
+
p.help?.()
|
|
2400
|
+
expect(logSpy).toHaveBeenCalled()
|
|
2401
|
+
logSpy.mockRestore()
|
|
2402
|
+
})
|
|
2403
|
+
})
|
|
2404
|
+
|
|
2405
|
+
// ─── nodes.ts — keyed list LIS reorder branches ─────────────────────────────
|
|
2406
|
+
|
|
2407
|
+
describe("nodes.ts — keyed list LIS reorder", () => {
|
|
2408
|
+
test("mountFor LIS fallback — reverse with size change (lines 536-578)", () => {
|
|
2409
|
+
const el = document.createElement("div")
|
|
2410
|
+
document.body.appendChild(el)
|
|
2411
|
+
|
|
2412
|
+
type Item = { id: number; label: string }
|
|
2413
|
+
const items = signal<Item[]>([
|
|
2414
|
+
{ id: 1, label: "a" },
|
|
2415
|
+
{ id: 2, label: "b" },
|
|
2416
|
+
{ id: 3, label: "c" },
|
|
2417
|
+
{ id: 4, label: "d" },
|
|
2418
|
+
{ id: 5, label: "e" },
|
|
2419
|
+
{ id: 6, label: "f" },
|
|
2420
|
+
{ id: 7, label: "g" },
|
|
2421
|
+
{ id: 8, label: "h" },
|
|
2422
|
+
{ id: 9, label: "i" },
|
|
2423
|
+
{ id: 10, label: "j" },
|
|
2424
|
+
])
|
|
2425
|
+
|
|
2426
|
+
mount(
|
|
2427
|
+
h(For, {
|
|
2428
|
+
each: items,
|
|
2429
|
+
by: (item: Item) => item.id,
|
|
2430
|
+
children: (item: Item) => h("span", null, item.label),
|
|
2431
|
+
}),
|
|
2432
|
+
el,
|
|
2433
|
+
)
|
|
2434
|
+
expect(el.textContent).toBe("abcdefghij")
|
|
2435
|
+
|
|
2436
|
+
// Reverse + add new item = size change → hits LIS fallback (not small-k)
|
|
2437
|
+
items.set([
|
|
2438
|
+
{ id: 10, label: "j" },
|
|
2439
|
+
{ id: 9, label: "i" },
|
|
2440
|
+
{ id: 8, label: "h" },
|
|
2441
|
+
{ id: 7, label: "g" },
|
|
2442
|
+
{ id: 6, label: "f" },
|
|
2443
|
+
{ id: 5, label: "e" },
|
|
2444
|
+
{ id: 4, label: "d" },
|
|
2445
|
+
{ id: 3, label: "c" },
|
|
2446
|
+
{ id: 2, label: "b" },
|
|
2447
|
+
{ id: 1, label: "a" },
|
|
2448
|
+
{ id: 11, label: "k" },
|
|
2449
|
+
])
|
|
2450
|
+
expect(el.textContent).toBe("jihgfedcbak")
|
|
2451
|
+
|
|
2452
|
+
el.remove()
|
|
2453
|
+
})
|
|
2454
|
+
|
|
2455
|
+
test("mountFor smallKPlace — few items swapped (lines 609-646)", () => {
|
|
2456
|
+
const el = document.createElement("div")
|
|
2457
|
+
document.body.appendChild(el)
|
|
2458
|
+
|
|
2459
|
+
type Item = { id: number; label: string }
|
|
2460
|
+
const items = signal<Item[]>([
|
|
2461
|
+
{ id: 1, label: "a" },
|
|
2462
|
+
{ id: 2, label: "b" },
|
|
2463
|
+
{ id: 3, label: "c" },
|
|
2464
|
+
{ id: 4, label: "d" },
|
|
2465
|
+
])
|
|
2466
|
+
|
|
2467
|
+
mount(
|
|
2468
|
+
h(For, {
|
|
2469
|
+
each: items,
|
|
2470
|
+
by: (item: Item) => item.id,
|
|
2471
|
+
children: (item: Item) => h("span", null, item.label),
|
|
2472
|
+
}),
|
|
2473
|
+
el,
|
|
2474
|
+
)
|
|
2475
|
+
expect(el.textContent).toBe("abcd")
|
|
2476
|
+
|
|
2477
|
+
// Swap 2 items — triggers small-k path (≤ SMALL_K diffs)
|
|
2478
|
+
items.set([
|
|
2479
|
+
{ id: 1, label: "a" },
|
|
2480
|
+
{ id: 4, label: "d" },
|
|
2481
|
+
{ id: 3, label: "c" },
|
|
2482
|
+
{ id: 2, label: "b" },
|
|
2483
|
+
])
|
|
2484
|
+
expect(el.textContent).toBe("adcb")
|
|
2485
|
+
|
|
2486
|
+
el.remove()
|
|
2487
|
+
})
|
|
2488
|
+
})
|