a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { findScopedSignal, getComputedAttributes, isNil, isSignal, kebabToCamel } from "$core/shared";
2import { signal } from "$core/signal";
3import { describe, expect, it } from "vitest";
4
5describe("shared utilities", () => {
6 describe("isNil", () => {
7 it.each([
8 { value: null, expected: true, label: "null" },
9 { value: undefined, expected: true, label: "undefined" },
10 { value: 0, expected: false, label: "0" },
11 { value: "", expected: false, label: "empty string" },
12 { value: false, expected: false, label: "false" },
13 { value: {}, expected: false, label: "empty object" },
14 { value: [], expected: false, label: "empty array" },
15 { value: Number.NaN, expected: false, label: "NaN" },
16 { value: "test", expected: false, label: "string" },
17 { value: 42, expected: false, label: "number" },
18 { value: true, expected: false, label: "true" },
19 { value: { a: 1 }, expected: false, label: "object" },
20 { value: [1, 2], expected: false, label: "array" },
21 ])("returns $expected for $label", ({ value, expected }) => {
22 expect(isNil(value)).toBe(expected);
23 });
24 });
25
26 describe("kebabToCamel", () => {
27 it.each([
28 { input: "hello-world", expected: "helloWorld" },
29 { input: "font-size", expected: "fontSize" },
30 { input: "background-color", expected: "backgroundColor" },
31 { input: "data-volt-text", expected: "dataVoltText" },
32 { input: "simple", expected: "simple" },
33 { input: "a-b-c-d", expected: "aBCD" },
34 { input: "", expected: "" },
35 ])("converts '$input' to '$expected'", ({ input, expected }) => {
36 expect(kebabToCamel(input)).toBe(expected);
37 });
38 });
39
40 describe("isSignal", () => {
41 it("returns true for signals", () => {
42 const sig = signal(0);
43 expect(isSignal(sig)).toBe(true);
44 });
45
46 it.each([
47 { value: null, label: "null" },
48 { value: undefined, label: "undefined" },
49 { value: 42, label: "number" },
50 { value: "test", label: "string" },
51 { value: {}, label: "empty object" },
52 { value: { get: "not a function" }, label: "object with get property" },
53 { value: { get: () => {} }, label: "object with only get method" },
54 { value: { subscribe: () => {} }, label: "object with only subscribe method" },
55 ])("returns false for $label", ({ value }) => {
56 expect(isSignal(value)).toBe(false);
57 });
58 });
59
60 describe("findScopedSignal", () => {
61 it("finds signal at top level", () => {
62 const count = signal(5);
63 const scope = { count };
64
65 const found = findScopedSignal(scope, "count");
66 expect(found).toBe(count);
67 });
68
69 it("finds signal in nested path", () => {
70 const count = signal(5);
71 const scope = { user: { stats: { count } } };
72
73 const found = findScopedSignal(scope, "user.stats.count");
74 expect(found).toBe(count);
75 });
76
77 it("returns undefined for non-existent path", () => {
78 const scope = { count: signal(5) };
79
80 const found = findScopedSignal(scope, "missing");
81 expect(found).toBeUndefined();
82 });
83
84 it("returns undefined for non-signal values", () => {
85 const scope = { value: 42 };
86
87 const found = findScopedSignal(scope, "value");
88 expect(found).toBeUndefined();
89 });
90
91 it("returns undefined when traversing through null", () => {
92 const scope = { user: null };
93 const found = findScopedSignal(scope, "user.name");
94 expect(found).toBeUndefined();
95 });
96
97 it("handles whitespace in path", () => {
98 const count = signal(5);
99 const scope = { count };
100 const found = findScopedSignal(scope, " count ");
101 expect(found).toBe(count);
102 });
103 });
104
105 describe("getComputedAttributes", () => {
106 it("extracts computed attributes from element", () => {
107 const element = document.createElement("div");
108 element.dataset["voltComputed:doubled"] = "count * 2";
109 element.dataset["voltComputed:tripled"] = "count * 3";
110
111 const computed = getComputedAttributes(element);
112 expect(computed.size).toBe(2);
113 expect(computed.get("doubled")).toBe("count * 2");
114 expect(computed.get("tripled")).toBe("count * 3");
115 });
116
117 it("converts kebab-case names to camelCase", () => {
118 const element = document.createElement("div");
119 element.dataset["voltComputed:fullName"] = "firstName + ' ' + lastName";
120
121 const computed = getComputedAttributes(element);
122 expect(computed.get("fullName")).toBe("firstName + ' ' + lastName");
123 });
124
125 it("returns empty map when no computed attributes", () => {
126 const element = document.createElement("div");
127 element.dataset.voltText = "message";
128
129 const computed = getComputedAttributes(element);
130 expect(computed.size).toBe(0);
131 });
132
133 it("ignores non-computed data-volt attributes", () => {
134 const element = document.createElement("div");
135 element.dataset.voltText = "message";
136 element.dataset["voltComputed:value"] = "count * 2";
137 element.dataset.voltShow = "visible";
138
139 const computed = getComputedAttributes(element);
140 expect(computed.size).toBe(1);
141 expect(computed.get("value")).toBe("count * 2");
142 });
143 });
144});