a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { computed, signal } from "$core/signal";
2import {
3 buildDependencyGraph,
4 detectCircularDependencies,
5 getDependencies,
6 getDependents,
7 getSignalDepth,
8 hasDependency,
9 recordDependencies,
10} from "$debug/graph";
11import { registerSignal } from "$debug/registry";
12import { describe, expect, it } from "vitest";
13
14describe("debug/graph", () => {
15 describe("recordDependencies", () => {
16 it("records dependencies for a signal", () => {
17 const a = signal(1);
18 const b = signal(2);
19 const sum = computed(() => a.get() + b.get());
20
21 recordDependencies(sum, [a, b]);
22
23 const deps = getDependencies(sum);
24 expect(deps).toHaveLength(2);
25 expect(deps).toContain(a);
26 expect(deps).toContain(b);
27 });
28
29 it("records dependents bidirectionally", () => {
30 const a = signal(1);
31 const sum = computed(() => a.get() * 2);
32
33 recordDependencies(sum, [a]);
34
35 const dependents = getDependents(a);
36 expect(dependents).toHaveLength(1);
37 expect(dependents).toContain(sum);
38 });
39
40 it("allows multiple dependents on one dependency", () => {
41 const a = signal(1);
42 const double = computed(() => a.get() * 2);
43 const triple = computed(() => a.get() * 3);
44
45 recordDependencies(double, [a]);
46 recordDependencies(triple, [a]);
47
48 const dependents = getDependents(a);
49 expect(dependents).toHaveLength(2);
50 expect(dependents).toContain(double);
51 expect(dependents).toContain(triple);
52 });
53
54 it("accumulates dependencies on repeated calls", () => {
55 const a = signal(1);
56 const b = signal(2);
57 const c = signal(3);
58 const sum = computed(() => a.get() + b.get() + c.get());
59
60 recordDependencies(sum, [a, b]);
61 recordDependencies(sum, [c]);
62
63 const deps = getDependencies(sum);
64 expect(deps).toHaveLength(3);
65 expect(deps).toContain(a);
66 expect(deps).toContain(b);
67 expect(deps).toContain(c);
68 });
69 });
70
71 describe("getDependencies", () => {
72 it("returns empty array for signal with no dependencies", () => {
73 const sig = signal(0);
74 const deps = getDependencies(sig);
75 expect(deps).toEqual([]);
76 });
77
78 it("returns all recorded dependencies", () => {
79 const a = signal(1);
80 const b = signal(2);
81 const sum = computed(() => a.get() + b.get());
82
83 recordDependencies(sum, [a, b]);
84
85 const deps = getDependencies(sum);
86 expect(deps).toHaveLength(2);
87 expect(deps).toContain(a);
88 expect(deps).toContain(b);
89 });
90 });
91
92 describe("getDependents", () => {
93 it("returns empty array for signal with no dependents", () => {
94 const sig = signal(0);
95 const deps = getDependents(sig);
96 expect(deps).toEqual([]);
97 });
98
99 it("returns all dependents", () => {
100 const a = signal(1);
101 const double = computed(() => a.get() * 2);
102 const triple = computed(() => a.get() * 3);
103
104 recordDependencies(double, [a]);
105 recordDependencies(triple, [a]);
106
107 const dependents = getDependents(a);
108 expect(dependents).toHaveLength(2);
109 expect(dependents).toContain(double);
110 expect(dependents).toContain(triple);
111 });
112 });
113
114 describe("hasDependency", () => {
115 it("returns true when dependency exists", () => {
116 const a = signal(1);
117 const double = computed(() => a.get() * 2);
118
119 recordDependencies(double, [a]);
120
121 expect(hasDependency(double, a)).toBe(true);
122 });
123
124 it("returns false when dependency does not exist", () => {
125 const a = signal(1);
126 const b = signal(2);
127 const double = computed(() => a.get() * 2);
128
129 recordDependencies(double, [a]);
130 expect(hasDependency(double, b)).toBe(false);
131 });
132
133 it("returns false for signal with no dependencies", () => {
134 const a = signal(1);
135 const b = signal(2);
136 expect(hasDependency(a, b)).toBe(false);
137 });
138 });
139
140 describe("buildDependencyGraph", () => {
141 it("builds a graph with nodes and edges", () => {
142 const a = signal(1);
143 const b = signal(2);
144 const sum = computed(() => a.get() + b.get());
145
146 registerSignal(a, "signal", "a");
147 registerSignal(b, "signal", "b");
148 registerSignal(sum, "computed", "sum");
149
150 recordDependencies(sum, [a, b]);
151
152 const graph = buildDependencyGraph([a, b, sum]);
153
154 expect(graph.nodes).toHaveLength(3);
155 expect(graph.edges).toHaveLength(2);
156 });
157
158 it("creates nodes with correct metadata", () => {
159 const a = signal(5);
160 registerSignal(a, "signal", "count");
161
162 const graph = buildDependencyGraph([a]);
163
164 expect(graph.nodes).toHaveLength(1);
165 const node = graph.nodes[0];
166 expect(node.signal).toBe(a);
167 expect(node.name).toBe("count");
168 expect(node.type).toBe("signal");
169 expect(node.value).toBe(5);
170 expect(node.id).toMatch(/^signal-\d+$/);
171 expect(node.dependencies).toEqual([]);
172 expect(node.dependents).toEqual([]);
173 });
174
175 it("creates edges from dependencies to dependents", () => {
176 const a = signal(1);
177 const b = signal(2);
178 const sum = computed(() => a.get() + b.get());
179
180 registerSignal(a, "signal", "a");
181 registerSignal(b, "signal", "b");
182 registerSignal(sum, "computed", "sum");
183
184 recordDependencies(sum, [a, b]);
185
186 const graph = buildDependencyGraph([a, b, sum]);
187 const aId = graph.nodes.find((n) => n.name === "a")?.id;
188 const bId = graph.nodes.find((n) => n.name === "b")?.id;
189 const sumId = graph.nodes.find((n) => n.name === "sum")?.id;
190
191 expect(graph.edges).toContainEqual({ from: aId, to: sumId });
192 expect(graph.edges).toContainEqual({ from: bId, to: sumId });
193 });
194
195 it("handles empty signal list", () => {
196 const graph = buildDependencyGraph([]);
197 expect(graph.nodes).toEqual([]);
198 expect(graph.edges).toEqual([]);
199 });
200
201 it("includes dependency and dependent IDs in nodes", () => {
202 const a = signal(1);
203 const double = computed(() => a.get() * 2);
204 const quad = computed(() => double.get() * 2);
205
206 registerSignal(a, "signal", "a");
207 registerSignal(double, "computed", "double");
208 registerSignal(quad, "computed", "quad");
209
210 recordDependencies(double, [a]);
211 recordDependencies(quad, [double]);
212
213 const graph = buildDependencyGraph([a, double, quad]);
214
215 const aNode = graph.nodes.find((n) => n.name === "a");
216 const doubleNode = graph.nodes.find((n) => n.name === "double");
217 const quadNode = graph.nodes.find((n) => n.name === "quad");
218
219 expect(aNode?.dependencies).toEqual([]);
220 expect(aNode?.dependents).toEqual([doubleNode?.id]);
221
222 expect(doubleNode?.dependencies).toEqual([aNode?.id]);
223 expect(doubleNode?.dependents).toEqual([quadNode?.id]);
224
225 expect(quadNode?.dependencies).toEqual([doubleNode?.id]);
226 expect(quadNode?.dependents).toEqual([]);
227 });
228 });
229
230 describe("detectCircularDependencies", () => {
231 it("returns null when no cycle exists", () => {
232 const a = signal(1);
233 const double = computed(() => a.get() * 2);
234
235 recordDependencies(double, [a]);
236
237 const cycle = detectCircularDependencies(a);
238 expect(cycle).toBeNull();
239 });
240
241 it("detects direct self-dependency", () => {
242 const a = signal(1);
243 recordDependencies(a, [a]);
244
245 const cycle = detectCircularDependencies(a);
246 expect(cycle).not.toBeNull();
247 expect(cycle).toContain(a);
248 });
249
250 it("detects two-node cycle", () => {
251 const a = signal(1);
252 const b = computed(() => a.get() * 2);
253
254 recordDependencies(a, [b]);
255 recordDependencies(b, [a]);
256
257 const cycle = detectCircularDependencies(a);
258 expect(cycle).not.toBeNull();
259 expect(cycle).toContain(a);
260 expect(cycle).toContain(b);
261 });
262
263 it("detects multi-node cycle", () => {
264 const a = signal(1);
265 const b = computed(() => a.get() * 2);
266 const c = computed(() => b.get() * 2);
267
268 recordDependencies(a, [c]);
269 recordDependencies(b, [a]);
270 recordDependencies(c, [b]);
271
272 const cycle = detectCircularDependencies(a);
273 expect(cycle).not.toBeNull();
274 expect(cycle).toContain(a);
275 expect(cycle).toContain(b);
276 expect(cycle).toContain(c);
277 });
278
279 it("handles shared dependencies without false positives", () => {
280 const a = signal(1);
281 const b = computed(() => a.get() * 2);
282 const c = computed(() => a.get() * 3);
283 const sum = computed(() => b.get() + c.get());
284
285 recordDependencies(b, [a]);
286 recordDependencies(c, [a]);
287 recordDependencies(sum, [b, c]);
288
289 const cycle = detectCircularDependencies(a);
290 expect(cycle).toBeNull();
291 });
292 });
293
294 describe("getSignalDepth", () => {
295 it("returns 0 for signal with no dependencies", () => {
296 const sig = signal(0);
297 expect(getSignalDepth(sig)).toBe(0);
298 });
299
300 it("returns 1 for signal depending on base signal", () => {
301 const a = signal(1);
302 const double = computed(() => a.get() * 2);
303 recordDependencies(double, [a]);
304 expect(getSignalDepth(double)).toBe(1);
305 });
306
307 it("calculates depth for multi-level dependencies", () => {
308 const a = signal(1);
309 const double = computed(() => a.get() * 2);
310 const quad = computed(() => double.get() * 2);
311 const oct = computed(() => quad.get() * 2);
312
313 recordDependencies(double, [a]);
314 recordDependencies(quad, [double]);
315 recordDependencies(oct, [quad]);
316
317 expect(getSignalDepth(a)).toBe(0);
318 expect(getSignalDepth(double)).toBe(1);
319 expect(getSignalDepth(quad)).toBe(2);
320 expect(getSignalDepth(oct)).toBe(3);
321 });
322
323 it("handles shared dependencies correctly", () => {
324 const a = signal(1);
325 const b = signal(2);
326 const double = computed(() => a.get() * 2);
327 const sum = computed(() => double.get() + b.get());
328
329 recordDependencies(double, [a]);
330 recordDependencies(sum, [double, b]);
331
332 expect(getSignalDepth(sum)).toBe(2);
333 });
334
335 it("uses maximum depth when multiple paths exist", () => {
336 const a = signal(1);
337 const b = computed(() => a.get() * 2);
338 const c = computed(() => b.get() * 2);
339 const d = computed(() => a.get() + c.get());
340
341 recordDependencies(b, [a]);
342 recordDependencies(c, [b]);
343 recordDependencies(d, [a, c]);
344
345 expect(getSignalDepth(d)).toBe(3);
346 });
347
348 it("handles circular dependencies gracefully", () => {
349 const a = signal(1);
350 const b = computed(() => a.get() * 2);
351
352 recordDependencies(a, [b]);
353 recordDependencies(b, [a]);
354
355 expect(() => getSignalDepth(a)).not.toThrow();
356 });
357 });
358});