Precise DOM morphing
morphing
typescript
dom
1import { describe, it, expect, vi } from "vitest"
2import { morph, morphInner } from "../src/morphlex"
3
4describe("Morphlex - Remaining Uncovered Lines", () => {
5 describe("Invalid HTML string error (line 39)", () => {
6 it("should verify the error is thrown with correct message and stack trace", () => {
7 const div = document.createElement("div")
8 div.innerHTML = "<span>Test</span>"
9 document.body.appendChild(div)
10
11 // Verify the error is actually thrown from the correct line
12 try {
13 morphInner(div.firstChild!, "<p>First</p><p>Second</p>")
14 expect.fail("Should have thrown an error")
15 } catch (e: any) {
16 expect(e.message).toBe("[Morphlex] The string was not a valid HTML element.")
17 // The error should be thrown from morphInner function
18 expect(e.stack).toContain("morphInner")
19 }
20
21 div.remove()
22 })
23
24 it("should throw error when string contains multiple root elements", () => {
25 const div = document.createElement("div")
26 div.innerHTML = "<span>Test</span>"
27 document.body.appendChild(div)
28
29 // String with multiple root elements should throw when using morphInner
30 expect(() => {
31 morphInner(div.firstChild!, "<p>First</p><p>Second</p>")
32 }).toThrow("[Morphlex] The string was not a valid HTML element.")
33
34 div.remove()
35 })
36
37 it("should throw error when string contains only text content", () => {
38 const div = document.createElement("div")
39 div.innerHTML = "<span>Test</span>"
40 document.body.appendChild(div)
41
42 // String with only text (no element) should throw
43 expect(() => {
44 morphInner(div.firstChild!, "Just plain text")
45 }).toThrow("[Morphlex] The string was not a valid HTML element.")
46
47 div.remove()
48 })
49
50 it("should throw error when string contains comment only", () => {
51 const div = document.createElement("div")
52 div.innerHTML = "<span>Test</span>"
53 document.body.appendChild(div)
54
55 // String with only a comment should throw
56 expect(() => {
57 morphInner(div.firstChild!, "<!-- just a comment -->")
58 }).toThrow("[Morphlex] The string was not a valid HTML element.")
59
60 div.remove()
61 })
62
63 it("should throw error when string is empty", () => {
64 const div = document.createElement("div")
65 div.innerHTML = "<span>Test</span>"
66 document.body.appendChild(div)
67
68 // Empty string should throw
69 expect(() => {
70 morphInner(div.firstChild!, "")
71 }).toThrow("[Morphlex] The string was not a valid HTML element.")
72
73 div.remove()
74 })
75
76 it("should throw error when string contains whitespace only", () => {
77 const div = document.createElement("div")
78 div.innerHTML = "<span>Test</span>"
79 document.body.appendChild(div)
80
81 // Whitespace-only string should throw
82 expect(() => {
83 morphInner(div.firstChild!, " \n\t ")
84 }).toThrow("[Morphlex] The string was not a valid HTML element.")
85
86 div.remove()
87 })
88
89 it("should throw error when morphInner receives string with text and element", () => {
90 const div = document.createElement("div")
91 div.innerHTML = "<span>Test</span>"
92 document.body.appendChild(div)
93
94 // String with text before element
95 expect(() => {
96 morphInner(div.firstChild!, "text before <div>element</div>")
97 }).toThrow("[Morphlex] The string was not a valid HTML element.")
98
99 div.remove()
100 })
101 })
102
103 describe("morphOneToMany with empty array (lines 116-125)", () => {
104 it("should remove node when morphing to empty NodeList", () => {
105 const parent = document.createElement("div")
106 const child = document.createElement("span")
107 child.textContent = "Will be removed"
108 parent.appendChild(child)
109 document.body.appendChild(parent)
110
111 // Create an empty NodeList by parsing empty content
112 const template = document.createElement("template")
113 const emptyNodeList = template.content.childNodes
114
115 // Morph the child to empty NodeList
116 morph(child, emptyNodeList)
117
118 // Child should be removed from parent
119 expect(parent.children.length).toBe(0)
120 expect(parent.contains(child)).toBe(false)
121
122 parent.remove()
123 })
124
125 it("should remove node when morphing to empty string parsed as NodeList", () => {
126 const parent = document.createElement("div")
127 const element = document.createElement("p")
128 element.id = "test-element"
129 element.textContent = "Original"
130 parent.appendChild(element)
131 document.body.appendChild(parent)
132
133 // Morph to empty string (gets parsed to empty NodeList)
134 morph(element, "")
135
136 // Element should be removed
137 expect(parent.querySelector("#test-element")).toBe(null)
138 expect(parent.children.length).toBe(0)
139
140 parent.remove()
141 })
142
143 it("should call beforeNodeRemoved/afterNodeRemoved when removing via empty NodeList", () => {
144 const parent = document.createElement("div")
145 const child = document.createElement("span")
146 child.id = "to-remove"
147 parent.appendChild(child)
148 document.body.appendChild(parent)
149
150 let beforeRemoveCalled = false
151 let afterRemoveCalled = false
152 let removedNode: Node | null = null
153
154 // Create empty NodeList
155 const template = document.createElement("template")
156 const emptyNodeList = template.content.childNodes
157
158 // Morph with callbacks
159 morph(child, emptyNodeList, {
160 beforeNodeRemoved: (node) => {
161 beforeRemoveCalled = true
162 removedNode = node
163 return true
164 },
165 afterNodeRemoved: (node) => {
166 afterRemoveCalled = true
167 expect(node).toBe(removedNode)
168 },
169 })
170
171 expect(beforeRemoveCalled).toBe(true)
172 expect(afterRemoveCalled).toBe(true)
173 expect(removedNode).toBe(child)
174 expect(parent.children.length).toBe(0)
175
176 parent.remove()
177 })
178
179 it("should not remove node when beforeNodeRemoved returns false", () => {
180 const parent = document.createElement("div")
181 const child = document.createElement("span")
182 child.textContent = "Should not be removed"
183 parent.appendChild(child)
184 document.body.appendChild(parent)
185
186 // Create empty NodeList
187 const template = document.createElement("template")
188 const emptyNodeList = template.content.childNodes
189
190 // Morph with beforeNodeRemoved returning false
191 morph(child, emptyNodeList, {
192 beforeNodeRemoved: () => false,
193 })
194
195 // Child should still be in parent
196 expect(parent.children.length).toBe(1)
197 expect(parent.contains(child)).toBe(true)
198
199 parent.remove()
200 })
201
202 it("should morph one element to multiple elements from string", () => {
203 const parent = document.createElement("div")
204 const single = document.createElement("span")
205 single.id = "single"
206 single.textContent = "Single"
207 parent.appendChild(single)
208 document.body.appendChild(parent)
209
210 // Morph single element to multiple elements using a string
211 morph(single, "<span id='first'>First</span><span id='second'>Second</span><span id='third'>Third</span>")
212
213 // Should have morphed the first element and added the rest
214 expect(parent.children.length).toBe(3)
215 expect(parent.children[0].id).toBe("first")
216 expect(parent.children[0].textContent).toBe("First")
217 expect(parent.children[1].id).toBe("second")
218 expect(parent.children[2].id).toBe("third")
219
220 parent.remove()
221 })
222
223 it("should call callbacks when morphing one to many", () => {
224 const parent = document.createElement("div")
225 const single = document.createElement("span")
226 single.textContent = "Single"
227 parent.appendChild(single)
228 document.body.appendChild(parent)
229
230 const addedNodes: Node[] = []
231 let morphedCalled = false
232
233 morph(single, "<span>First</span><span>Second</span>", {
234 beforeNodeAdded: (_node) => {
235 return true // Allow addition
236 },
237 afterNodeAdded: (node) => {
238 addedNodes.push(node)
239 },
240 afterNodeVisited: (_from, _to) => {
241 morphedCalled = true
242 // The 'from' could be the original single element or its child nodes after morphing
243 // Just verify the callback was called
244 },
245 })
246
247 expect(morphedCalled).toBe(true)
248 expect(addedNodes.length).toBe(1) // Only second span was added, first was morphed
249 expect(parent.children.length).toBe(2)
250
251 parent.remove()
252 })
253
254 it("should prevent adding nodes when beforeNodeAdded returns false", () => {
255 const parent = document.createElement("div")
256 const single = document.createElement("span")
257 single.id = "original"
258 single.textContent = "Original"
259 parent.appendChild(single)
260 document.body.appendChild(parent)
261
262 morph(single, "<span id='first'>First</span><span id='second'>Second</span>", {
263 beforeNodeAdded: () => false, // Prevent all additions
264 })
265
266 // Only the first element should be morphed, second should not be added
267 expect(parent.children.length).toBe(1)
268 expect(parent.children[0].id).toBe("first") // First was morphed
269 expect(parent.children[0].textContent).toBe("First")
270
271 parent.remove()
272 })
273
274 it("should handle morphing to single text node", () => {
275 const parent = document.createElement("div")
276 const element = document.createElement("span")
277 element.textContent = "Element"
278 parent.appendChild(element)
279 document.body.appendChild(parent)
280
281 // Morph to just text content (which creates a text node in NodeList)
282 morph(element, "Just text")
283
284 // Element should be replaced with text node
285 expect(parent.children.length).toBe(0) // No elements
286 expect(parent.textContent).toBe("Just text")
287
288 parent.remove()
289 })
290 })
291
292 describe("moveBefore API usage (line 66)", () => {
293 it("should use moveBefore when available and node is in same parent", () => {
294 const parent = document.createElement("div")
295 const child1 = document.createElement("span")
296 child1.id = "first"
297 const child2 = document.createElement("span")
298 child2.id = "second"
299
300 parent.appendChild(child1)
301 parent.appendChild(child2)
302 document.body.appendChild(parent)
303
304 // Mock moveBefore if it doesn't exist, to test the condition
305 const originalMoveBefore = (parent as any).moveBefore
306 if (!("moveBefore" in parent)) {
307 // Add a mock moveBefore to test the branch
308 ;(parent as any).moveBefore = vi.fn((node: Node, before: Node | null) => {
309 // Simulate moveBefore behavior
310 if (node.parentNode === parent) {
311 parent.insertBefore(node, before)
312 }
313 })
314 }
315
316 // Morph to reverse order - should trigger moveBefore if available
317 morph(parent, '<div><span id="second"></span><span id="first"></span></div>')
318
319 // Check order is reversed
320 expect(parent.children[0].id).toBe("second")
321 expect(parent.children[1].id).toBe("first")
322
323 // Restore original moveBefore (if it existed)
324 if (originalMoveBefore === undefined) {
325 delete (parent as any).moveBefore
326 } else {
327 ;(parent as any).moveBefore = originalMoveBefore
328 }
329
330 parent.remove()
331 })
332
333 it("should fall back to insertBefore when moveBefore is not available", () => {
334 const parent = document.createElement("div")
335 const child1 = document.createElement("span")
336 child1.id = "a"
337 const child2 = document.createElement("span")
338 child2.id = "b"
339
340 parent.appendChild(child1)
341 parent.appendChild(child2)
342 document.body.appendChild(parent)
343
344 // Ensure moveBefore is not available
345 const originalMoveBefore = (parent as any).moveBefore
346 if ("moveBefore" in parent) {
347 delete (parent as any).moveBefore
348 }
349
350 // Morph to reverse order - should use insertBefore fallback
351 morph(parent, '<div><span id="b"></span><span id="a"></span></div>')
352
353 // Check order is reversed
354 expect(parent.children[0].id).toBe("b")
355 expect(parent.children[1].id).toBe("a")
356
357 // Restore original moveBefore if it existed
358 if (originalMoveBefore !== undefined) {
359 ;(parent as any).moveBefore = originalMoveBefore
360 }
361
362 parent.remove()
363 })
364
365 it("should use insertBefore when node is not already in the same parent", () => {
366 const parent1 = document.createElement("div")
367 const parent2 = document.createElement("div")
368 const child = document.createElement("span")
369 child.id = "movable"
370 child.textContent = "Move me"
371
372 parent2.appendChild(child)
373 document.body.appendChild(parent1)
374 document.body.appendChild(parent2)
375
376 // Add mock moveBefore to parent1
377 let moveBeforeCalled = false
378 const originalMoveBefore = (parent1 as any).moveBefore
379 if (!("moveBefore" in parent1)) {
380 ;(parent1 as any).moveBefore = vi.fn(() => {
381 moveBeforeCalled = true
382 })
383 }
384
385 // Create a reference element in parent1
386 const reference = document.createElement("span")
387 reference.id = "ref"
388 parent1.appendChild(reference)
389
390 // Morph parent1 to include the child from parent2
391 // The child with id="movable" will be found in parent2 and moved to parent1
392 morph(parent1, '<div><span id="movable">Move me</span><span id="ref"></span></div>')
393
394 // moveBefore should NOT be called since node was in different parent
395 expect(moveBeforeCalled).toBe(false)
396
397 // Child should now be in parent1
398 expect(parent1.querySelector("#movable")).toBeTruthy()
399
400 // Restore original moveBefore
401 if (originalMoveBefore === undefined) {
402 delete (parent1 as any).moveBefore
403 } else {
404 ;(parent1 as any).moveBefore = originalMoveBefore
405 }
406
407 parent1.remove()
408 parent2.remove()
409 })
410 })
411})