a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 468 lines 13 kB view raw
1import { charge } from "$core/charge"; 2import type { Signal } from "$types/volt"; 3import { afterEach, beforeEach, describe, expect, it } from "vitest"; 4 5describe("charge", () => { 6 let container: HTMLDivElement; 7 8 beforeEach(() => { 9 container = document.createElement("div"); 10 document.body.append(container); 11 }); 12 13 afterEach(() => { 14 container.remove(); 15 }); 16 17 describe("basic charging", () => { 18 it("discovers and mounts elements with data-volt attribute", () => { 19 container.innerHTML = `<div data-volt id="root1"></div>`; 20 21 const result = charge(); 22 23 expect(result.roots).toHaveLength(1); 24 expect(result.roots[0].element).toBe(container.querySelector("#root1")); 25 expect(typeof result.cleanup).toBe("function"); 26 27 result.cleanup(); 28 }); 29 30 it("mounts multiple roots", () => { 31 container.innerHTML = ` 32 <div data-volt id="root1"></div> 33 <div data-volt id="root2"></div> 34 <div data-volt id="root3"></div> 35 `; 36 37 const result = charge(); 38 39 expect(result.roots).toHaveLength(3); 40 result.cleanup(); 41 }); 42 43 it("accepts custom selector", () => { 44 container.innerHTML = ` 45 <div data-volt id="root1"></div> 46 <div class="custom" id="root2"></div> 47 `; 48 49 const result = charge(".custom"); 50 51 expect(result.roots).toHaveLength(1); 52 expect(result.roots[0].element.id).toBe("root2"); 53 result.cleanup(); 54 }); 55 56 it("returns empty array when no roots found", () => { 57 container.innerHTML = `<div id="noRoots"></div>`; 58 59 const result = charge(); 60 61 expect(result.roots).toHaveLength(0); 62 result.cleanup(); 63 }); 64 }); 65 66 describe("data-volt-state parsing", () => { 67 it("creates signals from data-volt-state JSON", () => { 68 container.innerHTML = ` 69 <div data-volt data-volt-state='{"count": 0, "message": "hello"}'> 70 <span data-volt-text="count"></span> 71 <span data-volt-text="message"></span> 72 </div> 73 `; 74 75 const result = charge(); 76 const root = container.querySelector("div[data-volt]")!; 77 const spans = root.querySelectorAll("span"); 78 79 expect(spans[0].textContent).toBe("0"); 80 expect(spans[1].textContent).toBe("hello"); 81 82 result.cleanup(); 83 }); 84 85 it("creates signals for nested objects", () => { 86 container.innerHTML = ` 87 <div data-volt data-volt-state='{"user": {"name": "Alice", "age": 30}}'> 88 <span data-volt-text="user.name"></span> 89 <span data-volt-text="user.age"></span> 90 </div> 91 `; 92 93 const result = charge(); 94 const spans = container.querySelectorAll("span"); 95 96 expect(spans[0].textContent).toBe("Alice"); 97 expect(spans[1].textContent).toBe("30"); 98 99 result.cleanup(); 100 }); 101 102 it("creates reactive signals that can be updated", () => { 103 container.innerHTML = ` 104 <div data-volt data-volt-state='{"count": 0}'> 105 <span data-volt-text="count"></span> 106 </div> 107 `; 108 109 const result = charge(); 110 const span = container.querySelector("span")!; 111 const scope = result.roots[0].scope; 112 113 expect(span.textContent).toBe("0"); 114 115 (scope.count as Signal<number>).set(5); 116 expect(span.textContent).toBe("5"); 117 118 (scope.count as Signal<number>).set(10); 119 expect(span.textContent).toBe("10"); 120 121 result.cleanup(); 122 }); 123 124 it("handles arrays in state", () => { 125 container.innerHTML = ` 126 <div data-volt data-volt-state='{"items": ["a", "b", "c"]}'> 127 <ul> 128 <li data-volt-for="item in items" data-volt-text="item"></li> 129 </ul> 130 </div> 131 `; 132 133 const result = charge(); 134 const items = container.querySelectorAll("li"); 135 136 expect(items).toHaveLength(3); 137 expect(items[0].textContent).toBe("a"); 138 expect(items[1].textContent).toBe("b"); 139 expect(items[2].textContent).toBe("c"); 140 141 result.cleanup(); 142 }); 143 144 it("handles empty state object", () => { 145 container.innerHTML = `<div data-volt data-volt-state='{}'>Content</div>`; 146 147 const result = charge(); 148 149 expect(result.roots).toHaveLength(1); 150 151 const scope = result.roots[0].scope; 152 expect(scope.$store).toBeDefined(); 153 expect(scope.$arc).toBeDefined(); 154 expect(scope.$origin).toBeDefined(); 155 expect(scope.$pins).toBeDefined(); 156 expect(scope.$probe).toBeDefined(); 157 expect(scope.$pulse).toBeDefined(); 158 expect(scope.$scope).toBe(scope); 159 expect(scope.$uid).toBeDefined(); 160 161 result.cleanup(); 162 }); 163 164 it("handles missing data-volt-state gracefully", () => { 165 container.innerHTML = `<div data-volt><span data-volt-text="'static'"></span></div>`; 166 167 const result = charge(); 168 const span = container.querySelector("span")!; 169 170 expect(span.textContent).toBe("static"); 171 172 result.cleanup(); 173 }); 174 175 it("logs error for invalid JSON", () => { 176 const consoleError = console.error; 177 const errors: unknown[] = []; 178 console.error = (...args: unknown[]) => errors.push(args); 179 180 container.innerHTML = `<div data-volt data-volt-state='invalid json'>Content</div>`; 181 182 const result = charge(); 183 184 expect(errors.length).toBeGreaterThan(0); 185 console.error = consoleError; 186 187 result.cleanup(); 188 }); 189 190 it("logs error for non-object state", () => { 191 const consoleError = console.error; 192 const errors: unknown[] = []; 193 console.error = (...args: unknown[]) => errors.push(args); 194 195 container.innerHTML = `<div data-volt data-volt-state='"string"'>Content</div>`; 196 197 const result = charge(); 198 199 expect(errors.length).toBeGreaterThan(0); 200 console.error = consoleError; 201 202 result.cleanup(); 203 }); 204 }); 205 206 describe("data-volt-computed", () => { 207 it("creates computed values from expressions", () => { 208 container.innerHTML = ` 209 <div data-volt 210 data-volt-state='{"count": 5}' 211 data-volt-computed:double="count * 2"> 212 <span data-volt-text="count"></span> 213 <span data-volt-text="double"></span> 214 </div> 215 `; 216 217 const result = charge(); 218 const spans = container.querySelectorAll("span"); 219 220 expect(spans[0].textContent).toBe("5"); 221 expect(spans[1].textContent).toBe("10"); 222 223 result.cleanup(); 224 }); 225 226 it("updates computed values when dependencies change", () => { 227 container.innerHTML = ` 228 <div data-volt 229 data-volt-state='{"count": 3}' 230 data-volt-computed:double="count * 2" 231 data-volt-computed:triple="count * 3"> 232 <span id="double" data-volt-text="double"></span> 233 <span id="triple" data-volt-text="triple"></span> 234 </div> 235 `; 236 237 const result = charge(); 238 const scope = result.roots[0].scope; 239 const double = container.querySelector("#double")!; 240 const triple = container.querySelector("#triple")!; 241 242 expect(double.textContent).toBe("6"); 243 expect(triple.textContent).toBe("9"); 244 245 (scope.count as Signal<number>).set(5); 246 247 expect(double.textContent).toBe("10"); 248 expect(triple.textContent).toBe("15"); 249 250 result.cleanup(); 251 }); 252 253 it("supports computed values with multiple dependencies", () => { 254 container.innerHTML = ` 255 <div data-volt 256 data-volt-state='{"a": 5, "b": 3}' 257 data-volt-computed:sum="a + b" 258 data-volt-computed:product="a * b"> 259 <span id="sum" data-volt-text="sum"></span> 260 <span id="product" data-volt-text="product"></span> 261 </div> 262 `; 263 264 const result = charge(); 265 const scope = result.roots[0].scope; 266 267 expect(container.querySelector("#sum")!.textContent).toBe("8"); 268 expect(container.querySelector("#product")!.textContent).toBe("15"); 269 270 (scope.a as Signal<number>).set(10); 271 272 expect(container.querySelector("#sum")!.textContent).toBe("13"); 273 expect(container.querySelector("#product")!.textContent).toBe("30"); 274 275 result.cleanup(); 276 }); 277 278 it("supports complex expressions in computed", () => { 279 container.innerHTML = ` 280 <div data-volt 281 data-volt-state='{"count": 10, "limit": 5}' 282 data-volt-computed:is-valid="count > limit && count < 20"> 283 <span data-volt-text="isValid"></span> 284 </div> 285 `; 286 287 const result = charge(); 288 const span = container.querySelector("span")!; 289 290 expect(span.textContent).toBe("true"); 291 292 result.cleanup(); 293 }); 294 295 it("handles computed with no dependencies", () => { 296 container.innerHTML = ` 297 <div data-volt 298 data-volt-computed:constant="42 * 2"> 299 <span data-volt-text="constant"></span> 300 </div> 301 `; 302 303 const result = charge(); 304 const span = container.querySelector("span")!; 305 306 expect(span.textContent).toBe("84"); 307 308 result.cleanup(); 309 }); 310 311 it("supports computed accessing nested properties", () => { 312 container.innerHTML = ` 313 <div data-volt 314 data-volt-state='{"user": {"firstName": "John", "lastName": "Doe"}}' 315 data-volt-computed:full-name="user.firstName"> 316 <span data-volt-text="fullName"></span> 317 </div> 318 `; 319 320 const result = charge(); 321 const span = container.querySelector("span")!; 322 323 expect(span.textContent).toBe("John"); 324 325 result.cleanup(); 326 }); 327 }); 328 329 describe("isolated scopes", () => { 330 it("creates isolated scopes for each root", () => { 331 container.innerHTML = ` 332 <div data-volt data-volt-state='{"count": 1}'> 333 <span id="root1" data-volt-text="count"></span> 334 </div> 335 <div data-volt data-volt-state='{"count": 2}'> 336 <span id="root2" data-volt-text="count"></span> 337 </div> 338 `; 339 340 const result = charge(); 341 342 expect(container.querySelector("#root1")!.textContent).toBe("1"); 343 expect(container.querySelector("#root2")!.textContent).toBe("2"); 344 345 const scope = result.roots[0].scope; 346 347 (scope.count as Signal<number>).set(10); 348 349 expect(container.querySelector("#root1")!.textContent).toBe("10"); 350 expect(container.querySelector("#root2")!.textContent).toBe("2"); 351 352 result.cleanup(); 353 }); 354 355 it("does not share state between roots", () => { 356 container.innerHTML = ` 357 <div data-volt data-volt-state='{"shared": "root1"}'> 358 <span id="s1" data-volt-text="shared"></span> 359 </div> 360 <div data-volt data-volt-state='{"shared": "root2"}'> 361 <span id="s2" data-volt-text="shared"></span> 362 </div> 363 `; 364 365 const result = charge(); 366 367 expect(container.querySelector("#s1")!.textContent).toBe("root1"); 368 expect(container.querySelector("#s2")!.textContent).toBe("root2"); 369 370 result.cleanup(); 371 }); 372 }); 373 374 describe("cleanup", () => { 375 it("cleans up all roots when calling global cleanup", () => { 376 container.innerHTML = ` 377 <div data-volt data-volt-state='{"count": 0}'> 378 <span data-volt-text="count"></span> 379 </div> 380 `; 381 382 const result = charge(); 383 const span = container.querySelector("span")!; 384 const scope = result.roots[0].scope; 385 386 (scope.count as Signal<number>).set(5); 387 expect(span.textContent).toBe("5"); 388 389 result.cleanup(); 390 391 (scope.count as Signal<number>).set(10); 392 expect(span.textContent).toBe("5"); 393 }); 394 395 it("cleans up individual roots", () => { 396 container.innerHTML = ` 397 <div data-volt data-volt-state='{"count": 0}'> 398 <span data-volt-text="count"></span> 399 </div> 400 `; 401 402 const result = charge(); 403 const span = container.querySelector("span")!; 404 const scope = result.roots[0].scope; 405 406 (scope.count as Signal<number>).set(5); 407 expect(span.textContent).toBe("5"); 408 409 result.roots[0].cleanup(); 410 411 (scope.count as Signal<number>).set(10); 412 expect(span.textContent).toBe("5"); 413 }); 414 }); 415 416 describe("integration with bindings", () => { 417 it("works with all binding types", () => { 418 container.innerHTML = ` 419 <div data-volt data-volt-state='{"message": "Hello", "active": true}'> 420 <span data-volt-text="message" data-volt-class="active"></span> 421 </div> 422 `; 423 424 const result = charge(); 425 const span = container.querySelector("span")!; 426 427 expect(span.textContent).toBe("Hello"); 428 expect(span.classList.contains("true")).toBe(true); 429 430 result.cleanup(); 431 }); 432 433 it("works with conditional rendering", () => { 434 container.innerHTML = ` 435 <div data-volt data-volt-state='{"show": true}'> 436 <p data-volt-if="show">Visible</p> 437 </div> 438 `; 439 440 const result = charge(); 441 442 expect(container.querySelector("p")).toBeTruthy(); 443 444 const scope = result.roots[0].scope; 445 (scope.show as Signal<boolean>).set(false); 446 447 expect(container.querySelector("p")).toBeNull(); 448 449 result.cleanup(); 450 }); 451 452 it("works with list rendering", () => { 453 container.innerHTML = ` 454 <div data-volt data-volt-state='{"items": ["a", "b"]}'> 455 <ul> 456 <li data-volt-for="item in items" data-volt-text="item"></li> 457 </ul> 458 </div> 459 `; 460 461 const result = charge(); 462 463 expect(container.querySelectorAll("li")).toHaveLength(2); 464 465 result.cleanup(); 466 }); 467 }); 468});