zero-query 0.6.3 → 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.
- package/README.md +6 -6
- package/cli/commands/build.js +3 -3
- package/cli/commands/bundle.js +286 -8
- package/cli/commands/dev/index.js +2 -2
- package/cli/commands/dev/overlay.js +51 -2
- package/cli/commands/dev/server.js +34 -5
- package/cli/commands/dev/watcher.js +33 -0
- package/cli/scaffold/index.html +1 -0
- package/cli/scaffold/scripts/app.js +15 -22
- package/cli/scaffold/scripts/components/contacts/contacts.css +0 -7
- package/cli/scaffold/scripts/components/contacts/contacts.html +3 -3
- package/cli/scaffold/styles/styles.css +1 -0
- package/cli/utils.js +111 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +379 -27
- package/dist/zquery.min.js +3 -16
- package/index.d.ts +127 -1290
- package/package.json +5 -5
- package/src/component.js +11 -1
- package/src/core.js +305 -10
- package/src/router.js +49 -2
- package/tests/component.test.js +304 -0
- package/tests/core.test.js +726 -0
- package/tests/diff.test.js +194 -0
- package/tests/errors.test.js +162 -0
- package/tests/expression.test.js +334 -0
- package/tests/http.test.js +181 -0
- package/tests/reactive.test.js +191 -0
- package/tests/router.test.js +332 -0
- package/tests/store.test.js +253 -0
- package/tests/utils.test.js +353 -0
- package/types/collection.d.ts +368 -0
- package/types/component.d.ts +210 -0
- package/types/errors.d.ts +103 -0
- package/types/http.d.ts +81 -0
- package/types/misc.d.ts +166 -0
- package/types/reactive.d.ts +76 -0
- package/types/router.d.ts +132 -0
- package/types/ssr.d.ts +49 -0
- package/types/store.d.ts +107 -0
- package/types/utils.d.ts +142 -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
|
+
});
|