a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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});