a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { computed, effect, signal } from "$core/signal";
2import { describe, expect, it, vi } from "vitest";
3
4describe("signal", () => {
5 it("creates a signal with an initial value", () => {
6 const count = signal(0);
7 expect(count.get()).toBe(0);
8 });
9
10 it("updates the value with set", () => {
11 const count = signal(0);
12 count.set(5);
13 expect(count.get()).toBe(5);
14 });
15
16 it("notifies subscribers when value changes", () => {
17 const count = signal(0);
18 const subscriber = vi.fn();
19
20 count.subscribe(subscriber);
21 count.set(10);
22
23 expect(subscriber).toHaveBeenCalledWith(10);
24 expect(subscriber).toHaveBeenCalledTimes(1);
25 });
26
27 it("does not notify subscribers when value is the same", () => {
28 const count = signal(0);
29 const subscriber = vi.fn();
30
31 count.subscribe(subscriber);
32 count.set(0);
33
34 expect(subscriber).not.toHaveBeenCalled();
35 });
36
37 it("supports multiple subscribers", () => {
38 const count = signal(0);
39 const subscriber1 = vi.fn();
40 const subscriber2 = vi.fn();
41
42 count.subscribe(subscriber1);
43 count.subscribe(subscriber2);
44 count.set(5);
45
46 expect(subscriber1).toHaveBeenCalledWith(5);
47 expect(subscriber2).toHaveBeenCalledWith(5);
48 });
49
50 it("allows unsubscribing", () => {
51 const count = signal(0);
52 const subscriber = vi.fn();
53
54 const unsubscribe = count.subscribe(subscriber);
55 unsubscribe();
56 count.set(10);
57
58 expect(subscriber).not.toHaveBeenCalled();
59 });
60
61 it("notifies immediately on each update", () => {
62 const count = signal(0);
63 const subscriber = vi.fn();
64
65 count.subscribe(subscriber);
66
67 count.set(1);
68 count.set(2);
69 count.set(3);
70
71 expect(subscriber).toHaveBeenCalledTimes(3);
72 expect(subscriber).toHaveBeenNthCalledWith(1, 1);
73 expect(subscriber).toHaveBeenNthCalledWith(2, 2);
74 expect(subscriber).toHaveBeenNthCalledWith(3, 3);
75 });
76
77 it("handles object values", () => {
78 const object = signal({ count: 0 });
79 const subscriber = vi.fn();
80
81 object.subscribe(subscriber);
82
83 const newValue = { count: 1 };
84 object.set(newValue);
85
86 expect(object.get()).toBe(newValue);
87 });
88
89 it("handles array values", () => {
90 const array = signal([1, 2, 3]);
91 const subscriber = vi.fn();
92
93 array.subscribe(subscriber);
94
95 const newValue = [4, 5, 6];
96 array.set(newValue);
97
98 expect(array.get()).toEqual([4, 5, 6]);
99 });
100
101 it("allows updating to null or undefined", () => {
102 const value = signal<string | null | undefined>("test");
103
104 value.set(null);
105 expect(value.get()).toBe(null);
106
107 value.set(undefined);
108 expect(value.get()).toBe(undefined);
109 });
110
111 it("handles rapid subscribe/unsubscribe", () => {
112 const count = signal(0);
113 const subscriber = vi.fn();
114
115 const unsub = count.subscribe(subscriber);
116 unsub();
117 count.subscribe(subscriber);
118
119 count.set(5);
120
121 expect(subscriber).toHaveBeenCalledTimes(1);
122 expect(subscriber).toHaveBeenCalledWith(5);
123 });
124});
125
126describe("computed", () => {
127 it("computes initial value", () => {
128 const count = signal(5);
129 const doubled = computed(() => count.get() * 2);
130
131 expect(doubled.get()).toBe(10);
132 });
133
134 it("recomputes when dependency changes", () => {
135 const count = signal(5);
136 const doubled = computed(() => count.get() * 2);
137
138 expect(doubled.get()).toBe(10);
139
140 count.set(10);
141 expect(doubled.get()).toBe(20);
142
143 count.set(0);
144 expect(doubled.get()).toBe(0);
145 });
146
147 it("notifies subscribers when value changes", () => {
148 const count = signal(5);
149 const doubled = computed(() => count.get() * 2);
150 const subscriber = vi.fn();
151
152 doubled.subscribe(subscriber);
153
154 count.set(10);
155 expect(subscriber).toHaveBeenCalledWith(20);
156 expect(subscriber).toHaveBeenCalledTimes(1);
157 });
158
159 it("does not notify when computed value is the same", () => {
160 const count = signal(5);
161 const isPositive = computed(() => count.get() > 0);
162 const subscriber = vi.fn();
163
164 isPositive.subscribe(subscriber);
165
166 count.set(10);
167 expect(subscriber).not.toHaveBeenCalled();
168
169 count.set(-1);
170 expect(subscriber).toHaveBeenCalledWith(false);
171 expect(subscriber).toHaveBeenCalledTimes(1);
172 });
173
174 it("supports multiple dependencies", () => {
175 const a = signal(2);
176 const b = signal(3);
177 const sum = computed(() => a.get() + b.get());
178
179 expect(sum.get()).toBe(5);
180
181 a.set(5);
182 expect(sum.get()).toBe(8);
183
184 b.set(10);
185 expect(sum.get()).toBe(15);
186 });
187
188 it("can depend on other computed signals", () => {
189 const count = signal(2);
190 const doubled = computed(() => count.get() * 2);
191 const quadrupled = computed(() => doubled.get() * 2);
192
193 expect(quadrupled.get()).toBe(8);
194
195 count.set(5);
196 expect(doubled.get()).toBe(10);
197 expect(quadrupled.get()).toBe(20);
198 });
199
200 it("allows unsubscribing", () => {
201 const count = signal(5);
202 const doubled = computed(() => count.get() * 2);
203 const subscriber = vi.fn();
204
205 const unsubscribe = doubled.subscribe(subscriber);
206 unsubscribe();
207
208 count.set(10);
209 expect(subscriber).not.toHaveBeenCalled();
210 });
211});
212
213describe("effect", () => {
214 it("runs when dependency changes", () => {
215 const count = signal(0);
216 const effectFunction = vi.fn(() => {
217 count.get();
218 });
219
220 effect(effectFunction);
221
222 count.set(1);
223 count.set(2);
224
225 expect(effectFunction).toHaveBeenCalledTimes(3);
226 });
227
228 it("can be cleaned up", () => {
229 const count = signal(0);
230 const effectFunction = vi.fn(() => {
231 count.get();
232 });
233
234 const cleanup = effect(effectFunction);
235
236 expect(effectFunction).toHaveBeenCalledTimes(1);
237
238 cleanup();
239
240 count.set(1);
241 expect(effectFunction).toHaveBeenCalledTimes(1);
242 });
243
244 it("runs cleanup function from previous effect", () => {
245 const count = signal(0);
246 const innerCleanup = vi.fn();
247 const effectFunction = vi.fn(() => {
248 count.get();
249 return innerCleanup;
250 });
251
252 effect(effectFunction);
253
254 expect(innerCleanup).not.toHaveBeenCalled();
255
256 count.set(1);
257 expect(innerCleanup).toHaveBeenCalledTimes(1);
258
259 count.set(2);
260 expect(innerCleanup).toHaveBeenCalledTimes(2);
261 });
262
263 it("runs final cleanup when effect is disposed", () => {
264 const count = signal(0);
265 const innerCleanup = vi.fn();
266 const effectFunction = vi.fn(() => {
267 count.get();
268 return innerCleanup;
269 });
270
271 const cleanup = effect(effectFunction);
272
273 count.set(1);
274 expect(innerCleanup).toHaveBeenCalledTimes(1);
275
276 cleanup();
277 expect(innerCleanup).toHaveBeenCalledTimes(2);
278 });
279
280 it("supports multiple dependencies", () => {
281 const a = signal(1);
282 const b = signal(2);
283 const effectFunction = vi.fn(() => {
284 a.get();
285 b.get();
286 });
287
288 effect(effectFunction);
289
290 expect(effectFunction).toHaveBeenCalledTimes(1);
291
292 a.set(5);
293 expect(effectFunction).toHaveBeenCalledTimes(2);
294
295 b.set(10);
296 expect(effectFunction).toHaveBeenCalledTimes(3);
297 });
298
299 it("can depend on computed signals", () => {
300 const count = signal(2);
301 const doubled = computed(() => count.get() * 2);
302 const effectFunction = vi.fn(() => {
303 doubled.get();
304 });
305
306 effect(effectFunction);
307
308 expect(effectFunction).toHaveBeenCalledTimes(1);
309
310 count.set(5);
311 expect(effectFunction).toHaveBeenCalledTimes(2);
312 });
313});