a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { evaluate, evaluateStatements, EvaluationError } from "$core/evaluator";
2import { signal } from "$core/signal";
3import type { Scope, Signal } from "$types/volt";
4import { beforeEach, describe, expect, it } from "vitest";
5
6describe("Evaluator - Functional Tests", () => {
7 let scope: Scope;
8
9 beforeEach(() => {
10 scope = {};
11 });
12
13 describe("Literals", () => {
14 it("should evaluate number literals", () => {
15 expect(evaluate("42", scope)).toBe(42);
16 expect(evaluate("-10", scope)).toBe(-10);
17 expect(evaluate("3.14", scope)).toBe(3.14);
18 });
19
20 it("should evaluate string literals", () => {
21 expect(evaluate("'hello'", scope)).toBe("hello");
22 expect(evaluate("\"world\"", scope)).toBe("world");
23 expect(evaluate("\"hello world\"", scope)).toBe("hello world");
24 });
25
26 it("should evaluate boolean literals", () => {
27 expect(evaluate("true", scope)).toBe(true);
28 expect(evaluate("false", scope)).toBe(false);
29 });
30
31 it("should evaluate null and undefined", () => {
32 expect(evaluate("null", scope)).toBe(null);
33 expect(evaluate("undefined", scope)).toBe(undefined);
34 });
35 });
36
37 describe("Arithmetic Operators", () => {
38 it("should handle addition", () => {
39 expect(evaluate("1 + 2", scope)).toBe(3);
40 expect(evaluate("10 + 5", scope)).toBe(15);
41 });
42
43 it("should handle subtraction", () => {
44 expect(evaluate("10 - 5", scope)).toBe(5);
45 expect(evaluate("5 - 10", scope)).toBe(-5);
46 });
47
48 it("should handle multiplication", () => {
49 expect(evaluate("3 * 4", scope)).toBe(12);
50 expect(evaluate("10 * 0", scope)).toBe(0);
51 });
52
53 it("should handle division", () => {
54 expect(evaluate("10 / 2", scope)).toBe(5);
55 expect(evaluate("7 / 2", scope)).toBe(3.5);
56 });
57
58 it("should handle modulo", () => {
59 expect(evaluate("10 % 3", scope)).toBe(1);
60 expect(evaluate("7 % 2", scope)).toBe(1);
61 });
62
63 it("should respect operator precedence", () => {
64 expect(evaluate("2 + 3 * 4", scope)).toBe(14);
65 expect(evaluate("(2 + 3) * 4", scope)).toBe(20);
66 });
67 });
68
69 describe("Comparison Operators", () => {
70 it("should handle equality", () => {
71 expect(evaluate("5 === 5", scope)).toBe(true);
72 expect(evaluate("5 === 6", scope)).toBe(false);
73 expect(evaluate("5 !== 6", scope)).toBe(true);
74 expect(evaluate("5 !== 5", scope)).toBe(false);
75 });
76
77 it("should handle relational operators", () => {
78 expect(evaluate("5 < 10", scope)).toBe(true);
79 expect(evaluate("10 < 5", scope)).toBe(false);
80 expect(evaluate("5 > 3", scope)).toBe(true);
81 expect(evaluate("3 > 5", scope)).toBe(false);
82 expect(evaluate("5 <= 5", scope)).toBe(true);
83 expect(evaluate("5 >= 5", scope)).toBe(true);
84 });
85 });
86
87 describe("Logical Operators", () => {
88 it("should handle AND operator", () => {
89 expect(evaluate("true && true", scope)).toBe(true);
90 expect(evaluate("true && false", scope)).toBe(false);
91 expect(evaluate("false && true", scope)).toBe(false);
92 });
93
94 it("should handle OR operator", () => {
95 expect(evaluate("true || false", scope)).toBe(true);
96 expect(evaluate("false || true", scope)).toBe(true);
97 expect(evaluate("false || false", scope)).toBe(false);
98 });
99
100 it("should handle NOT operator", () => {
101 expect(evaluate("!true", scope)).toBe(false);
102 expect(evaluate("!false", scope)).toBe(true);
103 expect(evaluate("!!true", scope)).toBe(true);
104 });
105 });
106
107 describe("Ternary Operator", () => {
108 it("should evaluate ternary expressions", () => {
109 expect(evaluate("true ? 'yes' : 'no'", scope)).toBe("yes");
110 expect(evaluate("false ? 'yes' : 'no'", scope)).toBe("no");
111 expect(evaluate("5 > 3 ? 'greater' : 'lesser'", scope)).toBe("greater");
112 });
113
114 it("should handle nested ternaries", () => {
115 expect(evaluate("true ? (false ? 'a' : 'b') : 'c'", scope)).toBe("b");
116 });
117 });
118
119 describe("Variable Access", () => {
120 it("should access scope variables", () => {
121 scope.name = "Alice";
122 scope.age = 30;
123 expect(evaluate("name", scope)).toBe("Alice");
124 expect(evaluate("age", scope)).toBe(30);
125 });
126
127 it("should return undefined for missing variables", () => {
128 expect(evaluate("missing", scope)).toBe(undefined);
129 });
130
131 it("should handle variables in expressions", () => {
132 scope.x = 10;
133 scope.y = 5;
134 expect(evaluate("x + y", scope)).toBe(15);
135 expect(evaluate("x * y", scope)).toBe(50);
136 });
137 });
138
139 describe("Property Access", () => {
140 it("should access object properties with dot notation", () => {
141 scope.user = { name: "Bob", age: 25 };
142 expect(evaluate("user.name", scope)).toBe("Bob");
143 expect(evaluate("user.age", scope)).toBe(25);
144 });
145
146 it("should access object properties with bracket notation", () => {
147 scope.user = { name: "Charlie", age: 35 };
148 expect(evaluate("user['name']", scope)).toBe("Charlie");
149 expect(evaluate("user['age']", scope)).toBe(35);
150 });
151
152 it("should access nested properties", () => {
153 scope.data = { user: { profile: { name: "Dave" } } };
154 expect(evaluate("data.user.profile.name", scope)).toBe("Dave");
155 });
156
157 it("should access array elements", () => {
158 scope.items = [10, 20, 30];
159 expect(evaluate("items[0]", scope)).toBe(10);
160 expect(evaluate("items[1]", scope)).toBe(20);
161 expect(evaluate("items[2]", scope)).toBe(30);
162 });
163 });
164
165 describe("Function Calls", () => {
166 it("should call scope functions", () => {
167 scope.double = (x: number) => x * 2;
168 expect(evaluate("double(5)", scope)).toBe(10);
169 });
170
171 it("should call functions with multiple arguments", () => {
172 scope.add = (a: number, b: number) => a + b;
173 expect(evaluate("add(3, 7)", scope)).toBe(10);
174 });
175
176 it("should call object methods", () => {
177 scope.calc = { multiply: (a: number, b: number) => a * b };
178 expect(evaluate("calc.multiply(4, 5)", scope)).toBe(20);
179 });
180
181 it("should call safe global functions", () => {
182 expect(evaluate("Math.max(10, 20)", scope)).toBe(20);
183 expect(evaluate("Math.min(10, 20)", scope)).toBe(10);
184 expect(evaluate("Math.abs(-5)", scope)).toBe(5);
185 });
186 });
187
188 describe("Array Literals", () => {
189 it("should create array literals", () => {
190 const result = evaluate("[1, 2, 3]", scope);
191 expect(result).toEqual([1, 2, 3]);
192 });
193
194 it("should handle empty arrays", () => {
195 const result = evaluate("[]", scope);
196 expect(result).toEqual([]);
197 });
198
199 it("should handle arrays with expressions", () => {
200 scope.x = 10;
201 const result = evaluate("[x, x + 1, x + 2]", scope);
202 expect(result).toEqual([10, 11, 12]);
203 });
204
205 it("should handle spread in arrays", () => {
206 scope.arr = [2, 3, 4];
207 const result = evaluate("[1, ...arr, 5]", scope);
208 expect(result).toEqual([1, 2, 3, 4, 5]);
209 });
210 });
211
212 describe("Object Literals", () => {
213 it("should create object literals", () => {
214 const result = evaluate("{ name: 'Alice', age: 30 }", scope);
215 expect(result).toEqual({ name: "Alice", age: 30 });
216 });
217
218 it("should handle empty objects", () => {
219 const result = evaluate("{}", scope);
220 expect(result).toEqual({});
221 });
222
223 it("should handle objects with computed values", () => {
224 scope.x = 10;
225 const result = evaluate("{ value: x, double: x * 2 }", scope);
226 expect(result).toEqual({ value: 10, double: 20 });
227 });
228
229 it("should handle spread in objects", () => {
230 scope.base = { a: 1, b: 2 };
231 const result = evaluate("{ ...base, c: 3 }", scope);
232 expect(result).toEqual({ a: 1, b: 2, c: 3 });
233 });
234 });
235
236 describe("Arrow Functions", () => {
237 it("should support arrow functions", () => {
238 const fn = evaluate("(x) => x * 2", scope) as (x: number) => number;
239 expect(fn(5)).toBe(10);
240 });
241
242 it("should support arrow functions with no parameters", () => {
243 const fn = evaluate("() => 42", scope) as () => number;
244 expect(fn()).toBe(42);
245 });
246
247 it("should support arrow functions with multiple parameters", () => {
248 const fn = evaluate("(a, b) => a + b", scope) as (a: number, b: number) => number;
249 expect(fn(3, 7)).toBe(10);
250 });
251
252 it("should support arrow functions that capture scope", () => {
253 scope.multiplier = 3;
254 const fn = evaluate("(x) => x * multiplier", scope) as (x: number) => number;
255 expect(fn(5)).toBe(15);
256 });
257 });
258
259 describe("Signal Auto-Unwrapping", () => {
260 it("should auto-unwrap signals on read", () => {
261 scope.count = signal(10);
262 expect(evaluate("count", scope)).toBe(10);
263 });
264
265 it("should auto-unwrap signals in expressions", () => {
266 scope.count = signal(5);
267 expect(evaluate("count + 10", scope)).toBe(15);
268 expect(evaluate("count * 2", scope)).toBe(10);
269 });
270
271 it("should auto-unwrap nested signal properties", () => {
272 scope.user = signal({ name: "Alice", age: 30 });
273 expect(evaluate("user.name", scope)).toBe("Alice");
274 expect(evaluate("user.age", scope)).toBe(30);
275 });
276
277 it("should allow signal.set() calls", () => {
278 scope.count = signal(10);
279 evaluateStatements("count.set(20)", scope);
280 expect((scope.count as Signal<number>).get()).toBe(20);
281 });
282
283 it("should support strict equality comparisons with signals", () => {
284 scope.status = signal("active");
285 scope.page = signal("home");
286 expect(evaluate("status === 'active'", scope)).toBe(true);
287 expect(evaluate("status === 'inactive'", scope)).toBe(false);
288 expect(evaluate("page === 'home'", scope)).toBe(true);
289 expect(evaluate("page === 'about'", scope)).toBe(false);
290 });
291
292 it("should support loose equality comparisons with signals", () => {
293 scope.status = signal("active");
294 expect(evaluate("status == 'active'", scope)).toBe(true);
295 expect(evaluate("status == 'inactive'", scope)).toBe(false);
296 });
297
298 it("should support spreading signals containing arrays", () => {
299 scope.items = signal([2, 3, 4]);
300 const result = evaluate("[1, ...items, 5]", scope);
301 expect(result).toEqual([1, 2, 3, 4, 5]);
302 });
303
304 it("should support spreading signals in complex expressions", () => {
305 scope.todos = signal([{ id: 1, text: "Learn" }, { id: 2, text: "Build" }]);
306 scope.newTodo = { id: 3, text: "Ship" };
307 const result = evaluate("[...todos, newTodo]", scope);
308 expect(result).toEqual([{ id: 1, text: "Learn" }, { id: 2, text: "Build" }, { id: 3, text: "Ship" }]);
309 });
310
311 it("should support iterating over signals containing arrays", () => {
312 scope.items = signal([1, 2, 3]);
313 const result = evaluate("[...items].map(x => x * 2)", scope);
314 expect(result).toEqual([2, 4, 6]);
315 });
316
317 it("should handle spreading non-iterable signals gracefully", () => {
318 scope.count = signal(42);
319 expect(() => evaluate("[...count]", scope)).toThrow();
320 });
321
322 it("should unwrap signals in object literals when unwrapSignals is false", () => {
323 scope.id = signal(42);
324 scope.name = signal("Alice");
325 const result = evaluate("{id: id, name: name}", scope, { unwrapSignals: false });
326 expect(result).toEqual({ id: 42, name: "Alice" });
327 });
328
329 it("should unwrap signals in complex object literals", () => {
330 scope.todoId = signal(3);
331 scope.todoText = signal("New task");
332 scope.todoDone = signal(false);
333 const result = evaluate("{id: todoId, text: todoText, done: todoDone}", scope, { unwrapSignals: false });
334 expect(result).toEqual({ id: 3, text: "New task", done: false });
335 });
336
337 it("should not unwrap method calls in object literals", () => {
338 scope.text = signal(" hello ");
339 const result = evaluate("{value: text.trim()}", scope, { unwrapSignals: false });
340 expect(result).toEqual({ value: "hello" });
341 });
342 });
343
344 describe("Expression Caching", () => {
345 it("should cache compiled expressions", () => {
346 const expr = "x + y";
347 scope.x = 10;
348 scope.y = 5;
349
350 const result1 = evaluate(expr, scope);
351 const result2 = evaluate(expr, scope);
352
353 expect(result1).toBe(15);
354 expect(result2).toBe(15);
355 });
356
357 it("should cache statement expressions separately", () => {
358 scope.x = 10;
359
360 evaluateStatements("x = 20", scope);
361 expect(scope.x).toBe(20);
362
363 const result = evaluate("x", scope);
364 expect(result).toBe(20);
365 });
366 });
367
368 describe("Statement Evaluation", () => {
369 it("should execute single statements", () => {
370 scope.x = 10;
371 evaluateStatements("x = 20", scope);
372 expect(scope.x).toBe(20);
373 });
374
375 it("should execute multiple statements", () => {
376 scope.x = 1;
377 scope.y = 2;
378 evaluateStatements("x = 10; y = 20", scope);
379 expect(scope.x).toBe(10);
380 expect(scope.y).toBe(20);
381 });
382
383 it("should allow function calls in statements", () => {
384 scope.log = [];
385 scope.add = (value: number) => {
386 (scope.log as number[]).push(value);
387 };
388 evaluateStatements("add(1); add(2); add(3)", scope);
389 expect(scope.log).toEqual([1, 2, 3]);
390 });
391 });
392
393 describe("Error Handling", () => {
394 it("should throw EvaluationError for invalid syntax", () => {
395 expect(() => evaluate("1 +", scope)).toThrow(EvaluationError);
396 });
397
398 it("should throw EvaluationError for runtime errors", () => {
399 expect(() => evaluate("undefined.property", scope)).toThrow(EvaluationError);
400 });
401
402 it("should include expression in error message", () => {
403 try {
404 evaluate("1 +", scope);
405 } catch (error) {
406 expect(error).toBeInstanceOf(EvaluationError);
407 expect((error as EvaluationError).expr).toBe("1 +");
408 }
409 });
410
411 it("should preserve original error cause", () => {
412 try {
413 evaluate("undefined.property", scope);
414 } catch (error) {
415 expect(error).toBeInstanceOf(EvaluationError);
416 expect((error as EvaluationError).cause).toBeDefined();
417 }
418 });
419 });
420});