Precise DOM morphing
morphing typescript dom
at main 706 lines 22 kB view raw
1import { describe, it, expect, beforeEach, afterEach } from "vitest" 2import { morph, morphInner } from "../src/morphlex" 3 4describe("Morphlex - Coverage Tests", () => { 5 let container: HTMLElement 6 7 beforeEach(() => { 8 container = document.createElement("div") 9 document.body.appendChild(container) 10 }) 11 12 afterEach(() => { 13 if (container && container.parentNode) { 14 container.parentNode.removeChild(container) 15 } 16 }) 17 18 describe("String parsing error cases", () => { 19 it("should throw error when parseElementFromString receives non-element string", () => { 20 const div = document.createElement("div") 21 container.appendChild(div) 22 23 // Text node is not an element 24 expect(() => { 25 morphInner(div, "Just text") 26 }).toThrow("[Morphlex] The string was not a valid HTML element.") 27 }) 28 29 it("should parse multiple elements as valid HTML (they go into body)", () => { 30 const div = document.createElement("div") 31 container.appendChild(div) 32 33 // Multiple root nodes actually work because DOMParser wraps them in body 34 // This test just verifies the parsing works 35 const reference = "<div>Content</div>" 36 morph(div, reference) 37 expect(div.textContent).toBe("Content") 38 }) 39 40 it("should throw error when morphInner called with non-matching elements", () => { 41 const div = document.createElement("div") 42 const span = document.createElement("span") 43 container.appendChild(div) 44 45 expect(() => { 46 morphInner(div, span) 47 }).toThrow("[Morphlex] You can only do an inner morph with matching elements.") 48 }) 49 }) 50 51 describe("ariaBusy handling", () => { 52 it("should handle ariaBusy for non-element nodes", () => { 53 const parent = document.createElement("div") 54 const textNode = document.createTextNode("Original") 55 parent.appendChild(textNode) 56 57 const referenceParent = document.createElement("div") 58 const refTextNode = document.createTextNode("Updated") 59 referenceParent.appendChild(refTextNode) 60 61 morph(parent, referenceParent) 62 63 expect(parent.textContent).toBe("Updated") 64 }) 65 }) 66 67 describe("Property updates", () => { 68 it("should update input disabled property", () => { 69 const parent = document.createElement("div") 70 const input = document.createElement("input") 71 input.disabled = false 72 parent.appendChild(input) 73 74 const reference = document.createElement("div") 75 const refInput = document.createElement("input") 76 refInput.disabled = true 77 reference.appendChild(refInput) 78 79 morph(parent, reference) 80 81 expect(input.disabled).toBe(true) 82 }) 83 84 it("should not update file input value", () => { 85 const parent = document.createElement("div") 86 const input = document.createElement("input") 87 input.type = "file" 88 parent.appendChild(input) 89 90 const reference = document.createElement("div") 91 const refInput = document.createElement("input") 92 refInput.type = "file" 93 reference.appendChild(refInput) 94 95 morph(parent, reference) 96 97 expect(input.type).toBe("file") 98 }) 99 }) 100 101 describe("Head element special handling", () => { 102 it("should handle nested head elements in child morphing", () => { 103 const parent = document.createElement("div") 104 const head = document.createElement("head") 105 const meta1 = document.createElement("meta") 106 meta1.setAttribute("name", "test") 107 meta1.setAttribute("content", "value") 108 head.appendChild(meta1) 109 parent.appendChild(head) 110 111 const reference = document.createElement("div") 112 const refHead = document.createElement("head") 113 const refMeta = document.createElement("meta") 114 refMeta.setAttribute("name", "test") 115 refMeta.setAttribute("content", "updated") 116 refHead.appendChild(refMeta) 117 reference.appendChild(refHead) 118 119 morph(parent, reference) 120 121 expect(parent.querySelector("head")).toBeTruthy() 122 }) 123 }) 124 125 describe("Child element morphing edge cases", () => { 126 it("should handle ID matching with overlapping ID sets", () => { 127 const parent = document.createElement("div") 128 const child1 = document.createElement("div") 129 child1.id = "child1" 130 const nested = document.createElement("span") 131 nested.id = "nested" 132 child1.appendChild(nested) 133 134 const child2 = document.createElement("div") 135 child2.id = "child2" 136 137 parent.appendChild(child1) 138 parent.appendChild(child2) 139 140 const reference = document.createElement("div") 141 const refChild1 = document.createElement("div") 142 refChild1.id = "child1" 143 const refNested = document.createElement("span") 144 refNested.id = "different" 145 refChild1.appendChild(refNested) 146 147 const refChild2 = document.createElement("div") 148 refChild2.id = "child2" 149 150 reference.appendChild(refChild1) 151 reference.appendChild(refChild2) 152 153 morph(parent, reference) 154 155 expect(parent.children[0].id).toBe("child1") 156 }) 157 158 it("should insert new node when no match found and beforeNodeAdded returns true", () => { 159 const parent = document.createElement("div") 160 const existing = document.createElement("div") 161 existing.id = "existing" 162 parent.appendChild(existing) 163 164 const reference = document.createElement("div") 165 const refNew = document.createElement("div") 166 refNew.id = "new" 167 const refExisting = document.createElement("div") 168 refExisting.id = "existing" 169 reference.appendChild(refNew) 170 reference.appendChild(refExisting) 171 172 let addedNode: Node | null = null 173 morph(parent, reference, { 174 beforeNodeAdded: (node) => { 175 addedNode = node 176 return true 177 }, 178 }) 179 180 expect(addedNode).toBeTruthy() 181 expect(parent.children[0].id).toBe("new") 182 }) 183 184 it("should not insert new node when beforeNodeAdded returns false", () => { 185 const parent = document.createElement("div") 186 const existing = document.createElement("div") 187 existing.id = "existing" 188 existing.textContent = "original" 189 parent.appendChild(existing) 190 191 const reference = document.createElement("div") 192 const refNew = document.createElement("span") 193 refNew.id = "new" 194 refNew.textContent = "new content" 195 const refExisting = document.createElement("div") 196 refExisting.id = "existing" 197 refExisting.textContent = "updated" 198 reference.appendChild(refNew) 199 reference.appendChild(refExisting) 200 201 let addCallbackCalled = false 202 morph(parent, reference, { 203 beforeNodeAdded: () => { 204 addCallbackCalled = true 205 return false 206 }, 207 }) 208 209 // beforeNodeAdded should have been called 210 expect(addCallbackCalled).toBe(true) 211 // The existing div will be morphed to match reference 212 expect(parent.children[0].tagName).toBe("DIV") 213 }) 214 215 it("should call afterNodeVisited for child elements even when new node inserted", () => { 216 const parent = document.createElement("div") 217 const child = document.createElement("div") 218 child.id = "child" 219 parent.appendChild(child) 220 221 const reference = document.createElement("div") 222 const refChild = document.createElement("span") 223 refChild.id = "newChild" 224 reference.appendChild(refChild) 225 226 let morphedCalled = false 227 morph(parent, reference, { 228 afterNodeVisited: () => { 229 morphedCalled = true 230 }, 231 }) 232 233 expect(morphedCalled).toBe(true) 234 }) 235 }) 236 237 describe("Callback cancellation", () => { 238 it("should call beforeAttributeUpdated and cancel attribute removal when it returns false", () => { 239 const div = document.createElement("div") 240 div.setAttribute("data-keep", "value") 241 div.setAttribute("data-remove", "value") 242 243 const reference = document.createElement("div") 244 reference.setAttribute("data-keep", "value") 245 246 morph(div, reference, { 247 beforeAttributeUpdated: (element, name, value) => { 248 if (name === "data-remove" && value === null) { 249 return false // Cancel removal 250 } 251 return true 252 }, 253 }) 254 255 // Attribute should still be there because callback returned false 256 expect(div.hasAttribute("data-remove")).toBe(true) 257 }) 258 }) 259 260 describe("Empty ID handling", () => { 261 it("should ignore elements with empty id attribute", () => { 262 const parent = document.createElement("div") 263 const child1 = document.createElement("div") 264 child1.setAttribute("id", "") // Empty ID 265 child1.textContent = "First" 266 267 const child2 = document.createElement("div") 268 child2.id = "valid-id" 269 child2.textContent = "Second" 270 271 parent.appendChild(child1) 272 parent.appendChild(child2) 273 274 const reference = document.createElement("div") 275 const refChild1 = document.createElement("div") 276 refChild1.setAttribute("id", "") 277 refChild1.textContent = "First Updated" 278 279 const refChild2 = document.createElement("div") 280 refChild2.id = "valid-id" 281 refChild2.textContent = "Second Updated" 282 283 reference.appendChild(refChild1) 284 reference.appendChild(refChild2) 285 286 morph(parent, reference) 287 288 expect(child1.textContent).toBe("First Updated") 289 expect(child2.textContent).toBe("Second Updated") 290 }) 291 }) 292 293 describe("Complex morphing scenarios", () => { 294 it("should handle mixed content with sensitive and non-sensitive elements", () => { 295 const parent = document.createElement("div") 296 container.appendChild(parent) 297 298 const div = document.createElement("div") 299 div.id = "div1" 300 301 const input = document.createElement("input") 302 input.id = "input1" 303 input.value = "test" 304 input.defaultValue = "" 305 306 const canvas = document.createElement("canvas") 307 canvas.id = "canvas1" 308 309 parent.appendChild(div) 310 parent.appendChild(input) 311 parent.appendChild(canvas) 312 313 const reference = document.createElement("div") 314 315 const refCanvas = document.createElement("canvas") 316 refCanvas.id = "canvas1" 317 318 const refInput = document.createElement("input") 319 refInput.id = "input1" 320 321 const refDiv = document.createElement("div") 322 refDiv.id = "div1" 323 324 reference.appendChild(refCanvas) 325 reference.appendChild(refInput) 326 reference.appendChild(refDiv) 327 328 morph(parent, reference) 329 330 expect(parent.children.length).toBe(3) 331 }) 332 333 it("should match elements by overlapping ID sets", () => { 334 // Test lines 372-373 - matching by overlapping ID sets 335 const parent = document.createElement("div") 336 337 const outer1 = document.createElement("div") 338 outer1.id = "outer1" 339 const inner1a = document.createElement("span") 340 inner1a.id = "inner1a" 341 const inner1b = document.createElement("span") 342 inner1b.id = "inner1b" 343 outer1.appendChild(inner1a) 344 outer1.appendChild(inner1b) 345 346 const outer2 = document.createElement("div") 347 outer2.id = "outer2" 348 const inner2 = document.createElement("span") 349 inner2.id = "inner2" 350 outer2.appendChild(inner2) 351 352 parent.appendChild(outer1) 353 parent.appendChild(outer2) 354 355 const reference = document.createElement("div") 356 357 // Reference wants outer1 to come second, but references an inner ID 358 const refOuter2 = document.createElement("div") 359 refOuter2.id = "outer2" 360 const refInner2 = document.createElement("span") 361 refInner2.id = "inner2" 362 refOuter2.appendChild(refInner2) 363 364 const refOuter1 = document.createElement("div") 365 refOuter1.id = "outer1" 366 const refInner1a = document.createElement("span") 367 refInner1a.id = "inner1a" 368 refOuter1.appendChild(refInner1a) 369 370 reference.appendChild(refOuter2) 371 reference.appendChild(refOuter1) 372 373 morph(parent, reference) 374 375 expect(parent.children[0].id).toBe("outer2") 376 }) 377 378 it("should add completely new element when no match found by tag or ID", () => { 379 // Test lines 386-389 - adding new node with callbacks 380 const parent = document.createElement("div") 381 const existing = document.createElement("div") 382 existing.id = "existing" 383 parent.appendChild(existing) 384 385 const reference = document.createElement("div") 386 const refNew = document.createElement("article") 387 refNew.id = "brand-new" 388 refNew.textContent = "New content" 389 const refExisting = document.createElement("div") 390 refExisting.id = "existing" 391 reference.appendChild(refNew) 392 reference.appendChild(refExisting) 393 394 let addedNode: Node | null = null 395 let afterAddedCalled = false 396 morph(parent, reference, { 397 beforeNodeAdded: (node) => { 398 addedNode = node 399 return true 400 }, 401 afterNodeAdded: () => { 402 afterAddedCalled = true 403 }, 404 }) 405 406 expect(addedNode).toBeTruthy() 407 expect(afterAddedCalled).toBe(true) 408 expect(parent.children[0].tagName).toBe("ARTICLE") 409 }) 410 411 describe("DOMParser edge cases", () => { 412 it("should explore parser behavior to trigger line 74", () => { 413 // Line 74 checks if doc.childNodes.length === 1 414 // This is checking the document's childNodes, not body's childNodes 415 // DOMParser always returns a document with html element as child 416 // So doc.childNodes.length is always 1 (the html element) 417 // The else branch on line 74 appears to be unreachable in normal usage 418 419 // Let's verify with actual morph call 420 const parent = document.createElement("div") 421 const div = document.createElement("div") 422 div.textContent = "Original" 423 parent.appendChild(div) 424 425 // This should work fine 426 morph(div, "<span>Test</span>") 427 expect(parent.firstChild?.textContent).toBe("Test") 428 }) 429 }) 430 431 describe("Additional edge cases for remaining coverage", () => { 432 it("should handle element matching with nested IDs and no direct ID match", () => { 433 // More specific test for lines 372-373 434 const parent = document.createElement("div") 435 436 const container1 = document.createElement("section") 437 const child1a = document.createElement("div") 438 child1a.id = "shared-id-a" 439 const child1b = document.createElement("div") 440 child1b.id = "shared-id-b" 441 container1.appendChild(child1a) 442 container1.appendChild(child1b) 443 444 const container2 = document.createElement("section") 445 const child2 = document.createElement("div") 446 child2.id = "other-id" 447 container2.appendChild(child2) 448 449 parent.appendChild(container1) 450 parent.appendChild(container2) 451 452 const reference = document.createElement("div") 453 const refContainer = document.createElement("section") 454 const refChild = document.createElement("div") 455 refChild.id = "shared-id-a" 456 refContainer.appendChild(refChild) 457 458 reference.appendChild(refContainer) 459 460 morph(parent, reference) 461 462 expect(parent.children.length).toBeGreaterThanOrEqual(1) 463 }) 464 465 it("should insert node before when no ID or tag match exists", () => { 466 // Test for lines 386-389 with different scenario 467 const parent = document.createElement("div") 468 const oldChild = document.createElement("p") 469 oldChild.textContent = "Old" 470 parent.appendChild(oldChild) 471 472 const reference = document.createElement("div") 473 const newChild = document.createElement("article") 474 newChild.textContent = "New" 475 reference.appendChild(newChild) 476 477 let beforeAddCalled = false 478 let afterAddCalled = false 479 480 morph(parent, reference, { 481 beforeNodeAdded: () => { 482 beforeAddCalled = true 483 return true 484 }, 485 afterNodeAdded: () => { 486 afterAddCalled = true 487 }, 488 }) 489 490 expect(beforeAddCalled).toBe(true) 491 expect(afterAddCalled).toBe(true) 492 }) 493 494 it("should match by overlapping ID sets in sibling scan - lines 372-373", () => { 495 // Lines 372-373: Match element by overlapping ID sets when ID doesn't match 496 // This requires: currentNode has ID != reference.id, but has nested IDs that overlap 497 const parent = document.createElement("div") 498 499 // First child with nested IDs 500 const div1 = document.createElement("div") 501 div1.id = "div1" 502 const nested1 = document.createElement("span") 503 nested1.id = "overlap-id" 504 div1.appendChild(nested1) 505 506 // Second child that's a match by tag name 507 const div2 = document.createElement("div") 508 div2.id = "div2" 509 510 parent.appendChild(div1) 511 parent.appendChild(div2) 512 513 // Reference wants div2 first, but references the overlap-id 514 const reference = document.createElement("div") 515 const refDiv = document.createElement("div") 516 refDiv.id = "target" 517 const refNested = document.createElement("span") 518 refNested.id = "overlap-id" // This ID exists nested in div1 519 refDiv.appendChild(refNested) 520 reference.appendChild(refDiv) 521 522 morph(parent, reference) 523 524 expect(parent.children.length).toBeGreaterThanOrEqual(1) 525 }) 526 527 it("should add new node when no tag match exists - lines 386-389", () => { 528 // Lines 386-389: No nextMatchByTagName, so add new node 529 // This requires the reference child has a tag that doesn't exist in current children 530 const parent = document.createElement("div") 531 const p = document.createElement("p") 532 p.textContent = "Paragraph" 533 parent.appendChild(p) 534 535 const reference = document.createElement("div") 536 // Use article tag which doesn't exist in parent 537 const article = document.createElement("article") 538 article.textContent = "Article" 539 const refP = document.createElement("p") 540 reference.appendChild(article) 541 reference.appendChild(refP) 542 543 let addedNode: Node | null = null 544 morph(parent, reference, { 545 beforeNodeAdded: (node) => { 546 addedNode = node 547 return true 548 }, 549 afterNodeAdded: () => { 550 // Lines 388-389 551 }, 552 }) 553 554 expect(addedNode).toBeTruthy() 555 expect(parent.querySelector("article")).toBeTruthy() 556 }) 557 558 it("should trigger line 74 - unreachable error path in parseChildNodeFromString", () => { 559 // Line 74: else throw new Error("[Morphlex] The string was not a valid HTML node."); 560 // This line is actually unreachable because DOMParser always returns doc with childNodes.length === 1 561 // However, we can document this as a known unreachable path 562 // The parser always creates: doc -> html -> (head + body) 563 // So doc.childNodes.length is always 1 (the html element) 564 565 // All valid HTML strings will pass the check on line 72 566 const parent = document.createElement("div") 567 const child = document.createElement("div") 568 parent.appendChild(child) 569 570 // Even empty string parses to valid document structure 571 morph(child, "<p>Test</p>") 572 expect(parent.querySelector("p")?.textContent).toBe("Test") 573 }) 574 575 it("should handle case where child exists but refChild doesn't in loop - lines 332-333", () => { 576 // Lines 332-333 are actually unreachable in the for loop 577 // because we iterate up to refChildNodes.length, so refChild will always exist 578 // The cleanup happens in the while loop below (lines 338-341) 579 // This test documents that lines 332-333 appear to be dead code 580 const parent = document.createElement("div") 581 const child1 = document.createElement("span") 582 child1.textContent = "Keep" 583 const child2 = document.createElement("span") 584 child2.textContent = "Remove via while loop" 585 parent.appendChild(child1) 586 parent.appendChild(child2) 587 588 const reference = document.createElement("div") 589 const refChild = document.createElement("span") 590 refChild.textContent = "Keep Updated" 591 reference.appendChild(refChild) 592 593 morph(parent, reference) 594 595 expect(parent.children.length).toBe(1) 596 }) 597 598 it("should add new node when no ID or tag match exists - lines 386-389", () => { 599 // Lines 386-389 require: no nextMatchByTagName AND beforeNodeAdded returns true 600 // This means the first child must not match by tag, and no sibling matches either 601 const parent = document.createElement("div") 602 // Use a tag that won't match 603 const article = document.createElement("article") 604 article.id = "article1" 605 article.textContent = "Article" 606 parent.appendChild(article) 607 608 const reference = document.createElement("div") 609 // First child is a section (different tag), and has no ID match in siblings 610 const section = document.createElement("section") 611 section.id = "section1" 612 section.textContent = "Section" 613 // Second child to trigger morphChildElement for the article 614 const refArticle = document.createElement("article") 615 refArticle.id = "article1" 616 reference.appendChild(section) 617 reference.appendChild(refArticle) 618 619 let beforeCalled = false 620 let afterCalled = false 621 622 morph(parent, reference, { 623 beforeNodeAdded: () => { 624 beforeCalled = true 625 return true // Line 387: insertBefore is called 626 }, 627 afterNodeAdded: () => { 628 afterCalled = true // Line 389 629 }, 630 }) 631 632 expect(beforeCalled).toBe(true) 633 expect(afterCalled).toBe(true) 634 }) 635 636 describe("Exact uncovered line tests", () => { 637 it("should cancel morphing with beforeNodeVisited returning false in morphChildElement - line 300", () => { 638 // Line 300: return early when beforeNodeMorphed returns false in morphChildElement 639 const parent = document.createElement("div") 640 const child = document.createElement("div") 641 child.id = "child" 642 parent.appendChild(child) 643 644 const reference = document.createElement("div") 645 const refChild = document.createElement("div") 646 refChild.id = "child" 647 refChild.textContent = "updated" 648 reference.appendChild(refChild) 649 650 let callbackInvoked = false 651 morph(parent, reference, { 652 beforeNodeVisited: (node) => { 653 if (node === child) { 654 callbackInvoked = true 655 return false // This triggers line 300 return 656 } 657 return true 658 }, 659 }) 660 661 expect(callbackInvoked).toBe(true) 662 // Child should not be updated because callback returned false 663 expect(child.textContent).toBe("") 664 }) 665 666 it("should add completely new element type with no matches - lines 386-389", () => { 667 // Lines 386-389: else branch where no nextMatchByTagName exists 668 // Need a reference child with a tag that doesn't exist anywhere in parent 669 const parent = document.createElement("div") 670 const p = document.createElement("p") 671 p.textContent = "paragraph" 672 parent.appendChild(p) 673 674 const reference = document.createElement("div") 675 // Use custom element or uncommon tag 676 const custom = document.createElement("custom-element") 677 custom.textContent = "custom" 678 const refP = document.createElement("p") 679 reference.appendChild(custom) 680 reference.appendChild(refP) 681 682 let beforeCalled = false 683 let afterCalled = false 684 685 morph(parent, reference, { 686 beforeNodeAdded: (_parent, node, _insertionPoint) => { 687 if ((node as Element).tagName === "CUSTOM-ELEMENT") { 688 beforeCalled = true 689 return true // Line 387-388 690 } 691 return true 692 }, 693 afterNodeAdded: (node) => { 694 if ((node as Element).tagName === "CUSTOM-ELEMENT") { 695 afterCalled = true // Line 389 696 } 697 }, 698 }) 699 700 expect(beforeCalled).toBe(true) 701 expect(afterCalled).toBe(true) 702 }) 703 }) 704 }) 705 }) 706})