a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/* eslint-disable @typescript-eslint/no-unused-expressions */
2import { isReactive, reactive, toRaw } from "$core/reactive";
3import { computed, effect, signal } from "$core/signal";
4import { describe, expect, it, vi } from "vitest";
5
6describe("reactive", () => {
7 describe("basic reactivity", () => {
8 it("creates a reactive object", () => {
9 const obj = reactive({ count: 0 });
10 expect(obj.count).toBe(0);
11 });
12
13 it("allows setting properties", () => {
14 const obj = reactive({ count: 0 });
15 obj.count = 5;
16 expect(obj.count).toBe(5);
17 });
18
19 it("triggers effects when properties change", () => {
20 const obj = reactive({ count: 0 });
21 const effectFn = vi.fn(() => {
22 obj.count;
23 });
24
25 effect(effectFn);
26 expect(effectFn).toHaveBeenCalledTimes(1);
27
28 obj.count = 5;
29 expect(effectFn).toHaveBeenCalledTimes(2);
30 });
31
32 it("works with computed signals", () => {
33 const obj = reactive({ count: 5 });
34 const doubled = computed(() => obj.count * 2);
35
36 expect(doubled.get()).toBe(10);
37
38 obj.count = 10;
39 expect(doubled.get()).toBe(20);
40 });
41
42 it("supports multiple properties", () => {
43 const obj = reactive({ a: 1, b: 2 });
44 const sum = computed(() => obj.a + obj.b);
45
46 expect(sum.get()).toBe(3);
47
48 obj.a = 5;
49 expect(sum.get()).toBe(7);
50
51 obj.b = 10;
52 expect(sum.get()).toBe(15);
53 });
54
55 it("does not trigger effects when value is the same", () => {
56 const obj = reactive({ count: 0 });
57 const effectFn = vi.fn(() => {
58 obj.count;
59 });
60
61 effect(effectFn);
62 expect(effectFn).toHaveBeenCalledTimes(1);
63
64 obj.count = 0;
65 expect(effectFn).toHaveBeenCalledTimes(1);
66 });
67
68 it("handles property deletion", () => {
69 const obj = reactive({ count: 5 as number | undefined, name: "test" });
70 const effectFn = vi.fn(() => {
71 obj.count;
72 });
73
74 effect(effectFn);
75 expect(effectFn).toHaveBeenCalledTimes(1);
76
77 delete obj.count;
78 expect(effectFn).toHaveBeenCalledTimes(2);
79 expect(obj.count).toBeUndefined();
80 });
81
82 it("handles 'in' operator", () => {
83 const obj = reactive({ count: 5 });
84 expect("count" in obj).toBe(true);
85 expect("missing" in obj).toBe(false);
86 });
87 });
88
89 describe("nested reactivity", () => {
90 it("makes nested objects reactive", () => {
91 const obj = reactive({ nested: { count: 0 } });
92 const effectFn = vi.fn(() => {
93 obj.nested.count;
94 });
95
96 effect(effectFn);
97 expect(effectFn).toHaveBeenCalledTimes(1);
98
99 obj.nested.count = 5;
100 expect(effectFn).toHaveBeenCalledTimes(2);
101 });
102
103 it("handles deeply nested objects", () => {
104 const obj = reactive({ a: { b: { c: 0 } } });
105 const effectFn = vi.fn(() => {
106 obj.a.b.c;
107 });
108
109 effect(effectFn);
110 expect(effectFn).toHaveBeenCalledTimes(1);
111
112 obj.a.b.c = 5;
113 expect(effectFn).toHaveBeenCalledTimes(2);
114 });
115
116 it("replaces nested objects reactively", () => {
117 const obj = reactive({ nested: { count: 0 } });
118 const effectFn = vi.fn(() => {
119 obj.nested.count;
120 });
121
122 effect(effectFn);
123 expect(effectFn).toHaveBeenCalledTimes(1);
124
125 obj.nested = { count: 10 };
126 expect(effectFn).toHaveBeenCalledTimes(2);
127 expect(obj.nested.count).toBe(10);
128 });
129 });
130
131 describe("array reactivity", () => {
132 it("creates a reactive array", () => {
133 const arr = reactive([1, 2, 3]);
134 expect(arr[0]).toBe(1);
135 expect(arr.length).toBe(3);
136 });
137
138 it("triggers effects on array index changes", () => {
139 const arr = reactive([1, 2, 3]);
140 const effectFn = vi.fn(() => {
141 arr[0];
142 });
143
144 effect(effectFn);
145 expect(effectFn).toHaveBeenCalledTimes(1);
146
147 arr[0] = 10;
148 expect(effectFn).toHaveBeenCalledTimes(2);
149 });
150
151 it("triggers effects on push", () => {
152 const arr = reactive([1, 2, 3]);
153 const effectFn = vi.fn(() => {
154 arr.length;
155 });
156
157 effect(effectFn);
158 expect(effectFn).toHaveBeenCalledTimes(1);
159
160 arr.push(4);
161 expect(effectFn).toHaveBeenCalledTimes(2);
162 expect(arr.length).toBe(4);
163 expect(arr[3]).toBe(4);
164 });
165
166 it("triggers effects on pop", () => {
167 const arr = reactive([1, 2, 3]);
168 const effectFn = vi.fn(() => {
169 arr.length;
170 });
171
172 effect(effectFn);
173 expect(effectFn).toHaveBeenCalledTimes(1);
174
175 const popped = arr.pop();
176 expect(popped).toBe(3);
177 expect(effectFn).toHaveBeenCalledTimes(2);
178 expect(arr.length).toBe(2);
179 });
180
181 it("triggers effects on shift", () => {
182 const arr = reactive([1, 2, 3]);
183 const effectFn = vi.fn(() => {
184 arr.length;
185 });
186
187 effect(effectFn);
188 expect(effectFn).toHaveBeenCalledTimes(1);
189
190 const shifted = arr.shift();
191 expect(shifted).toBe(1);
192 expect(effectFn).toHaveBeenCalledTimes(2);
193 expect(arr.length).toBe(2);
194 });
195
196 it("triggers effects on unshift", () => {
197 const arr = reactive([1, 2, 3]);
198 const effectFn = vi.fn(() => {
199 arr.length;
200 });
201
202 effect(effectFn);
203 expect(effectFn).toHaveBeenCalledTimes(1);
204
205 arr.unshift(0);
206 expect(effectFn).toHaveBeenCalledTimes(2);
207 expect(arr.length).toBe(4);
208 expect(arr[0]).toBe(0);
209 });
210
211 it("triggers effects on splice", () => {
212 const arr = reactive([1, 2, 3, 4, 5]);
213 const effectFn = vi.fn(() => {
214 arr[2];
215 });
216
217 effect(effectFn);
218 expect(effectFn).toHaveBeenCalledTimes(1);
219
220 arr.splice(2, 1, 10);
221 expect(effectFn).toHaveBeenCalledTimes(2);
222 expect(arr).toEqual([1, 2, 10, 4, 5]);
223 });
224
225 it("triggers effects on sort", () => {
226 const arr = reactive([3, 1, 2]);
227 const effectFn = vi.fn(() => {
228 arr[0];
229 });
230
231 effect(effectFn);
232 expect(effectFn).toHaveBeenCalledTimes(1);
233
234 arr.sort();
235 expect(effectFn).toHaveBeenCalledTimes(2);
236 expect(arr).toEqual([1, 2, 3]);
237 });
238
239 it("triggers effects on reverse", () => {
240 const arr = reactive([1, 2, 3]);
241 const effectFn = vi.fn(() => {
242 arr[0];
243 });
244
245 effect(effectFn);
246 expect(effectFn).toHaveBeenCalledTimes(1);
247
248 arr.reverse();
249 expect(effectFn).toHaveBeenCalledTimes(2);
250 expect(arr).toEqual([3, 2, 1]);
251 });
252
253 it("handles nested arrays", () => {
254 const arr = reactive([[1, 2], [3, 4]]);
255 const effectFn = vi.fn(() => {
256 arr[0][0];
257 });
258
259 effect(effectFn);
260 expect(effectFn).toHaveBeenCalledTimes(1);
261
262 arr[0][0] = 10;
263 expect(effectFn).toHaveBeenCalledTimes(2);
264 });
265 });
266
267 describe("isReactive and toRaw", () => {
268 it("identifies reactive objects", () => {
269 const obj = reactive({ count: 0 });
270 expect(isReactive(obj)).toBe(true);
271 });
272
273 it("returns false for non-reactive objects", () => {
274 const obj = { count: 0 };
275 expect(isReactive(obj)).toBe(false);
276 });
277
278 it("returns false for primitives", () => {
279 expect(isReactive(5)).toBe(false);
280 expect(isReactive("test")).toBe(false);
281 expect(isReactive(null)).toBe(false);
282 expect(isReactive(void 0)).toBe(false);
283 });
284
285 it("returns the raw object from a reactive proxy", () => {
286 const original = { count: 0 };
287 const obj = reactive(original);
288 const raw = toRaw(obj);
289
290 expect(raw).toBe(original);
291 expect(isReactive(raw)).toBe(false);
292 });
293
294 it("returns the value as-is for non-reactive values", () => {
295 const obj = { count: 0 };
296 expect(toRaw(obj)).toBe(obj);
297 expect(toRaw(5)).toBe(5);
298 });
299 });
300
301 describe("edge cases", () => {
302 it("returns the same proxy for the same object", () => {
303 const original = { count: 0 };
304 const proxy1 = reactive(original);
305 const proxy2 = reactive(original);
306
307 expect(proxy1).toBe(proxy2);
308 });
309
310 it("does not double-wrap reactive objects", () => {
311 const obj = reactive({ count: 0 });
312 const obj2 = reactive(obj);
313
314 expect(obj).toBe(obj2);
315 });
316
317 it("handles null and undefined gracefully", () => {
318 expect(reactive(null as unknown as object)).toBe(null);
319 expect(reactive(undefined as unknown as object)).toBe(undefined);
320 });
321
322 it("handles primitive values gracefully", () => {
323 expect(reactive(5 as unknown as object)).toBe(5);
324 expect(reactive("test" as unknown as object)).toBe("test");
325 });
326
327 it("works with mixed signal and reactive", () => {
328 const sig = signal(5);
329 const obj = reactive({ count: 0 });
330
331 const total = computed(() => sig.get() + obj.count);
332
333 expect(total.get()).toBe(5);
334
335 sig.set(10);
336 expect(total.get()).toBe(10);
337
338 obj.count = 5;
339 expect(total.get()).toBe(15);
340 });
341
342 it("handles circular references", () => {
343 const obj: { self?: unknown } = reactive({ self: undefined });
344 obj.self = obj;
345
346 expect(obj.self).toBe(obj);
347 });
348
349 it("supports non-enumerable properties", () => {
350 const original: { hidden?: number } = {};
351 Object.defineProperty(original, "hidden", { value: 42, enumerable: false });
352
353 const obj = reactive(original);
354 expect(obj.hidden).toBe(42);
355 });
356 });
357
358 describe("integration with existing reactivity", () => {
359 it("works in computed chains", () => {
360 const obj = reactive({ a: 1, b: 2 });
361 const sum = computed(() => obj.a + obj.b);
362 const doubled = computed(() => sum.get() * 2);
363
364 expect(doubled.get()).toBe(6);
365
366 obj.a = 5;
367 expect(sum.get()).toBe(7);
368 expect(doubled.get()).toBe(14);
369 });
370
371 it("works with multiple effects", () => {
372 const obj = reactive({ count: 0 });
373 const effect1 = vi.fn(() => {
374 obj.count;
375 });
376 const effect2 = vi.fn(() => {
377 obj.count;
378 });
379
380 effect(effect1);
381 effect(effect2);
382
383 expect(effect1).toHaveBeenCalledTimes(1);
384 expect(effect2).toHaveBeenCalledTimes(1);
385
386 obj.count = 5;
387
388 expect(effect1).toHaveBeenCalledTimes(2);
389 expect(effect2).toHaveBeenCalledTimes(2);
390 });
391
392 it("allows unsubscribing from effects", () => {
393 const obj = reactive({ count: 0 });
394 const effectFn = vi.fn(() => {
395 obj.count;
396 });
397
398 const cleanup = effect(effectFn);
399 expect(effectFn).toHaveBeenCalledTimes(1);
400
401 cleanup();
402
403 obj.count = 5;
404 expect(effectFn).toHaveBeenCalledTimes(1);
405 });
406 });
407});