@daz4126/helium 0.31.0 → 1.0.0-beta.1
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/.claude/settings.local.json +3 -1
- package/CLAUDE.md +1 -1
- package/examples/index-lite.html +387 -0
- package/examples/index.html +11 -10
- package/examples/password-utils.js +80 -0
- package/helium-csp.js +240 -0
- package/helium-csp.test.js +776 -0
- package/helium-lite.js +366 -0
- package/helium-lite.test.js +120 -0
- package/helium.js +19 -7
- package/jexpr.js +666 -0
- package/jexpr.test.js +551 -0
- package/package.json +6 -1
- package/test-csp.html +50 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { helium, heliumTeardown, evaluate, preprocess } from './helium-csp.js';
|
|
3
|
+
|
|
4
|
+
describe('Helium CSP - CSP-Safe Reactive Library', () => {
|
|
5
|
+
let container;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
container = document.createElement('div');
|
|
9
|
+
container.setAttribute('data-helium', '');
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
heliumTeardown();
|
|
15
|
+
document.body.innerHTML = '';
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('Expression Parser', () => {
|
|
19
|
+
it('should preprocess count++ to assignment', () => {
|
|
20
|
+
expect(preprocess('count++')).toBe('(count = count + 1) - 1');
|
|
21
|
+
expect(preprocess('++count')).toBe('(count = count + 1)');
|
|
22
|
+
expect(preprocess('count--')).toBe('(count = count - 1) + 1');
|
|
23
|
+
expect(preprocess('--count')).toBe('(count = count - 1)');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should evaluate simple expressions', () => {
|
|
27
|
+
const scope = { count: 5, name: 'World' };
|
|
28
|
+
expect(evaluate('count', scope)).toBe(5);
|
|
29
|
+
expect(evaluate('count + 1', scope)).toBe(6);
|
|
30
|
+
expect(evaluate('name', scope)).toBe('World');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should evaluate property access', () => {
|
|
34
|
+
const scope = { user: { name: 'Alice' }, items: [1, 2, 3] };
|
|
35
|
+
expect(evaluate('user.name', scope)).toBe('Alice');
|
|
36
|
+
expect(evaluate('items[0]', scope)).toBe(1);
|
|
37
|
+
expect(evaluate('items.length', scope)).toBe(3);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should evaluate comparisons and ternary', () => {
|
|
41
|
+
const scope = { count: 5 };
|
|
42
|
+
expect(evaluate('count > 3', scope)).toBe(true);
|
|
43
|
+
expect(evaluate('count > 3 ? "big" : "small"', scope)).toBe('big');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should handle assignments', () => {
|
|
47
|
+
const scope = { count: 0 };
|
|
48
|
+
evaluate('count = 5', scope);
|
|
49
|
+
expect(scope.count).toBe(5);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should handle increment/decrement', () => {
|
|
53
|
+
const scope = { count: 5 };
|
|
54
|
+
expect(evaluate('count++', scope)).toBe(5);
|
|
55
|
+
expect(scope.count).toBe(6);
|
|
56
|
+
expect(evaluate('++count', scope)).toBe(7);
|
|
57
|
+
expect(scope.count).toBe(7);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should call functions', () => {
|
|
61
|
+
const scope = {
|
|
62
|
+
greet: (name) => `Hello, ${name}!`,
|
|
63
|
+
add: (a, b) => a + b
|
|
64
|
+
};
|
|
65
|
+
expect(evaluate('greet("World")', scope)).toBe('Hello, World!');
|
|
66
|
+
expect(evaluate('add(2, 3)', scope)).toBe(5);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should handle comma operator', () => {
|
|
70
|
+
const scope = { a: 0, b: 0, c: 0 };
|
|
71
|
+
// Comma operator evaluates all expressions, returns the last
|
|
72
|
+
expect(evaluate('a = 1, b = 2', scope)).toBe(2);
|
|
73
|
+
expect(scope.a).toBe(1);
|
|
74
|
+
expect(scope.b).toBe(2);
|
|
75
|
+
|
|
76
|
+
// Three expressions
|
|
77
|
+
expect(evaluate('a = 10, b = 20, c = 30', scope)).toBe(30);
|
|
78
|
+
expect(scope.a).toBe(10);
|
|
79
|
+
expect(scope.b).toBe(20);
|
|
80
|
+
expect(scope.c).toBe(30);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should not transform commas inside function calls or arrays', () => {
|
|
84
|
+
const scope = { add: (a, b) => a + b, arr: [] };
|
|
85
|
+
expect(evaluate('add(1, 2)', scope)).toBe(3);
|
|
86
|
+
expect(preprocess('add(1, 2)')).toBe('add(1, 2)');
|
|
87
|
+
expect(preprocess('[1, 2, 3]')).toBe('[1, 2, 3]');
|
|
88
|
+
expect(preprocess('{a: 1, b: 2}')).toBe('{a: 1, b: 2}');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('State Reactivity', () => {
|
|
93
|
+
it('should initialize with default data', async () => {
|
|
94
|
+
container.innerHTML = '<div @text="count"></div>';
|
|
95
|
+
await helium({ count: 5 });
|
|
96
|
+
expect(container.querySelector('div').textContent).toBe('5');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should react to direct property assignment', async () => {
|
|
100
|
+
container.innerHTML = '<div @text="count"></div>';
|
|
101
|
+
const state = await helium({ count: 0 });
|
|
102
|
+
|
|
103
|
+
state.count = 10;
|
|
104
|
+
await new Promise(r => setTimeout(r, 0));
|
|
105
|
+
expect(container.querySelector('div').textContent).toBe('10');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should react to nested object mutations', async () => {
|
|
109
|
+
container.innerHTML = '<div @text="user.name"></div>';
|
|
110
|
+
const state = await helium({ user: { name: 'Alice' } });
|
|
111
|
+
|
|
112
|
+
state.user.name = 'Bob';
|
|
113
|
+
await new Promise(r => setTimeout(r, 0));
|
|
114
|
+
expect(container.querySelector('div').textContent).toBe('Bob');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should react to array push', async () => {
|
|
118
|
+
container.innerHTML = '<div @text="items.length"></div>';
|
|
119
|
+
const state = await helium({ items: [1, 2, 3] });
|
|
120
|
+
|
|
121
|
+
state.items.push(4);
|
|
122
|
+
await new Promise(r => setTimeout(r, 0));
|
|
123
|
+
expect(container.querySelector('div').textContent).toBe('4');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should react to array index assignment', async () => {
|
|
127
|
+
container.innerHTML = '<div @text="items[0]"></div>';
|
|
128
|
+
const state = await helium({ items: ['a', 'b', 'c'] });
|
|
129
|
+
|
|
130
|
+
state.items[0] = 'z';
|
|
131
|
+
await new Promise(r => setTimeout(r, 0));
|
|
132
|
+
expect(container.querySelector('div').textContent).toBe('z');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should react to complete array replacement', async () => {
|
|
136
|
+
container.innerHTML = '<div @text="items.length"></div>';
|
|
137
|
+
const state = await helium({ items: [1, 2, 3] });
|
|
138
|
+
|
|
139
|
+
state.items = [...state.items, 4, 5];
|
|
140
|
+
await new Promise(r => setTimeout(r, 0));
|
|
141
|
+
expect(container.querySelector('div').textContent).toBe('5');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('Directives - @text and @html', () => {
|
|
146
|
+
it('should bind text content with @text', async () => {
|
|
147
|
+
container.innerHTML = '<div @text="message"></div>';
|
|
148
|
+
await helium({ message: 'Hello World' });
|
|
149
|
+
expect(container.querySelector('div').textContent).toBe('Hello World');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should bind HTML content with @html', async () => {
|
|
153
|
+
container.innerHTML = '<div @html="content"></div>';
|
|
154
|
+
await helium({ content: '<strong>Bold</strong>' });
|
|
155
|
+
expect(container.querySelector('div').innerHTML).toBe('<strong>Bold</strong>');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should work with data-he-text prefix', async () => {
|
|
159
|
+
container.innerHTML = '<div data-he-text="message"></div>';
|
|
160
|
+
await helium({ message: 'Test' });
|
|
161
|
+
expect(container.querySelector('div').textContent).toBe('Test');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should evaluate expressions in @text', async () => {
|
|
165
|
+
container.innerHTML = '<div @text="count * 2"></div>';
|
|
166
|
+
await helium({ count: 5 });
|
|
167
|
+
expect(container.querySelector('div').textContent).toBe('10');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should handle ternary in @text', async () => {
|
|
171
|
+
container.innerHTML = '<span @text="count > 5 ? \'big\' : \'small\'"></span>';
|
|
172
|
+
await helium({ count: 3 });
|
|
173
|
+
expect(container.querySelector('span').textContent).toBe('small');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('Directives - @bind', () => {
|
|
178
|
+
it('should create two-way binding with input', async () => {
|
|
179
|
+
container.innerHTML = '<input @bind="name" />';
|
|
180
|
+
await helium({ name: 'John' });
|
|
181
|
+
const input = container.querySelector('input');
|
|
182
|
+
|
|
183
|
+
expect(input.value).toBe('John');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should update state when input changes', async () => {
|
|
187
|
+
container.innerHTML = '<input @bind="name" /><span @text="name"></span>';
|
|
188
|
+
await helium({ name: 'John' });
|
|
189
|
+
const input = container.querySelector('input');
|
|
190
|
+
|
|
191
|
+
input.value = 'Jane';
|
|
192
|
+
input.dispatchEvent(new Event('input'));
|
|
193
|
+
|
|
194
|
+
await new Promise(r => setTimeout(r, 0));
|
|
195
|
+
expect(container.querySelector('span').textContent).toBe('Jane');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should handle checkbox binding', async () => {
|
|
199
|
+
container.innerHTML = '<input type="checkbox" @bind="checked" />';
|
|
200
|
+
const state = await helium({ checked: true });
|
|
201
|
+
const input = container.querySelector('input');
|
|
202
|
+
|
|
203
|
+
expect(input.checked).toBe(true);
|
|
204
|
+
|
|
205
|
+
input.checked = false;
|
|
206
|
+
input.dispatchEvent(new Event('change'));
|
|
207
|
+
|
|
208
|
+
expect(state.checked).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should handle radio button binding', async () => {
|
|
212
|
+
container.innerHTML = `
|
|
213
|
+
<input type="radio" name="color" value="red" @bind="color" />
|
|
214
|
+
<input type="radio" name="color" value="blue" @bind="color" />
|
|
215
|
+
`;
|
|
216
|
+
const state = await helium({ color: 'red' });
|
|
217
|
+
const [red, blue] = container.querySelectorAll('input');
|
|
218
|
+
|
|
219
|
+
expect(red.checked).toBe(true);
|
|
220
|
+
expect(blue.checked).toBe(false);
|
|
221
|
+
|
|
222
|
+
blue.checked = true;
|
|
223
|
+
blue.dispatchEvent(new Event('change'));
|
|
224
|
+
|
|
225
|
+
expect(state.color).toBe('blue');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should handle select binding', async () => {
|
|
229
|
+
container.innerHTML = `
|
|
230
|
+
<select @bind="choice">
|
|
231
|
+
<option value="a">A</option>
|
|
232
|
+
<option value="b">B</option>
|
|
233
|
+
</select>
|
|
234
|
+
`;
|
|
235
|
+
const state = await helium({ choice: 'b' });
|
|
236
|
+
const select = container.querySelector('select');
|
|
237
|
+
|
|
238
|
+
expect(select.value).toBe('b');
|
|
239
|
+
|
|
240
|
+
select.value = 'a';
|
|
241
|
+
select.dispatchEvent(new Event('change'));
|
|
242
|
+
|
|
243
|
+
expect(state.choice).toBe('a');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('Directives - @hidden and @visible', () => {
|
|
248
|
+
it('should hide element with @hidden when true', async () => {
|
|
249
|
+
container.innerHTML = '<div @hidden="isHidden"></div>';
|
|
250
|
+
await helium({ isHidden: true });
|
|
251
|
+
expect(container.querySelector('div').hidden).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should show element with @hidden when false', async () => {
|
|
255
|
+
container.innerHTML = '<div @hidden="isHidden"></div>';
|
|
256
|
+
await helium({ isHidden: false });
|
|
257
|
+
expect(container.querySelector('div').hidden).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should show element with @visible when true', async () => {
|
|
261
|
+
container.innerHTML = '<div @visible="isVisible"></div>';
|
|
262
|
+
await helium({ isVisible: true });
|
|
263
|
+
expect(container.querySelector('div').hidden).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should hide element with @visible when false', async () => {
|
|
267
|
+
container.innerHTML = '<div @visible="isVisible"></div>';
|
|
268
|
+
await helium({ isVisible: false });
|
|
269
|
+
expect(container.querySelector('div').hidden).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('Directives - Dynamic Attributes (:attr)', () => {
|
|
274
|
+
it('should bind attribute dynamically', async () => {
|
|
275
|
+
container.innerHTML = '<div :class="className"></div>';
|
|
276
|
+
await helium({ className: 'active' });
|
|
277
|
+
expect(container.querySelector('div').getAttribute('class')).toBe('active');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should update attribute on state change', async () => {
|
|
281
|
+
container.innerHTML = '<div :title="tooltip"></div>';
|
|
282
|
+
const state = await helium({ tooltip: 'Initial' });
|
|
283
|
+
|
|
284
|
+
state.tooltip = 'Updated';
|
|
285
|
+
await new Promise(r => setTimeout(r, 0));
|
|
286
|
+
expect(container.querySelector('div').getAttribute('title')).toBe('Updated');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should handle :class with object syntax', async () => {
|
|
290
|
+
container.innerHTML = '<div :class="{ active: isActive, disabled: isDisabled }"></div>';
|
|
291
|
+
await helium({ isActive: true, isDisabled: false });
|
|
292
|
+
const div = container.querySelector('div');
|
|
293
|
+
expect(div.classList.contains('active')).toBe(true);
|
|
294
|
+
expect(div.classList.contains('disabled')).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should handle :style with object syntax', async () => {
|
|
298
|
+
container.innerHTML = '<div :style="{ color: textColor }"></div>';
|
|
299
|
+
await helium({ textColor: 'red' });
|
|
300
|
+
expect(container.querySelector('div').style.color).toBe('red');
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('Event Handlers', () => {
|
|
305
|
+
it('should handle click events', async () => {
|
|
306
|
+
container.innerHTML = '<button @click="count++">Click</button><span @text="count"></span>';
|
|
307
|
+
await helium({ count: 0 });
|
|
308
|
+
|
|
309
|
+
container.querySelector('button').click();
|
|
310
|
+
await new Promise(r => setTimeout(r, 0));
|
|
311
|
+
expect(container.querySelector('span').textContent).toBe('1');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should handle event with prevent modifier', async () => {
|
|
315
|
+
container.innerHTML = '<form @submit.prevent="submitted = true"></form>';
|
|
316
|
+
await helium({ submitted: false });
|
|
317
|
+
const form = container.querySelector('form');
|
|
318
|
+
const event = new Event('submit', { cancelable: true });
|
|
319
|
+
|
|
320
|
+
form.dispatchEvent(event);
|
|
321
|
+
expect(event.defaultPrevented).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should handle event with stop modifier', async () => {
|
|
325
|
+
container.innerHTML = '<div @click="outer = true"><button @click.stop="inner = true">Click</button></div>';
|
|
326
|
+
const state = await helium({ outer: false, inner: false });
|
|
327
|
+
|
|
328
|
+
container.querySelector('button').click();
|
|
329
|
+
await new Promise(r => setTimeout(r, 0));
|
|
330
|
+
|
|
331
|
+
expect(state.inner).toBe(true);
|
|
332
|
+
expect(state.outer).toBe(false);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should handle keyboard events with key modifiers', async () => {
|
|
336
|
+
container.innerHTML = '<input @keydown.enter="pressed = true" />';
|
|
337
|
+
const state = await helium({ pressed: false });
|
|
338
|
+
const input = container.querySelector('input');
|
|
339
|
+
|
|
340
|
+
const event = new KeyboardEvent('keydown', { key: 'Enter' });
|
|
341
|
+
input.dispatchEvent(event);
|
|
342
|
+
|
|
343
|
+
await new Promise(r => setTimeout(r, 0));
|
|
344
|
+
expect(state.pressed).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should handle Space key', async () => {
|
|
348
|
+
container.innerHTML = '<button @keydown.space="pressed = true">Press</button>';
|
|
349
|
+
const state = await helium({ pressed: false });
|
|
350
|
+
const button = container.querySelector('button');
|
|
351
|
+
|
|
352
|
+
const event = new KeyboardEvent('keydown', { key: ' ' });
|
|
353
|
+
button.dispatchEvent(event);
|
|
354
|
+
|
|
355
|
+
await new Promise(r => setTimeout(r, 0));
|
|
356
|
+
expect(state.pressed).toBe(true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should handle Escape key as Esc', async () => {
|
|
360
|
+
container.innerHTML = '<input @keydown.esc="pressed = true" />';
|
|
361
|
+
const state = await helium({ pressed: false });
|
|
362
|
+
const input = container.querySelector('input');
|
|
363
|
+
|
|
364
|
+
const event = new KeyboardEvent('keydown', { key: 'Escape' });
|
|
365
|
+
input.dispatchEvent(event);
|
|
366
|
+
|
|
367
|
+
await new Promise(r => setTimeout(r, 0));
|
|
368
|
+
expect(state.pressed).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should handle modifier keys (ctrl, shift, alt, meta)', async () => {
|
|
372
|
+
container.innerHTML = '<input @keydown.ctrl.s="saved = true" />';
|
|
373
|
+
const state = await helium({ saved: false });
|
|
374
|
+
const input = container.querySelector('input');
|
|
375
|
+
|
|
376
|
+
// Without ctrl - should not trigger
|
|
377
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }));
|
|
378
|
+
await new Promise(r => setTimeout(r, 0));
|
|
379
|
+
expect(state.saved).toBe(false);
|
|
380
|
+
|
|
381
|
+
// With ctrl - should trigger
|
|
382
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }));
|
|
383
|
+
await new Promise(r => setTimeout(r, 0));
|
|
384
|
+
expect(state.saved).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should handle debounced events', async () => {
|
|
388
|
+
container.innerHTML = '<input @input.debounce:100="value = $event.target.value" />';
|
|
389
|
+
const state = await helium({ value: '' });
|
|
390
|
+
const input = container.querySelector('input');
|
|
391
|
+
|
|
392
|
+
input.value = 'test';
|
|
393
|
+
input.dispatchEvent(new Event('input'));
|
|
394
|
+
|
|
395
|
+
// Should not update immediately
|
|
396
|
+
expect(state.value).toBe('');
|
|
397
|
+
|
|
398
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
399
|
+
expect(state.value).toBe('test');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should handle once modifier', async () => {
|
|
403
|
+
container.innerHTML = '<button @click.once="count++">Click</button>';
|
|
404
|
+
const state = await helium({ count: 0 });
|
|
405
|
+
const button = container.querySelector('button');
|
|
406
|
+
|
|
407
|
+
button.click();
|
|
408
|
+
button.click();
|
|
409
|
+
button.click();
|
|
410
|
+
|
|
411
|
+
await new Promise(r => setTimeout(r, 0));
|
|
412
|
+
expect(state.count).toBe(1);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('@ref Directive', () => {
|
|
417
|
+
it('should create reference to element', async () => {
|
|
418
|
+
container.innerHTML = '<div @ref="myDiv"></div><button @click="$myDiv.textContent = \'Changed\'">Change</button>';
|
|
419
|
+
await helium({});
|
|
420
|
+
|
|
421
|
+
container.querySelector('button').click();
|
|
422
|
+
await new Promise(r => setTimeout(r, 0));
|
|
423
|
+
expect(container.querySelector('div').textContent).toBe('Changed');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('should make refs available in state', async () => {
|
|
427
|
+
container.innerHTML = '<input @ref="myInput" value="test" /><span @text="$myInput.value"></span>';
|
|
428
|
+
await helium({});
|
|
429
|
+
// The span should display the input's value via the ref
|
|
430
|
+
expect(container.querySelector('span').textContent).toBe('test');
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe('@init Directive', () => {
|
|
435
|
+
it('should execute code on initialization', async () => {
|
|
436
|
+
container.innerHTML = '<div @init="initialized = true"></div>';
|
|
437
|
+
const state = await helium({ initialized: false });
|
|
438
|
+
expect(state.initialized).toBe(true);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('should run after all bindings are set up', async () => {
|
|
442
|
+
container.innerHTML = '<span @text="value"></span><div @init="value = \'set by init\'"></div>';
|
|
443
|
+
await helium({ value: 'initial' });
|
|
444
|
+
await new Promise(r => setTimeout(r, 0));
|
|
445
|
+
expect(container.querySelector('span').textContent).toBe('set by init');
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('@data Directive', () => {
|
|
450
|
+
it('should merge data into state', async () => {
|
|
451
|
+
container.innerHTML = '<div @data="{ user: \'Alice\', age: 30 }"></div><span @text="user"></span>';
|
|
452
|
+
await helium({});
|
|
453
|
+
expect(container.querySelector('span').textContent).toBe('Alice');
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe('@calculate Directive', () => {
|
|
458
|
+
it('should create computed properties', async () => {
|
|
459
|
+
container.innerHTML = '<div @calculate:double="count * 2"></div><span @text="double"></span>';
|
|
460
|
+
const state = await helium({ count: 5 });
|
|
461
|
+
expect(container.querySelector('span').textContent).toBe('10');
|
|
462
|
+
|
|
463
|
+
state.count = 10;
|
|
464
|
+
await new Promise(r => setTimeout(r, 0));
|
|
465
|
+
expect(container.querySelector('span').textContent).toBe('20');
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe('@effect Directive', () => {
|
|
470
|
+
it('should run side effects on state change', async () => {
|
|
471
|
+
// Effect updates lastSeen which doesn't trigger the effect (different key)
|
|
472
|
+
container.innerHTML = '<div @effect="lastSeen = count"></div>';
|
|
473
|
+
const state = await helium({ count: 0, lastSeen: -1 });
|
|
474
|
+
|
|
475
|
+
state.count = 1;
|
|
476
|
+
await new Promise(r => setTimeout(r, 0));
|
|
477
|
+
expect(state.lastSeen).toBe(1);
|
|
478
|
+
|
|
479
|
+
state.count = 2;
|
|
480
|
+
await new Promise(r => setTimeout(r, 0));
|
|
481
|
+
expect(state.lastSeen).toBe(2);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('should support wildcard @effect:*', async () => {
|
|
485
|
+
// Effect tracks any change via a different state key
|
|
486
|
+
container.innerHTML = '<div @effect:*="effectRan = anyValue > 0"></div>';
|
|
487
|
+
const state = await helium({ effectRan: false, anyValue: 0 });
|
|
488
|
+
|
|
489
|
+
state.anyValue = 42;
|
|
490
|
+
await new Promise(r => setTimeout(r, 0));
|
|
491
|
+
|
|
492
|
+
expect(state.effectRan).toBe(true);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe('Helper Functions', () => {
|
|
497
|
+
it('should provide $ selector helper', async () => {
|
|
498
|
+
container.innerHTML = '<div id="target"></div><button @click="$(\'#target\').textContent = \'Found\'">Click</button>';
|
|
499
|
+
await helium({});
|
|
500
|
+
|
|
501
|
+
container.querySelector('button').click();
|
|
502
|
+
await new Promise(r => setTimeout(r, 0));
|
|
503
|
+
expect(container.querySelector('#target').textContent).toBe('Found');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('should provide $html helper to create elements', async () => {
|
|
507
|
+
container.innerHTML = '<div @init="$el.appendChild($html(\'<span>Test</span>\'))"></div>';
|
|
508
|
+
await helium({});
|
|
509
|
+
expect(container.querySelector('span').textContent).toBe('Test');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('should provide $event in event handlers', async () => {
|
|
513
|
+
container.innerHTML = '<input @input="lastKey = $event.target.value" />';
|
|
514
|
+
const state = await helium({ lastKey: '' });
|
|
515
|
+
const input = container.querySelector('input');
|
|
516
|
+
|
|
517
|
+
input.value = 'x';
|
|
518
|
+
input.dispatchEvent(new Event('input'));
|
|
519
|
+
|
|
520
|
+
expect(state.lastKey).toBe('x');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('should provide $el in expressions', async () => {
|
|
524
|
+
container.innerHTML = '<button @click="$el.textContent = \'Clicked\'">Click me</button>';
|
|
525
|
+
await helium({});
|
|
526
|
+
|
|
527
|
+
const button = container.querySelector('button');
|
|
528
|
+
button.click();
|
|
529
|
+
|
|
530
|
+
expect(button.textContent).toBe('Clicked');
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
describe('AJAX Helpers', () => {
|
|
535
|
+
beforeEach(() => {
|
|
536
|
+
global.fetch = vi.fn();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('should call GET request with $get', async () => {
|
|
540
|
+
global.fetch.mockResolvedValueOnce({
|
|
541
|
+
headers: { get: () => 'application/json' },
|
|
542
|
+
json: async () => ({ data: 'test' })
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
container.innerHTML = '<button @click="$get(\'/api/data\', \'result\')">Load</button><div @text="result?.data"></div>';
|
|
546
|
+
await helium({ result: null });
|
|
547
|
+
|
|
548
|
+
container.querySelector('button').click();
|
|
549
|
+
|
|
550
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
551
|
+
expect(global.fetch).toHaveBeenCalledWith('/api/data', expect.any(Object));
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('should call POST request with $post', async () => {
|
|
555
|
+
global.fetch.mockResolvedValueOnce({
|
|
556
|
+
headers: { get: () => 'application/json' },
|
|
557
|
+
json: async () => ({ success: true })
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
container.innerHTML = '<button @click="$post(\'/api/save\', { name: \'test\' }, \'response\')">Save</button>';
|
|
561
|
+
await helium({ response: null });
|
|
562
|
+
|
|
563
|
+
container.querySelector('button').click();
|
|
564
|
+
|
|
565
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
566
|
+
expect(global.fetch).toHaveBeenCalledWith('/api/save', expect.objectContaining({
|
|
567
|
+
method: 'POST',
|
|
568
|
+
body: JSON.stringify({ name: 'test' })
|
|
569
|
+
}));
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
describe('Dynamic DOM Updates', () => {
|
|
574
|
+
it('should process dynamically added elements', async () => {
|
|
575
|
+
container.innerHTML = '<div id="container"></div>';
|
|
576
|
+
await helium({ message: 'Dynamic' });
|
|
577
|
+
|
|
578
|
+
const newEl = document.createElement('span');
|
|
579
|
+
newEl.setAttribute('data-he-text', 'message');
|
|
580
|
+
container.querySelector('#container').appendChild(newEl);
|
|
581
|
+
|
|
582
|
+
await new Promise(r => setTimeout(r, 100));
|
|
583
|
+
expect(newEl.textContent).toBe('Dynamic');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('should cleanup listeners when elements are removed', async () => {
|
|
587
|
+
container.innerHTML = '<div id="wrapper"><button @click="count++">Click</button></div>';
|
|
588
|
+
const state = await helium({ count: 0 });
|
|
589
|
+
|
|
590
|
+
const button = container.querySelector('button');
|
|
591
|
+
button.click();
|
|
592
|
+
await new Promise(r => setTimeout(r, 0));
|
|
593
|
+
expect(state.count).toBe(1);
|
|
594
|
+
|
|
595
|
+
// Remove the element
|
|
596
|
+
container.querySelector('#wrapper').innerHTML = '';
|
|
597
|
+
await new Promise(r => setTimeout(r, 50));
|
|
598
|
+
|
|
599
|
+
// The button should no longer exist and listeners should be cleaned up
|
|
600
|
+
expect(container.querySelector('button')).toBe(null);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe('Template Literals', () => {
|
|
605
|
+
it('should evaluate simple template literal', async () => {
|
|
606
|
+
container.innerHTML = '<div @text="`Hello World`"></div>';
|
|
607
|
+
await helium();
|
|
608
|
+
expect(container.querySelector('div').textContent).toBe('Hello World');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('should interpolate single variable', async () => {
|
|
612
|
+
container.innerHTML = '<div @text="`Hello ${name}!`"></div>';
|
|
613
|
+
await helium({ name: 'Alice' });
|
|
614
|
+
expect(container.querySelector('div').textContent).toBe('Hello Alice!');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('should interpolate multiple variables', async () => {
|
|
618
|
+
container.innerHTML = '<div @text="`${greeting}, ${name}!`"></div>';
|
|
619
|
+
await helium({ greeting: 'Hi', name: 'Bob' });
|
|
620
|
+
expect(container.querySelector('div').textContent).toBe('Hi, Bob!');
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('should interpolate expressions', async () => {
|
|
624
|
+
container.innerHTML = '<div @text="`Count: ${count + 1}`"></div>';
|
|
625
|
+
await helium({ count: 5 });
|
|
626
|
+
expect(container.querySelector('div').textContent).toBe('Count: 6');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('should handle template at start of expression', async () => {
|
|
630
|
+
container.innerHTML = '<div @text="`${name}`"></div>';
|
|
631
|
+
await helium({ name: 'test' });
|
|
632
|
+
expect(container.querySelector('div').textContent).toBe('test');
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('should handle adjacent dollar signs', async () => {
|
|
636
|
+
container.innerHTML = '<div @text="`Price: $${price}`"></div>';
|
|
637
|
+
await helium({ price: 99 });
|
|
638
|
+
expect(container.querySelector('div').textContent).toBe('Price: $99');
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('should handle empty template literal', async () => {
|
|
642
|
+
container.innerHTML = '<div @text="``"></div>';
|
|
643
|
+
await helium();
|
|
644
|
+
expect(container.querySelector('div').textContent).toBe('');
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('should handle template with no interpolation', async () => {
|
|
648
|
+
container.innerHTML = '<div @text="`Just a string`"></div>';
|
|
649
|
+
await helium();
|
|
650
|
+
expect(container.querySelector('div').textContent).toBe('Just a string');
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('should handle nested braces in interpolation', async () => {
|
|
654
|
+
container.innerHTML = '<div @text="`Result: ${obj.value}`"></div>';
|
|
655
|
+
await helium({ obj: { value: 42 } });
|
|
656
|
+
expect(container.querySelector('div').textContent).toBe('Result: 42');
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('should handle method calls in interpolation', async () => {
|
|
660
|
+
container.innerHTML = '<div @text="`Name: ${name.toUpperCase()}`"></div>';
|
|
661
|
+
await helium({ name: 'alice' });
|
|
662
|
+
expect(container.querySelector('div').textContent).toBe('Name: ALICE');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('should handle ternary in interpolation', async () => {
|
|
666
|
+
container.innerHTML = '<div @text="`Status: ${active ? \'on\' : \'off\'}`"></div>';
|
|
667
|
+
await helium({ active: true });
|
|
668
|
+
expect(container.querySelector('div').textContent).toBe('Status: on');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('should escape single quotes in static parts', async () => {
|
|
672
|
+
container.innerHTML = `<div @text="\`It's working\`"></div>`;
|
|
673
|
+
await helium();
|
|
674
|
+
expect(container.querySelector('div').textContent).toBe("It's working");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('should handle escaped backticks', async () => {
|
|
678
|
+
container.innerHTML = '<div @text="`Code: \\`test\\``"></div>';
|
|
679
|
+
await helium();
|
|
680
|
+
expect(container.querySelector('div').textContent).toBe('Code: `test`');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('should handle escaped dollar signs', async () => {
|
|
684
|
+
container.innerHTML = '<div @text="`Cost: \\${100}`"></div>';
|
|
685
|
+
await helium();
|
|
686
|
+
expect(container.querySelector('div').textContent).toBe('Cost: ${100}');
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('should update reactively', async () => {
|
|
690
|
+
container.innerHTML = '<div @text="`Count: ${count}`"></div>';
|
|
691
|
+
const state = await helium({ count: 0 });
|
|
692
|
+
expect(container.querySelector('div').textContent).toBe('Count: 0');
|
|
693
|
+
state.count = 5;
|
|
694
|
+
await new Promise(r => setTimeout(r, 0));
|
|
695
|
+
expect(container.querySelector('div').textContent).toBe('Count: 5');
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
describe('Edge Cases', () => {
|
|
700
|
+
it('should handle undefined state values', async () => {
|
|
701
|
+
container.innerHTML = '<div @text="undefined"></div>';
|
|
702
|
+
await helium({});
|
|
703
|
+
expect(container.querySelector('div').textContent).toBe('');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('should handle null state values', async () => {
|
|
707
|
+
container.innerHTML = '<div @text="value"></div>';
|
|
708
|
+
await helium({ value: null });
|
|
709
|
+
expect(container.querySelector('div').textContent).toBe('');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('should handle circular references in state', () => {
|
|
713
|
+
const obj = { name: 'test' };
|
|
714
|
+
obj.self = obj;
|
|
715
|
+
|
|
716
|
+
container.innerHTML = '<div @text="obj.name"></div>';
|
|
717
|
+
expect(() => helium({ obj })).not.toThrow();
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('should not process already processed elements', async () => {
|
|
721
|
+
container.innerHTML = '<div @text="count"></div>';
|
|
722
|
+
const state = await helium({ count: 0 });
|
|
723
|
+
|
|
724
|
+
state.count = 5;
|
|
725
|
+
// Should still work because binding was already created
|
|
726
|
+
await new Promise(r => setTimeout(r, 0));
|
|
727
|
+
expect(container.querySelector('div').textContent).toBe('5');
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('should handle reserved words in expressions', async () => {
|
|
731
|
+
container.innerHTML = '<div @text="true"></div>';
|
|
732
|
+
await helium({});
|
|
733
|
+
expect(container.querySelector('div').textContent).toBe('true');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('should not auto-initialize reserved words as state', async () => {
|
|
737
|
+
container.innerHTML = '<span @text="null">placeholder</span>';
|
|
738
|
+
const state = await helium({});
|
|
739
|
+
// Use Object.keys since state is a Proxy that returns true for all `in` checks
|
|
740
|
+
expect(Object.keys(state).includes('null')).toBe(false);
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
describe('Performance', () => {
|
|
745
|
+
it('should handle many bindings efficiently', async () => {
|
|
746
|
+
const html = Array.from({ length: 100 }, (_, i) =>
|
|
747
|
+
`<div @text="items[${i}]"></div>`
|
|
748
|
+
).join('');
|
|
749
|
+
|
|
750
|
+
container.innerHTML = html;
|
|
751
|
+
const items = Array.from({ length: 100 }, (_, i) => i);
|
|
752
|
+
|
|
753
|
+
const start = performance.now();
|
|
754
|
+
await helium({ items });
|
|
755
|
+
const end = performance.now();
|
|
756
|
+
|
|
757
|
+
expect(end - start).toBeLessThan(500); // Should initialize reasonably fast
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('should not cause infinite loops with DOM mutations', async () => {
|
|
761
|
+
container.innerHTML = '<div @html="content"></div>';
|
|
762
|
+
const state = await helium({ content: '<span>Test</span>', updateCount: 0 });
|
|
763
|
+
|
|
764
|
+
let mutationCount = 0;
|
|
765
|
+
const observer = new MutationObserver(() => {
|
|
766
|
+
mutationCount++;
|
|
767
|
+
});
|
|
768
|
+
observer.observe(container, { childList: true, subtree: true });
|
|
769
|
+
|
|
770
|
+
state.content = '<span>Updated</span>';
|
|
771
|
+
await new Promise(r => setTimeout(r, 100));
|
|
772
|
+
expect(mutationCount).toBeLessThan(10); // Should not cause excessive mutations
|
|
773
|
+
observer.disconnect();
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
});
|