a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 29 kB view raw
1import { mount } from "$core/binder"; 2import { signal } from "$core/signal"; 3import { describe, expect, it } from "vitest"; 4 5describe("data-volt-model binding", () => { 6 describe("text inputs", () => { 7 it("binds signal to text input value", () => { 8 const container = document.createElement("div"); 9 container.innerHTML = `<input type="text" data-volt-model="name" />`; 10 11 const name = signal("Alice"); 12 mount(container, { name }); 13 14 const input = container.querySelector("input")!; 15 expect(input.value).toBe("Alice"); 16 }); 17 18 it("updates input when signal changes", () => { 19 const container = document.createElement("div"); 20 container.innerHTML = `<input type="text" data-volt-model="message" />`; 21 22 const message = signal("Hello"); 23 mount(container, { message }); 24 25 const input = container.querySelector("input")!; 26 expect(input.value).toBe("Hello"); 27 28 message.set("World"); 29 expect(input.value).toBe("World"); 30 }); 31 32 it("updates signal when input changes", () => { 33 const container = document.createElement("div"); 34 container.innerHTML = `<input type="text" data-volt-model="text" />`; 35 36 const text = signal("initial"); 37 mount(container, { text }); 38 39 const input = container.querySelector("input")!; 40 input.value = "changed"; 41 input.dispatchEvent(new Event("input")); 42 43 expect(text.get()).toBe("changed"); 44 }); 45 46 it("handles bidirectional updates", () => { 47 const container = document.createElement("div"); 48 container.innerHTML = ` 49 <input data-volt-model="value" /> 50 <span data-volt-text="value"></span> 51 `; 52 53 const value = signal("test"); 54 mount(container, { value }); 55 56 const input = container.querySelector("input")!; 57 const span = container.querySelector("span")!; 58 59 expect(input.value).toBe("test"); 60 expect(span.textContent).toBe("test"); 61 62 input.value = "updated"; 63 input.dispatchEvent(new Event("input")); 64 65 expect(value.get()).toBe("updated"); 66 expect(span.textContent).toBe("updated"); 67 }); 68 }); 69 70 describe("number inputs", () => { 71 it("binds signal to number input", () => { 72 const container = document.createElement("div"); 73 container.innerHTML = `<input type="number" data-volt-model="count" />`; 74 75 const count = signal(42); 76 mount(container, { count }); 77 78 const input = container.querySelector("input")!; 79 expect(input.value).toBe("42"); 80 }); 81 82 it("updates signal with numeric value", () => { 83 const container = document.createElement("div"); 84 container.innerHTML = `<input type="number" data-volt-model="quantity" />`; 85 86 const quantity = signal(0); 87 mount(container, { quantity }); 88 89 const input = container.querySelector("input")!; 90 input.value = "10"; 91 input.dispatchEvent(new Event("input")); 92 93 expect(quantity.get()).toBe(10); 94 expect(typeof quantity.get()).toBe("number"); 95 }); 96 }); 97 98 describe("checkbox inputs", () => { 99 it("binds signal to checkbox checked state", () => { 100 const container = document.createElement("div"); 101 container.innerHTML = `<input type="checkbox" data-volt-model="agreed" />`; 102 103 const agreed = signal(true); 104 mount(container, { agreed }); 105 106 const checkbox = container.querySelector("input")!; 107 expect(checkbox.checked).toBe(true); 108 }); 109 110 it("updates checkbox when signal changes", () => { 111 const container = document.createElement("div"); 112 container.innerHTML = `<input type="checkbox" data-volt-model="enabled" />`; 113 114 const enabled = signal(false); 115 mount(container, { enabled }); 116 117 const checkbox = container.querySelector("input")!; 118 expect(checkbox.checked).toBe(false); 119 120 enabled.set(true); 121 expect(checkbox.checked).toBe(true); 122 }); 123 124 it("updates signal when checkbox is clicked", () => { 125 const container = document.createElement("div"); 126 container.innerHTML = `<input type="checkbox" data-volt-model="checked" />`; 127 128 const checked = signal(false); 129 mount(container, { checked }); 130 131 const checkbox = container.querySelector("input")!; 132 checkbox.checked = true; 133 checkbox.dispatchEvent(new Event("change")); 134 135 expect(checked.get()).toBe(true); 136 }); 137 }); 138 139 describe("radio buttons", () => { 140 it("binds signal to radio button selection", () => { 141 const container = document.createElement("div"); 142 container.innerHTML = ` 143 <input type="radio" name="choice" value="a" data-volt-model="selected" /> 144 <input type="radio" name="choice" value="b" data-volt-model="selected" /> 145 `; 146 147 const selected = signal("b"); 148 mount(container, { selected }); 149 150 const radios = container.querySelectorAll("input"); 151 expect(radios[0].checked).toBe(false); 152 expect(radios[1].checked).toBe(true); 153 }); 154 155 it("updates signal when radio is selected", () => { 156 const container = document.createElement("div"); 157 container.innerHTML = ` 158 <input type="radio" name="option" value="x" data-volt-model="choice" /> 159 <input type="radio" name="option" value="y" data-volt-model="choice" /> 160 `; 161 162 const choice = signal("x"); 163 mount(container, { choice }); 164 165 const radios = container.querySelectorAll("input"); 166 radios[1].checked = true; 167 radios[1].dispatchEvent(new Event("change")); 168 169 expect(choice.get()).toBe("y"); 170 }); 171 }); 172 173 describe("select elements", () => { 174 it("binds signal to select value", () => { 175 const container = document.createElement("div"); 176 container.innerHTML = ` 177 <select data-volt-model="color"> 178 <option value="red">Red</option> 179 <option value="blue">Blue</option> 180 </select> 181 `; 182 183 const color = signal("blue"); 184 mount(container, { color }); 185 186 const select = container.querySelector("select")!; 187 expect(select.value).toBe("blue"); 188 }); 189 190 it("updates select when signal changes", () => { 191 const container = document.createElement("div"); 192 container.innerHTML = ` 193 <select data-volt-model="size"> 194 <option value="s">Small</option> 195 <option value="m">Medium</option> 196 <option value="l">Large</option> 197 </select> 198 `; 199 200 const size = signal("s"); 201 mount(container, { size }); 202 203 const select = container.querySelector("select")!; 204 expect(select.value).toBe("s"); 205 206 size.set("l"); 207 expect(select.value).toBe("l"); 208 }); 209 210 it("updates signal when selection changes", () => { 211 const container = document.createElement("div"); 212 container.innerHTML = ` 213 <select data-volt-model="fruit"> 214 <option value="apple">Apple</option> 215 <option value="banana">Banana</option> 216 </select> 217 `; 218 219 const fruit = signal("apple"); 220 mount(container, { fruit }); 221 222 const select = container.querySelector("select")!; 223 select.value = "banana"; 224 select.dispatchEvent(new Event("input")); 225 226 expect(fruit.get()).toBe("banana"); 227 }); 228 }); 229 230 describe("textarea elements", () => { 231 it("binds signal to textarea value", () => { 232 const container = document.createElement("div"); 233 container.innerHTML = `<textarea data-volt-model="content"></textarea>`; 234 235 const content = signal("Hello World"); 236 mount(container, { content }); 237 238 const textarea = container.querySelector("textarea")!; 239 expect(textarea.value).toBe("Hello World"); 240 }); 241 242 it("updates textarea when signal changes", () => { 243 const container = document.createElement("div"); 244 container.innerHTML = `<textarea data-volt-model="notes"></textarea>`; 245 246 const notes = signal("Initial"); 247 mount(container, { notes }); 248 249 const textarea = container.querySelector("textarea")!; 250 expect(textarea.value).toBe("Initial"); 251 252 notes.set("Updated"); 253 expect(textarea.value).toBe("Updated"); 254 }); 255 256 it("updates signal when textarea changes", () => { 257 const container = document.createElement("div"); 258 container.innerHTML = `<textarea data-volt-model="message"></textarea>`; 259 260 const message = signal(""); 261 mount(container, { message }); 262 263 const textarea = container.querySelector("textarea")!; 264 textarea.value = "New content"; 265 textarea.dispatchEvent(new Event("input")); 266 267 expect(message.get()).toBe("New content"); 268 }); 269 }); 270}); 271 272describe("data-volt-bind:attr binding", () => { 273 describe("boolean attributes", () => { 274 it("binds disabled attribute", () => { 275 const container = document.createElement("div"); 276 container.innerHTML = `<button data-volt-bind:disabled="isDisabled">Click</button>`; 277 278 const isDisabled = signal(true); 279 mount(container, { isDisabled }); 280 281 const button = container.querySelector("button")!; 282 expect(button.hasAttribute("disabled")).toBe(true); 283 284 isDisabled.set(false); 285 expect(button.hasAttribute("disabled")).toBe(false); 286 }); 287 288 it("binds readonly attribute", () => { 289 const container = document.createElement("div"); 290 container.innerHTML = `<input data-volt-bind:readonly="locked" />`; 291 292 const locked = signal(false); 293 mount(container, { locked }); 294 295 const input = container.querySelector("input")!; 296 expect(input.hasAttribute("readonly")).toBe(false); 297 298 locked.set(true); 299 expect(input.hasAttribute("readonly")).toBe(true); 300 }); 301 302 it("binds checked attribute", () => { 303 const container = document.createElement("div"); 304 container.innerHTML = `<input type="checkbox" data-volt-bind:checked="isChecked" />`; 305 306 const isChecked = signal(true); 307 mount(container, { isChecked }); 308 309 const input = container.querySelector("input")!; 310 expect(input.hasAttribute("checked")).toBe(true); 311 }); 312 313 it("binds required attribute", () => { 314 const container = document.createElement("div"); 315 container.innerHTML = `<input data-volt-bind:required="mandatory" />`; 316 317 const mandatory = signal(true); 318 mount(container, { mandatory }); 319 320 const input = container.querySelector("input")!; 321 expect(input.hasAttribute("required")).toBe(true); 322 }); 323 }); 324 325 describe("string attributes", () => { 326 it("binds href attribute", () => { 327 const container = document.createElement("div"); 328 container.innerHTML = `<a data-volt-bind:href="url">Link</a>`; 329 330 const url = signal("https://example.com"); 331 mount(container, { url }); 332 333 const link = container.querySelector("a")!; 334 expect(link.getAttribute("href")).toBe("https://example.com"); 335 336 url.set("https://volt.js.org"); 337 expect(link.getAttribute("href")).toBe("https://volt.js.org"); 338 }); 339 340 it("binds src attribute", () => { 341 const container = document.createElement("div"); 342 container.innerHTML = `<img data-volt-bind:src="image" />`; 343 344 const image = signal("/placeholder.png"); 345 mount(container, { image }); 346 347 const img = container.querySelector("img")!; 348 expect(img.getAttribute("src")).toBe("/placeholder.png"); 349 }); 350 351 it("binds title attribute", () => { 352 const container = document.createElement("div"); 353 container.innerHTML = `<span data-volt-bind:title="tooltip">Hover me</span>`; 354 355 const tooltip = signal("Help text"); 356 mount(container, { tooltip }); 357 358 const span = container.querySelector("span")!; 359 expect(span.getAttribute("title")).toBe("Help text"); 360 }); 361 362 it("binds aria-label attribute", () => { 363 const container = document.createElement("div"); 364 container.innerHTML = `<button data-volt-bind:aria-label="label">Icon</button>`; 365 366 const label = signal("Close"); 367 mount(container, { label }); 368 369 const button = container.querySelector("button")!; 370 expect(button.getAttribute("aria-label")).toBe("Close"); 371 }); 372 }); 373 374 describe("dynamic values", () => { 375 it("updates attribute when expression changes", () => { 376 const container = document.createElement("div"); 377 container.innerHTML = `<a data-volt-bind:href="baseUrl">Link</a>`; 378 379 const baseUrl = signal("/page1"); 380 mount(container, { baseUrl }); 381 382 const link = container.querySelector("a")!; 383 expect(link.getAttribute("href")).toBe("/page1"); 384 385 baseUrl.set("/page2"); 386 expect(link.getAttribute("href")).toBe("/page2"); 387 }); 388 389 it("removes attribute when value is null/undefined/false", () => { 390 const container = document.createElement("div"); 391 container.innerHTML = `<div data-volt-bind:data-value="value">Content</div>`; 392 393 const value = signal("present"); 394 mount(container, { value }); 395 396 const div = container.children[0] as HTMLElement; 397 expect(div.dataset.value).toBe("present"); 398 399 // @ts-expect-error incorrect type is intentional 400 value.set(null); 401 expect(Object.hasOwn(div.dataset, "value")).toBe(false); 402 }); 403 404 it("handles expressions", () => { 405 const container = document.createElement("div"); 406 container.innerHTML = `<button data-volt-bind:disabled="count > 5">Submit</button>`; 407 408 const count = signal(3); 409 mount(container, { count }); 410 411 const button = container.querySelector("button")!; 412 expect(button.hasAttribute("disabled")).toBe(false); 413 414 count.set(10); 415 expect(button.hasAttribute("disabled")).toBe(true); 416 }); 417 }); 418}); 419 420describe("data-volt-else binding", () => { 421 it("shows else branch when if condition is false", () => { 422 const container = document.createElement("div"); 423 container.innerHTML = ` 424 <div> 425 <p data-volt-if="show">If content</p> 426 <p data-volt-else>Else content</p> 427 </div> 428 `; 429 430 const show = signal(false); 431 mount(container, { show }); 432 433 expect(container.textContent).toContain("Else content"); 434 expect(container.textContent).not.toContain("If content"); 435 }); 436 437 it("shows if branch when condition is true", () => { 438 const container = document.createElement("div"); 439 container.innerHTML = ` 440 <div> 441 <p data-volt-if="show">If content</p> 442 <p data-volt-else>Else content</p> 443 </div> 444 `; 445 446 const show = signal(true); 447 mount(container, { show }); 448 449 expect(container.textContent).toContain("If content"); 450 expect(container.textContent).not.toContain("Else content"); 451 }); 452 453 it("toggles between if and else branches", () => { 454 const container = document.createElement("div"); 455 container.innerHTML = ` 456 <div> 457 <span data-volt-if="visible">Visible</span> 458 <span data-volt-else>Hidden</span> 459 </div> 460 `; 461 462 const visible = signal(true); 463 mount(container, { visible }); 464 465 expect(container.textContent).toContain("Visible"); 466 expect(container.textContent).not.toContain("Hidden"); 467 468 visible.set(false); 469 expect(container.textContent).not.toContain("Visible"); 470 expect(container.textContent).toContain("Hidden"); 471 472 visible.set(true); 473 expect(container.textContent).toContain("Visible"); 474 expect(container.textContent).not.toContain("Hidden"); 475 }); 476 477 it("supports bindings in else branch", () => { 478 const container = document.createElement("div"); 479 container.innerHTML = ` 480 <div> 481 <p data-volt-if="show" data-volt-text="ifMessage"></p> 482 <p data-volt-else data-volt-text="elseMessage"></p> 483 </div> 484 `; 485 486 const show = signal(false); 487 const ifMessage = signal("If text"); 488 const elseMessage = signal("Else text"); 489 490 mount(container, { show, ifMessage, elseMessage }); 491 492 expect(container.querySelector("p")?.textContent).toBe("Else text"); 493 494 show.set(true); 495 expect(container.querySelector("p")?.textContent).toBe("If text"); 496 }); 497 498 it("maintains separate state for each branch", () => { 499 const container = document.createElement("div"); 500 container.innerHTML = ` 501 <div> 502 <div data-volt-if="mode"> 503 <span data-volt-text="ifValue"></span> 504 </div> 505 <div data-volt-else> 506 <span data-volt-text="elseValue"></span> 507 </div> 508 </div> 509 `; 510 511 const mode = signal(true); 512 const ifValue = signal("If"); 513 const elseValue = signal("Else"); 514 515 mount(container, { mode, ifValue, elseValue }); 516 517 expect(container.querySelector("span")?.textContent).toBe("If"); 518 519 mode.set(false); 520 expect(container.querySelector("span")?.textContent).toBe("Else"); 521 522 ifValue.set("Changed If"); 523 mode.set(true); 524 expect(container.querySelector("span")?.textContent).toBe("Changed If"); 525 }); 526 527 it("handles else without bindings", () => { 528 const container = document.createElement("div"); 529 container.innerHTML = ` 530 <div> 531 <p data-volt-if="condition">Condition true</p> 532 <p data-volt-else>No condition</p> 533 </div> 534 `; 535 536 const condition = signal(false); 537 mount(container, { condition }); 538 539 const paragraphs = container.querySelectorAll("p"); 540 expect(paragraphs).toHaveLength(1); 541 expect(paragraphs[0].textContent).toBe("No condition"); 542 }); 543 544 it("properly cleans up when switching branches", () => { 545 const container = document.createElement("div"); 546 container.innerHTML = ` 547 <div> 548 <div data-volt-if="branch"> 549 <span data-volt-text="message">If</span> 550 </div> 551 <div data-volt-else> 552 <span data-volt-text="message">Else</span> 553 </div> 554 </div> 555 `; 556 557 const branch = signal(true); 558 const message = signal("Test"); 559 560 const cleanup = mount(container, { branch, message }); 561 562 expect(container.querySelector("span")?.textContent).toBe("Test"); 563 564 message.set("Updated"); 565 expect(container.querySelector("span")?.textContent).toBe("Updated"); 566 567 branch.set(false); 568 expect(container.querySelector("span")?.textContent).toBe("Updated"); 569 570 cleanup(); 571 572 message.set("After cleanup"); 573 expect(container.querySelector("span")?.textContent).toBe("Updated"); 574 }); 575}); 576 577describe("nested property binding in data-volt-model", () => { 578 describe("single-level nesting", () => { 579 it("binds to nested property in signal with object value", () => { 580 const container = document.createElement("div"); 581 container.innerHTML = `<input type="text" data-volt-model="user.name" />`; 582 583 const user = signal({ name: "Alice", age: 30 }); 584 mount(container, { user }); 585 586 const input = container.querySelector("input")!; 587 expect(input.value).toBe("Alice"); 588 }); 589 590 it("updates input when nested property in signal changes", () => { 591 const container = document.createElement("div"); 592 container.innerHTML = `<input type="text" data-volt-model="person.email" />`; 593 594 const person = signal({ email: "alice@example.com", verified: false }); 595 mount(container, { person }); 596 597 const input = container.querySelector("input")!; 598 expect(input.value).toBe("alice@example.com"); 599 600 person.set({ email: "bob@example.com", verified: true }); 601 expect(input.value).toBe("bob@example.com"); 602 }); 603 604 it("updates nested property when input changes", () => { 605 const container = document.createElement("div"); 606 container.innerHTML = `<input type="text" data-volt-model="profile.username" />`; 607 608 const profile = signal({ username: "alice123", bio: "Developer" }); 609 mount(container, { profile }); 610 611 const input = container.querySelector("input")!; 612 input.value = "alice456"; 613 input.dispatchEvent(new Event("input")); 614 615 expect(profile.get().username).toBe("alice456"); 616 expect(profile.get().bio).toBe("Developer"); 617 }); 618 619 it("maintains immutability when updating nested properties", () => { 620 const container = document.createElement("div"); 621 container.innerHTML = `<input type="text" data-volt-model="data.value" />`; 622 623 const data = signal({ value: "original", other: "unchanged" }); 624 const originalObject = data.get(); 625 mount(container, { data }); 626 627 const input = container.querySelector("input")!; 628 input.value = "modified"; 629 input.dispatchEvent(new Event("input")); 630 631 const updatedObject = data.get(); 632 expect(updatedObject).not.toBe(originalObject); 633 expect(updatedObject.value).toBe("modified"); 634 expect(updatedObject.other).toBe("unchanged"); 635 expect(originalObject.value).toBe("original"); 636 }); 637 638 it("handles bidirectional updates with nested properties", () => { 639 const container = document.createElement("div"); 640 container.innerHTML = ` 641 <input data-volt-model="form.email" /> 642 <span data-volt-text="form.email"></span> 643 `; 644 645 const form = signal({ email: "test@example.com", name: "Test" }); 646 mount(container, { form }); 647 648 const input = container.querySelector("input")!; 649 const span = container.querySelector("span")!; 650 651 expect(input.value).toBe("test@example.com"); 652 expect(span.textContent).toBe("test@example.com"); 653 654 input.value = "updated@example.com"; 655 input.dispatchEvent(new Event("input")); 656 657 expect(form.get().email).toBe("updated@example.com"); 658 expect(span.textContent).toBe("updated@example.com"); 659 }); 660 }); 661 662 describe("multiple nested properties", () => { 663 it("binds multiple inputs to different nested properties", () => { 664 const container = document.createElement("div"); 665 container.innerHTML = ` 666 <input id="name" type="text" data-volt-model="formData.name" /> 667 <input id="email" type="email" data-volt-model="formData.email" /> 668 `; 669 670 const formData = signal({ name: "Alice", email: "alice@example.com" }); 671 mount(container, { formData }); 672 673 const nameInput = container.querySelector("#name")! as HTMLInputElement; 674 const emailInput = container.querySelector("#email")! as HTMLInputElement; 675 676 expect(nameInput.value).toBe("Alice"); 677 expect(emailInput.value).toBe("alice@example.com"); 678 679 nameInput.value = "Bob"; 680 nameInput.dispatchEvent(new Event("input")); 681 682 expect(formData.get().name).toBe("Bob"); 683 expect(formData.get().email).toBe("alice@example.com"); 684 685 emailInput.value = "bob@example.com"; 686 emailInput.dispatchEvent(new Event("input")); 687 688 expect(formData.get().name).toBe("Bob"); 689 expect(formData.get().email).toBe("bob@example.com"); 690 }); 691 }); 692 693 describe("different form element types", () => { 694 it("binds checkbox to nested boolean property", () => { 695 const container = document.createElement("div"); 696 container.innerHTML = `<input type="checkbox" data-volt-model="settings.enabled" />`; 697 698 const settings = signal({ enabled: true, theme: "dark" }); 699 mount(container, { settings }); 700 701 const checkbox = container.querySelector("input")!; 702 expect(checkbox.checked).toBe(true); 703 704 checkbox.checked = false; 705 checkbox.dispatchEvent(new Event("change")); 706 707 expect(settings.get().enabled).toBe(false); 708 expect(settings.get().theme).toBe("dark"); 709 }); 710 711 it("binds select to nested property", () => { 712 const container = document.createElement("div"); 713 container.innerHTML = ` 714 <select data-volt-model="preferences.color"> 715 <option value="red">Red</option> 716 <option value="blue">Blue</option> 717 <option value="green">Green</option> 718 </select> 719 `; 720 721 const preferences = signal({ color: "blue", size: "medium" }); 722 mount(container, { preferences }); 723 724 const select = container.querySelector("select")!; 725 expect(select.value).toBe("blue"); 726 727 select.value = "green"; 728 select.dispatchEvent(new Event("input")); 729 730 expect(preferences.get().color).toBe("green"); 731 expect(preferences.get().size).toBe("medium"); 732 }); 733 734 it("binds textarea to nested property", () => { 735 const container = document.createElement("div"); 736 container.innerHTML = `<textarea data-volt-model="post.content"></textarea>`; 737 738 const post = signal({ content: "Hello world", published: false }); 739 mount(container, { post }); 740 741 const textarea = container.querySelector("textarea")!; 742 expect(textarea.value).toBe("Hello world"); 743 744 textarea.value = "Updated content"; 745 textarea.dispatchEvent(new Event("input")); 746 747 expect(post.get().content).toBe("Updated content"); 748 expect(post.get().published).toBe(false); 749 }); 750 751 it("binds radio buttons to nested property", () => { 752 const container = document.createElement("div"); 753 container.innerHTML = ` 754 <input type="radio" name="plan" value="free" data-volt-model="subscription.plan" /> 755 <input type="radio" name="plan" value="pro" data-volt-model="subscription.plan" /> 756 <input type="radio" name="plan" value="enterprise" data-volt-model="subscription.plan" /> 757 `; 758 759 const subscription = signal({ plan: "pro", active: true }); 760 mount(container, { subscription }); 761 762 const radios = container.querySelectorAll("input"); 763 expect(radios[0].checked).toBe(false); 764 expect(radios[1].checked).toBe(true); 765 expect(radios[2].checked).toBe(false); 766 767 radios[2].checked = true; 768 radios[2].dispatchEvent(new Event("change")); 769 770 expect(subscription.get().plan).toBe("enterprise"); 771 expect(subscription.get().active).toBe(true); 772 }); 773 }); 774 775 describe("deep nesting", () => { 776 it("binds to deeply nested properties", () => { 777 const container = document.createElement("div"); 778 container.innerHTML = `<input type="text" data-volt-model="app.user.profile.displayName" />`; 779 780 const app = signal({ 781 user: { profile: { displayName: "Alice", avatar: "/avatar.png" }, settings: { theme: "dark" } }, 782 }); 783 mount(container, { app }); 784 785 const input = container.querySelector("input")!; 786 expect(input.value).toBe("Alice"); 787 788 input.value = "Bob"; 789 input.dispatchEvent(new Event("input")); 790 791 expect(app.get().user.profile.displayName).toBe("Bob"); 792 expect(app.get().user.profile.avatar).toBe("/avatar.png"); 793 expect(app.get().user.settings.theme).toBe("dark"); 794 }); 795 796 it("maintains immutability with deeply nested updates", () => { 797 const container = document.createElement("div"); 798 container.innerHTML = `<input type="text" data-volt-model="state.form.fields.email" />`; 799 800 const state = signal({ 801 form: { fields: { email: "test@example.com", name: "Test" }, meta: { submitted: false } }, 802 }); 803 804 const originalState = state.get(); 805 const originalForm = originalState.form; 806 const originalFields = originalState.form.fields; 807 808 mount(container, { state }); 809 810 const input = container.querySelector("input")!; 811 input.value = "new@example.com"; 812 input.dispatchEvent(new Event("input")); 813 814 const newState = state.get(); 815 const newForm = newState.form; 816 const newFields = newState.form.fields; 817 818 expect(newState).not.toBe(originalState); 819 expect(newForm).not.toBe(originalForm); 820 expect(newFields).not.toBe(originalFields); 821 822 expect(newFields.email).toBe("new@example.com"); 823 expect(newFields.name).toBe("Test"); 824 expect(newForm.meta.submitted).toBe(false); 825 826 expect(originalFields.email).toBe("test@example.com"); 827 }); 828 }); 829 830 describe("edge cases", () => { 831 it("handles undefined nested property gracefully", () => { 832 const container = document.createElement("div"); 833 container.innerHTML = `<input type="text" data-volt-model="obj.missing" />`; 834 835 const obj = signal({ present: "value" }); 836 mount(container, { obj }); 837 838 const input = container.querySelector("input")!; 839 expect(input.value).toBe(""); 840 }); 841 842 it("creates nested property path when updating undefined property", () => { 843 const container = document.createElement("div"); 844 container.innerHTML = `<input type="text" data-volt-model="data.newProp" />`; 845 846 const data = signal({ existingProp: "exists" }); 847 mount(container, { data }); 848 849 const input = container.querySelector("input")!; 850 input.value = "new value"; 851 input.dispatchEvent(new Event("input")); 852 853 // @ts-expect-error updating shape of data 854 expect(data.get().newProp).toBe("new value"); 855 expect(data.get().existingProp).toBe("exists"); 856 }); 857 858 it("works with modifiers on nested properties", () => { 859 const container = document.createElement("div"); 860 container.innerHTML = `<input type="text" data-volt-model-trim="form.username" />`; 861 862 const form = signal({ username: "", email: "" }); 863 mount(container, { form }); 864 865 const input = container.querySelector("input")!; 866 input.value = " alice "; 867 input.dispatchEvent(new Event("input")); 868 869 expect(form.get().username).toBe("alice"); 870 }); 871 872 it("works with number modifier on nested properties", () => { 873 const container = document.createElement("div"); 874 container.innerHTML = `<input type="text" data-volt-model-number="config.port" />`; 875 876 const config = signal({ port: 8080, host: "localhost" }); 877 mount(container, { config }); 878 879 const input = container.querySelector("input")!; 880 input.value = "3000"; 881 input.dispatchEvent(new Event("input")); 882 883 expect(config.get().port).toBe(3000); 884 expect(typeof config.get().port).toBe("number"); 885 }); 886 }); 887});