zero-query 0.5.2 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +12 -10
  2. package/cli/commands/build.js +7 -5
  3. package/cli/commands/bundle.js +286 -8
  4. package/cli/commands/dev/index.js +82 -0
  5. package/cli/commands/dev/logger.js +70 -0
  6. package/cli/commands/dev/overlay.js +366 -0
  7. package/cli/commands/dev/server.js +158 -0
  8. package/cli/commands/dev/validator.js +94 -0
  9. package/cli/commands/dev/watcher.js +147 -0
  10. package/cli/scaffold/favicon.ico +0 -0
  11. package/cli/scaffold/index.html +1 -0
  12. package/cli/scaffold/scripts/app.js +15 -22
  13. package/cli/scaffold/scripts/components/about.js +14 -2
  14. package/cli/scaffold/scripts/components/contacts/contacts.css +0 -7
  15. package/cli/scaffold/scripts/components/contacts/contacts.html +8 -7
  16. package/cli/scaffold/scripts/components/contacts/contacts.js +17 -1
  17. package/cli/scaffold/scripts/components/counter.js +30 -10
  18. package/cli/scaffold/scripts/components/home.js +3 -3
  19. package/cli/scaffold/scripts/components/todos.js +6 -5
  20. package/cli/scaffold/styles/styles.css +1 -0
  21. package/cli/utils.js +111 -6
  22. package/dist/zquery.dist.zip +0 -0
  23. package/dist/zquery.js +2005 -216
  24. package/dist/zquery.min.js +3 -13
  25. package/index.d.ts +149 -1080
  26. package/index.js +18 -7
  27. package/package.json +9 -3
  28. package/src/component.js +186 -45
  29. package/src/core.js +327 -35
  30. package/src/diff.js +280 -0
  31. package/src/errors.js +155 -0
  32. package/src/expression.js +806 -0
  33. package/src/http.js +18 -10
  34. package/src/reactive.js +29 -4
  35. package/src/router.js +59 -6
  36. package/src/ssr.js +224 -0
  37. package/src/store.js +24 -8
  38. package/tests/component.test.js +304 -0
  39. package/tests/core.test.js +726 -0
  40. package/tests/diff.test.js +194 -0
  41. package/tests/errors.test.js +162 -0
  42. package/tests/expression.test.js +334 -0
  43. package/tests/http.test.js +181 -0
  44. package/tests/reactive.test.js +191 -0
  45. package/tests/router.test.js +332 -0
  46. package/tests/store.test.js +253 -0
  47. package/tests/utils.test.js +353 -0
  48. package/types/collection.d.ts +368 -0
  49. package/types/component.d.ts +210 -0
  50. package/types/errors.d.ts +103 -0
  51. package/types/http.d.ts +81 -0
  52. package/types/misc.d.ts +166 -0
  53. package/types/reactive.d.ts +76 -0
  54. package/types/router.d.ts +132 -0
  55. package/types/ssr.d.ts +49 -0
  56. package/types/store.d.ts +107 -0
  57. package/types/utils.d.ts +142 -0
  58. /package/cli/commands/{dev.js → dev.old.js} +0 -0
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { morph } from '../src/diff.js';
3
+
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+ function el(html) {
9
+ const div = document.createElement('div');
10
+ div.innerHTML = html;
11
+ return div;
12
+ }
13
+
14
+ function morphAndGet(oldHTML, newHTML) {
15
+ const root = el(oldHTML);
16
+ morph(root, newHTML);
17
+ return root;
18
+ }
19
+
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Basic morphing
23
+ // ---------------------------------------------------------------------------
24
+
25
+ describe('morph — basic', () => {
26
+ it('updates text content', () => {
27
+ const root = morphAndGet('<p>old</p>', '<p>new</p>');
28
+ expect(root.innerHTML).toBe('<p>new</p>');
29
+ });
30
+
31
+ it('appends new elements', () => {
32
+ const root = morphAndGet('<p>a</p>', '<p>a</p><p>b</p>');
33
+ expect(root.children.length).toBe(2);
34
+ expect(root.children[1].textContent).toBe('b');
35
+ });
36
+
37
+ it('removes extra elements', () => {
38
+ const root = morphAndGet('<p>a</p><p>b</p>', '<p>a</p>');
39
+ expect(root.children.length).toBe(1);
40
+ });
41
+
42
+ it('replaces elements with different tag', () => {
43
+ const root = morphAndGet('<p>text</p>', '<div>text</div>');
44
+ expect(root.children[0].tagName).toBe('DIV');
45
+ expect(root.children[0].textContent).toBe('text');
46
+ });
47
+
48
+ it('handles empty to content', () => {
49
+ const root = morphAndGet('', '<p>hello</p>');
50
+ expect(root.innerHTML).toBe('<p>hello</p>');
51
+ });
52
+
53
+ it('handles content to empty', () => {
54
+ const root = morphAndGet('<p>hello</p>', '');
55
+ expect(root.children.length).toBe(0);
56
+ });
57
+ });
58
+
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Attribute morphing
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe('morph — attributes', () => {
65
+ it('adds new attributes', () => {
66
+ const root = morphAndGet('<div></div>', '<div class="active"></div>');
67
+ expect(root.children[0].className).toBe('active');
68
+ });
69
+
70
+ it('updates existing attributes', () => {
71
+ const root = morphAndGet('<div class="old"></div>', '<div class="new"></div>');
72
+ expect(root.children[0].className).toBe('new');
73
+ });
74
+
75
+ it('removes stale attributes', () => {
76
+ const root = morphAndGet('<div class="x" id="y"></div>', '<div class="x"></div>');
77
+ expect(root.children[0].hasAttribute('id')).toBe(false);
78
+ });
79
+ });
80
+
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Keyed reconciliation
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe('morph — keyed', () => {
87
+ it('matches elements by z-key', () => {
88
+ const root = el('<div z-key="a">A</div><div z-key="b">B</div>');
89
+ morph(root, '<div z-key="b">B-updated</div><div z-key="a">A-updated</div>');
90
+ // Keys should match — b first, then a
91
+ const kids = [...root.children];
92
+ expect(kids[0].getAttribute('z-key')).toBe('b');
93
+ expect(kids[0].textContent).toBe('B-updated');
94
+ expect(kids[1].getAttribute('z-key')).toBe('a');
95
+ expect(kids[1].textContent).toBe('A-updated');
96
+ });
97
+
98
+ it('removes keyed elements not in new tree', () => {
99
+ const root = el('<div z-key="a">A</div><div z-key="b">B</div><div z-key="c">C</div>');
100
+ morph(root, '<div z-key="a">A</div><div z-key="c">C</div>');
101
+ expect(root.children.length).toBe(2);
102
+ expect(root.children[0].getAttribute('z-key')).toBe('a');
103
+ expect(root.children[1].getAttribute('z-key')).toBe('c');
104
+ });
105
+
106
+ it('inserts new keyed elements', () => {
107
+ const root = el('<div z-key="a">A</div>');
108
+ morph(root, '<div z-key="a">A</div><div z-key="b">B</div>');
109
+ expect(root.children.length).toBe(2);
110
+ expect(root.children[1].getAttribute('z-key')).toBe('b');
111
+ });
112
+ });
113
+
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Input / form element handling
117
+ // ---------------------------------------------------------------------------
118
+
119
+ describe('morph — form elements', () => {
120
+ it('syncs input value', () => {
121
+ const root = el('<input value="old">');
122
+ morph(root, '<input value="new">');
123
+ expect(root.querySelector('input').value).toBe('new');
124
+ });
125
+
126
+ it('syncs checkbox checked state', () => {
127
+ const root = el('<input type="checkbox">');
128
+ root.querySelector('input').checked = false;
129
+ morph(root, '<input type="checkbox" checked>');
130
+ expect(root.querySelector('input').checked).toBe(true);
131
+ });
132
+
133
+ it('syncs textarea content', () => {
134
+ const root = el('<textarea>old</textarea>');
135
+ morph(root, '<textarea>new</textarea>');
136
+ expect(root.querySelector('textarea').value).toBe('new');
137
+ });
138
+ });
139
+
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Text and comment nodes
143
+ // ---------------------------------------------------------------------------
144
+
145
+ describe('morph — text nodes', () => {
146
+ it('updates text nodes', () => {
147
+ const root = document.createElement('div');
148
+ root.textContent = 'old';
149
+ morph(root, 'new');
150
+ expect(root.textContent).toBe('new');
151
+ });
152
+ });
153
+
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Nested children
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe('morph — nested', () => {
160
+ it('recursively morphs nested elements', () => {
161
+ const root = morphAndGet(
162
+ '<ul><li>a</li><li>b</li></ul>',
163
+ '<ul><li>a</li><li>b-updated</li><li>c</li></ul>'
164
+ );
165
+ const items = root.querySelectorAll('li');
166
+ expect(items.length).toBe(3);
167
+ expect(items[1].textContent).toBe('b-updated');
168
+ expect(items[2].textContent).toBe('c');
169
+ });
170
+
171
+ it('handles structure change in nested elements', () => {
172
+ const root = morphAndGet(
173
+ '<div><span>text</span></div>',
174
+ '<div><p>new text</p></div>'
175
+ );
176
+ expect(root.querySelector('p').textContent).toBe('new text');
177
+ expect(root.querySelector('span')).toBeNull();
178
+ });
179
+ });
180
+
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Preserves unchanged nodes (identity)
184
+ // ---------------------------------------------------------------------------
185
+
186
+ describe('morph — preservation', () => {
187
+ it('does not replace elements that have not changed', () => {
188
+ const root = el('<p>same</p><p>will-change</p>');
189
+ const firstP = root.children[0];
190
+ morph(root, '<p>same</p><p>changed</p>');
191
+ expect(root.children[0]).toBe(firstP); // same DOM node
192
+ expect(root.children[1].textContent).toBe('changed');
193
+ });
194
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { ZQueryError, ErrorCode, onError, reportError, guardCallback, validate } from '../src/errors.js';
3
+
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // ZQueryError
7
+ // ---------------------------------------------------------------------------
8
+
9
+ describe('ZQueryError', () => {
10
+ it('extends Error', () => {
11
+ const err = new ZQueryError(ErrorCode.INVALID_ARGUMENT, 'test msg');
12
+ expect(err).toBeInstanceOf(Error);
13
+ expect(err).toBeInstanceOf(ZQueryError);
14
+ });
15
+
16
+ it('sets code, message, and context', () => {
17
+ const err = new ZQueryError(ErrorCode.COMP_RENDER, 'render failed', { component: 'my-app' });
18
+ expect(err.code).toBe('ZQ_COMP_RENDER');
19
+ expect(err.message).toBe('render failed');
20
+ expect(err.context.component).toBe('my-app');
21
+ expect(err.name).toBe('ZQueryError');
22
+ });
23
+
24
+ it('stores cause', () => {
25
+ const cause = new Error('original');
26
+ const err = new ZQueryError(ErrorCode.HTTP_REQUEST, 'http error', {}, cause);
27
+ expect(err.cause).toBe(cause);
28
+ });
29
+ });
30
+
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // ErrorCode
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe('ErrorCode', () => {
37
+ it('is frozen', () => {
38
+ expect(Object.isFrozen(ErrorCode)).toBe(true);
39
+ });
40
+
41
+ it('contains expected codes', () => {
42
+ expect(ErrorCode.REACTIVE_CALLBACK).toBe('ZQ_REACTIVE_CALLBACK');
43
+ expect(ErrorCode.EXPR_PARSE).toBe('ZQ_EXPR_PARSE');
44
+ expect(ErrorCode.COMP_NOT_FOUND).toBe('ZQ_COMP_NOT_FOUND');
45
+ expect(ErrorCode.STORE_ACTION).toBe('ZQ_STORE_ACTION');
46
+ expect(ErrorCode.HTTP_REQUEST).toBe('ZQ_HTTP_REQUEST');
47
+ expect(ErrorCode.ROUTER_LOAD).toBe('ZQ_ROUTER_LOAD');
48
+ });
49
+ });
50
+
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // onError / reportError
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe('reportError', () => {
57
+ let errorSpy;
58
+ beforeEach(() => {
59
+ onError(null); // reset handler
60
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
61
+ });
62
+ afterEach(() => {
63
+ errorSpy.mockRestore();
64
+ onError(null);
65
+ });
66
+
67
+ it('logs to console.error', () => {
68
+ reportError(ErrorCode.STORE_ACTION, 'test error', { action: 'foo' });
69
+ expect(errorSpy).toHaveBeenCalledOnce();
70
+ expect(errorSpy.mock.calls[0][0]).toContain('ZQ_STORE_ACTION');
71
+ });
72
+
73
+ it('calls global error handler', () => {
74
+ const handler = vi.fn();
75
+ onError(handler);
76
+ reportError(ErrorCode.COMP_RENDER, 'render failed');
77
+ expect(handler).toHaveBeenCalledOnce();
78
+ const err = handler.mock.calls[0][0];
79
+ expect(err).toBeInstanceOf(ZQueryError);
80
+ expect(err.code).toBe('ZQ_COMP_RENDER');
81
+ });
82
+
83
+ it('does not crash if handler throws', () => {
84
+ onError(() => { throw new Error('handler error'); });
85
+ expect(() => reportError(ErrorCode.COMP_RENDER, 'test')).not.toThrow();
86
+ });
87
+
88
+ it('passes cause through', () => {
89
+ const handler = vi.fn();
90
+ onError(handler);
91
+ const cause = new Error('root cause');
92
+ reportError(ErrorCode.HTTP_REQUEST, 'http failed', {}, cause);
93
+ expect(handler.mock.calls[0][0].cause).toBe(cause);
94
+ });
95
+ });
96
+
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // guardCallback
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe('guardCallback', () => {
103
+ let errorSpy;
104
+ beforeEach(() => {
105
+ onError(null);
106
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
107
+ });
108
+ afterEach(() => {
109
+ errorSpy.mockRestore();
110
+ });
111
+
112
+ it('returns the same value as original function', () => {
113
+ const guarded = guardCallback((x) => x * 2, ErrorCode.COMP_RENDER);
114
+ expect(guarded(5)).toBe(10);
115
+ });
116
+
117
+ it('catches errors and reports them', () => {
118
+ const handler = vi.fn();
119
+ onError(handler);
120
+ const guarded = guardCallback(() => { throw new Error('boom'); }, ErrorCode.COMP_RENDER, { component: 'test' });
121
+ expect(() => guarded()).not.toThrow();
122
+ expect(handler).toHaveBeenCalledOnce();
123
+ });
124
+
125
+ it('passes arguments through', () => {
126
+ const guarded = guardCallback((a, b) => a + b, ErrorCode.COMP_RENDER);
127
+ expect(guarded(1, 2)).toBe(3);
128
+ });
129
+ });
130
+
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // validate
134
+ // ---------------------------------------------------------------------------
135
+
136
+ describe('validate', () => {
137
+ it('passes for valid values', () => {
138
+ expect(() => validate('hello', 'name', 'string')).not.toThrow();
139
+ expect(() => validate(42, 'count', 'number')).not.toThrow();
140
+ expect(() => validate(() => {}, 'fn', 'function')).not.toThrow();
141
+ });
142
+
143
+ it('throws ZQueryError for null', () => {
144
+ expect(() => validate(null, 'name', 'string')).toThrow(ZQueryError);
145
+ });
146
+
147
+ it('throws ZQueryError for undefined', () => {
148
+ expect(() => validate(undefined, 'count')).toThrow(ZQueryError);
149
+ });
150
+
151
+ it('throws ZQueryError for wrong type', () => {
152
+ expect(() => validate(42, 'name', 'string')).toThrow(ZQueryError);
153
+ });
154
+
155
+ it('error has INVALID_ARGUMENT code', () => {
156
+ try {
157
+ validate(null, 'param');
158
+ } catch (err) {
159
+ expect(err.code).toBe(ErrorCode.INVALID_ARGUMENT);
160
+ }
161
+ });
162
+ });
@@ -0,0 +1,334 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { safeEval } from '../src/expression.js';
3
+
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+ const eval_ = (expr, ...scopes) => safeEval(expr, scopes.length ? scopes : [{}]);
9
+
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Literals
13
+ // ---------------------------------------------------------------------------
14
+
15
+ describe('expression parser — literals', () => {
16
+ it('numbers', () => {
17
+ expect(eval_('42')).toBe(42);
18
+ expect(eval_('3.14')).toBe(3.14);
19
+ expect(eval_('0xFF')).toBe(255);
20
+ expect(eval_('1e3')).toBe(1000);
21
+ });
22
+
23
+ it('strings', () => {
24
+ expect(eval_("'hello'")).toBe('hello');
25
+ expect(eval_('"world"')).toBe('world');
26
+ expect(eval_("'it\\'s'")).toBe("it's");
27
+ });
28
+
29
+ it('booleans and null/undefined', () => {
30
+ expect(eval_('true')).toBe(true);
31
+ expect(eval_('false')).toBe(false);
32
+ expect(eval_('null')).toBe(null);
33
+ expect(eval_('undefined')).toBe(undefined);
34
+ });
35
+
36
+ it('empty expression returns undefined', () => {
37
+ expect(eval_('')).toBe(undefined);
38
+ expect(eval_(' ')).toBe(undefined);
39
+ });
40
+ });
41
+
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Arithmetic
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('expression parser — arithmetic', () => {
48
+ it('basic operations', () => {
49
+ expect(eval_('2 + 3')).toBe(5);
50
+ expect(eval_('10 - 4')).toBe(6);
51
+ expect(eval_('3 * 7')).toBe(21);
52
+ expect(eval_('15 / 3')).toBe(5);
53
+ expect(eval_('10 % 3')).toBe(1);
54
+ });
55
+
56
+ it('operator precedence', () => {
57
+ expect(eval_('2 + 3 * 4')).toBe(14);
58
+ expect(eval_('(2 + 3) * 4')).toBe(20);
59
+ });
60
+
61
+ it('unary operators', () => {
62
+ expect(eval_('-5')).toBe(-5);
63
+ expect(eval_('+3')).toBe(3);
64
+ });
65
+ });
66
+
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Comparison & logical
70
+ // ---------------------------------------------------------------------------
71
+
72
+ describe('expression parser — comparison', () => {
73
+ it('equality', () => {
74
+ expect(eval_('1 === 1')).toBe(true);
75
+ expect(eval_('1 !== 2')).toBe(true);
76
+ expect(eval_("1 == '1'")).toBe(true);
77
+ expect(eval_("1 != '2'")).toBe(true);
78
+ });
79
+
80
+ it('relational', () => {
81
+ expect(eval_('3 > 2')).toBe(true);
82
+ expect(eval_('3 < 2')).toBe(false);
83
+ expect(eval_('3 >= 3')).toBe(true);
84
+ expect(eval_('3 <= 2')).toBe(false);
85
+ });
86
+ });
87
+
88
+
89
+ describe('expression parser — logical', () => {
90
+ it('&& and ||', () => {
91
+ expect(eval_('true && false')).toBe(false);
92
+ expect(eval_('true || false')).toBe(true);
93
+ expect(eval_('0 || 42')).toBe(42);
94
+ expect(eval_("'' || 'default'")).toBe('default');
95
+ });
96
+
97
+ it('!', () => {
98
+ expect(eval_('!true')).toBe(false);
99
+ expect(eval_('!false')).toBe(true);
100
+ expect(eval_('!0')).toBe(true);
101
+ });
102
+
103
+ it('nullish coalescing ??', () => {
104
+ expect(eval_('null ?? 10')).toBe(10);
105
+ expect(eval_('undefined ?? 20')).toBe(20);
106
+ expect(eval_('0 ?? 30')).toBe(0);
107
+ expect(eval_("'' ?? 'fallback'")).toBe('');
108
+ });
109
+ });
110
+
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Ternary
114
+ // ---------------------------------------------------------------------------
115
+
116
+ describe('expression parser — ternary', () => {
117
+ it('evaluates truthy branch', () => {
118
+ expect(eval_("true ? 'yes' : 'no'")).toBe('yes');
119
+ });
120
+
121
+ it('evaluates falsy branch', () => {
122
+ expect(eval_("false ? 'yes' : 'no'")).toBe('no');
123
+ });
124
+
125
+ it('works with expressions', () => {
126
+ expect(eval_('5 > 3 ? 10 : 20')).toBe(10);
127
+ });
128
+ });
129
+
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Property access & scope
133
+ // ---------------------------------------------------------------------------
134
+
135
+ describe('expression parser — property access', () => {
136
+ it('reads scope variables', () => {
137
+ expect(eval_('x', { x: 42 })).toBe(42);
138
+ expect(eval_('name', { name: 'Tony' })).toBe('Tony');
139
+ });
140
+
141
+ it('dot access', () => {
142
+ expect(eval_('user.name', { user: { name: 'Tony' } })).toBe('Tony');
143
+ });
144
+
145
+ it('computed access', () => {
146
+ expect(eval_('items[0]', { items: ['a', 'b', 'c'] })).toBe('a');
147
+ expect(eval_('obj[key]', { obj: { x: 1 }, key: 'x' })).toBe(1);
148
+ });
149
+
150
+ it('optional chaining ?.', () => {
151
+ expect(eval_('user?.name', { user: null })).toBe(undefined);
152
+ expect(eval_('user?.name', { user: { name: 'Tony' } })).toBe('Tony');
153
+ });
154
+
155
+ it('returns undefined for missing scope keys', () => {
156
+ expect(eval_('missing')).toBe(undefined);
157
+ expect(eval_('a.b.c', { a: {} })).toBe(undefined);
158
+ });
159
+ });
160
+
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Method calls
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe('expression parser — method calls', () => {
167
+ it('string methods', () => {
168
+ expect(eval_("'hello'.toUpperCase()")).toBe('HELLO');
169
+ expect(eval_("'hello world'.split(' ')")).toEqual(['hello', 'world']);
170
+ expect(eval_("'abc'.includes('b')")).toBe(true);
171
+ });
172
+
173
+ it('array methods', () => {
174
+ expect(eval_('items.length', { items: [1, 2, 3] })).toBe(3);
175
+ expect(eval_('items.includes(2)', { items: [1, 2, 3] })).toBe(true);
176
+ expect(eval_("items.join(',')", { items: [1, 2, 3] })).toBe('1,2,3');
177
+ });
178
+
179
+ it('custom function calls', () => {
180
+ const add = (a, b) => a + b;
181
+ expect(eval_('add(1, 2)', { add })).toBe(3);
182
+ });
183
+ });
184
+
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Built-in globals
188
+ // ---------------------------------------------------------------------------
189
+
190
+ describe('expression parser — built-in globals', () => {
191
+ it('Math', () => {
192
+ expect(eval_('Math.PI')).toBeCloseTo(3.14159);
193
+ expect(eval_('Math.max(1, 5, 3)')).toBe(5);
194
+ expect(eval_('Math.abs(-7)')).toBe(7);
195
+ });
196
+
197
+ it('JSON', () => {
198
+ expect(eval_("JSON.parse('{\"a\":1}')")).toEqual({ a: 1 });
199
+ });
200
+
201
+ it('parseInt/parseFloat', () => {
202
+ expect(eval_("parseInt('42')")).toBe(42);
203
+ expect(eval_("parseFloat('3.14')")).toBeCloseTo(3.14);
204
+ });
205
+
206
+ it('isNaN', () => {
207
+ expect(eval_('isNaN(NaN)')).toBe(true);
208
+ expect(eval_('isNaN(5)')).toBe(false);
209
+ });
210
+ });
211
+
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Template literals
215
+ // ---------------------------------------------------------------------------
216
+
217
+ describe('expression parser — template literals', () => {
218
+ it('simple interpolation', () => {
219
+ expect(eval_('`Hello ${name}`', { name: 'Tony' })).toBe('Hello Tony');
220
+ });
221
+
222
+ it('expression inside interpolation', () => {
223
+ expect(eval_('`${a + b}`', { a: 1, b: 2 })).toBe('3');
224
+ });
225
+
226
+ it('no interpolation', () => {
227
+ expect(eval_('`plain text`')).toBe('plain text');
228
+ });
229
+ });
230
+
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Array & object literals
234
+ // ---------------------------------------------------------------------------
235
+
236
+ describe('expression parser — array/object literals', () => {
237
+ it('array literal', () => {
238
+ expect(eval_('[1, 2, 3]')).toEqual([1, 2, 3]);
239
+ expect(eval_('[]')).toEqual([]);
240
+ });
241
+
242
+ it('object literal', () => {
243
+ expect(eval_("{ x: 1, y: 'two' }")).toEqual({ x: 1, y: 'two' });
244
+ });
245
+
246
+ it('shorthand property', () => {
247
+ expect(eval_('{ x }', { x: 42 })).toEqual({ x: 42 });
248
+ });
249
+ });
250
+
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Arrow functions
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe('expression parser — arrow functions', () => {
257
+ it('single-param arrow', () => {
258
+ const fn = eval_('x => x * 2');
259
+ expect(fn(3)).toBe(6);
260
+ });
261
+
262
+ it('multi-param arrow', () => {
263
+ const fn = eval_('(a, b) => a + b');
264
+ expect(fn(1, 2)).toBe(3);
265
+ });
266
+
267
+ it('no-param arrow', () => {
268
+ const fn = eval_('() => 42');
269
+ expect(fn()).toBe(42);
270
+ });
271
+
272
+ it('arrow with scope access', () => {
273
+ const items = [1, 2, 3];
274
+ expect(eval_('items.filter(x => x > 1)', { items })).toEqual([2, 3]);
275
+ });
276
+ });
277
+
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // typeof
281
+ // ---------------------------------------------------------------------------
282
+
283
+ describe('expression parser — typeof', () => {
284
+ it('typeof string', () => {
285
+ expect(eval_("typeof 'hello'")).toBe('string');
286
+ });
287
+
288
+ it('typeof number', () => {
289
+ expect(eval_('typeof 42')).toBe('number');
290
+ });
291
+
292
+ it('typeof undefined variable', () => {
293
+ expect(eval_('typeof missing')).toBe('undefined');
294
+ });
295
+ });
296
+
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Safety / security
300
+ // ---------------------------------------------------------------------------
301
+
302
+ describe('expression parser — safety', () => {
303
+ it('blocks constructor access', () => {
304
+ expect(eval_("''.constructor")).toBe(undefined);
305
+ });
306
+
307
+ it('blocks __proto__ access', () => {
308
+ expect(eval_("({}).__proto__", {})).toBe(undefined);
309
+ });
310
+
311
+ it('returns undefined for invalid expressions', () => {
312
+ expect(eval_('!!!!')).toBe(undefined);
313
+ });
314
+
315
+ it('handles deeply nested safe access', () => {
316
+ const data = { a: { b: { c: { d: 'deep' } } } };
317
+ expect(eval_('a.b.c.d', data)).toBe('deep');
318
+ });
319
+ });
320
+
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Multi-scope resolution
324
+ // ---------------------------------------------------------------------------
325
+
326
+ describe('expression parser — multi-scope', () => {
327
+ it('checks scope layers in order', () => {
328
+ expect(safeEval('x', [{ x: 'first' }, { x: 'second' }])).toBe('first');
329
+ });
330
+
331
+ it('falls through to second scope', () => {
332
+ expect(safeEval('y', [{ x: 1 }, { y: 2 }])).toBe(2);
333
+ });
334
+ });