@daz4126/helium 0.30.0 → 0.31.0

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/helium.test.js CHANGED
@@ -11,287 +11,308 @@ describe('Helium Reactive Library', () => {
11
11
  });
12
12
 
13
13
  afterEach(() => {
14
+ window.heliumTeardown?.();
14
15
  document.body.innerHTML = '';
15
16
  });
16
17
 
17
18
  describe('State Reactivity', () => {
18
- it('should initialize with default data', () => {
19
+ it('should initialize with default data', async () => {
19
20
  container.innerHTML = '<div @text="count"></div>';
20
- helium({ count: 5 });
21
+ await helium({ count: 5 });
21
22
  expect(container.querySelector('div').textContent).toBe('5');
22
23
  });
23
24
 
24
- it('should react to direct property assignment', (done) => {
25
+ it('should react to direct property assignment', async () => {
25
26
  container.innerHTML = '<div @text="count"></div>';
26
- const state = helium({ count: 0 });
27
-
28
- setTimeout(() => {
29
- state.count = 10;
30
- setTimeout(() => {
31
- expect(container.querySelector('div').textContent).toBe('10');
32
- done();
33
- }, 0);
34
- }, 0);
35
- });
36
-
37
- it('should react to nested object mutations', (done) => {
27
+ const state = await helium({ count: 0 });
28
+
29
+ state.count = 10;
30
+ await new Promise(r => setTimeout(r, 0));
31
+ expect(container.querySelector('div').textContent).toBe('10');
32
+ });
33
+
34
+ it('should react to nested object mutations', async () => {
38
35
  container.innerHTML = '<div @text="user.name"></div>';
39
- const state = helium({ user: { name: 'Alice' } });
40
-
41
- setTimeout(() => {
42
- state.user.name = 'Bob';
43
- setTimeout(() => {
44
- expect(container.querySelector('div').textContent).toBe('Bob');
45
- done();
46
- }, 0);
47
- }, 0);
48
- });
49
-
50
- it('should react to array push', (done) => {
36
+ const state = await helium({ user: { name: 'Alice' } });
37
+
38
+ state.user.name = 'Bob';
39
+ await new Promise(r => setTimeout(r, 0));
40
+ expect(container.querySelector('div').textContent).toBe('Bob');
41
+ });
42
+
43
+ it('should react to array push', async () => {
51
44
  container.innerHTML = '<div @text="items.length"></div>';
52
- const state = helium({ items: [1, 2, 3] });
53
-
54
- setTimeout(() => {
55
- state.items.push(4);
56
- setTimeout(() => {
57
- expect(container.querySelector('div').textContent).toBe('4');
58
- done();
59
- }, 0);
60
- }, 0);
61
- });
62
-
63
- it('should react to array index assignment', (done) => {
45
+ const state = await helium({ items: [1, 2, 3] });
46
+
47
+ state.items.push(4);
48
+ await new Promise(r => setTimeout(r, 0));
49
+ expect(container.querySelector('div').textContent).toBe('4');
50
+ });
51
+
52
+ it('should react to array index assignment', async () => {
64
53
  container.innerHTML = '<div @text="items[0]"></div>';
65
- const state = helium({ items: ['a', 'b', 'c'] });
66
-
67
- setTimeout(() => {
68
- state.items[0] = 'z';
69
- setTimeout(() => {
70
- expect(container.querySelector('div').textContent).toBe('z');
71
- done();
72
- }, 0);
73
- }, 0);
74
- });
75
-
76
- it('should react to complete array replacement', (done) => {
54
+ const state = await helium({ items: ['a', 'b', 'c'] });
55
+
56
+ state.items[0] = 'z';
57
+ await new Promise(r => setTimeout(r, 0));
58
+ expect(container.querySelector('div').textContent).toBe('z');
59
+ });
60
+
61
+ it('should react to complete array replacement', async () => {
77
62
  container.innerHTML = '<div @text="items.length"></div>';
78
- const state = helium({ items: [1, 2, 3] });
79
-
80
- setTimeout(() => {
81
- state.items = [...state.items, 4, 5];
82
- setTimeout(() => {
83
- expect(container.querySelector('div').textContent).toBe('5');
84
- done();
85
- }, 0);
86
- }, 0);
63
+ const state = await helium({ items: [1, 2, 3] });
64
+
65
+ state.items = [...state.items, 4, 5];
66
+ await new Promise(r => setTimeout(r, 0));
67
+ expect(container.querySelector('div').textContent).toBe('5');
87
68
  });
88
69
  });
89
70
 
90
71
  describe('Directives - @text and @html', () => {
91
- it('should bind text content with @text', () => {
72
+ it('should bind text content with @text', async () => {
92
73
  container.innerHTML = '<div @text="message"></div>';
93
- helium({ message: 'Hello World' });
74
+ await helium({ message: 'Hello World' });
94
75
  expect(container.querySelector('div').textContent).toBe('Hello World');
95
76
  });
96
77
 
97
- it('should bind HTML content with @html', () => {
78
+ it('should bind HTML content with @html', async () => {
98
79
  container.innerHTML = '<div @html="content"></div>';
99
- helium({ content: '<strong>Bold</strong>' });
80
+ await helium({ content: '<strong>Bold</strong>' });
100
81
  expect(container.querySelector('div').innerHTML).toBe('<strong>Bold</strong>');
101
82
  });
102
83
 
103
- it('should work with data-he-text prefix', () => {
84
+ it('should work with data-he-text prefix', async () => {
104
85
  container.innerHTML = '<div data-he-text="message"></div>';
105
- helium({ message: 'Test' });
86
+ await helium({ message: 'Test' });
106
87
  expect(container.querySelector('div').textContent).toBe('Test');
107
88
  });
108
89
 
109
- it('should evaluate expressions in @text', () => {
90
+ it('should evaluate expressions in @text', async () => {
110
91
  container.innerHTML = '<div @text="count * 2"></div>';
111
- helium({ count: 5 });
92
+ await helium({ count: 5 });
112
93
  expect(container.querySelector('div').textContent).toBe('10');
113
94
  });
114
95
  });
115
96
 
116
97
  describe('Directives - @bind', () => {
117
- it('should create two-way binding with input', () => {
98
+ it('should create two-way binding with input', async () => {
118
99
  container.innerHTML = '<input @bind="name" />';
119
- const state = helium({ name: 'John' });
100
+ await helium({ name: 'John' });
120
101
  const input = container.querySelector('input');
121
-
102
+
122
103
  expect(input.value).toBe('John');
123
104
  });
124
105
 
125
- it('should update state when input changes', (done) => {
106
+ it('should update state when input changes', async () => {
126
107
  container.innerHTML = '<input @bind="name" /><span @text="name"></span>';
127
- const state = helium({ name: 'John' });
108
+ await helium({ name: 'John' });
128
109
  const input = container.querySelector('input');
129
-
110
+
130
111
  input.value = 'Jane';
131
112
  input.dispatchEvent(new Event('input'));
132
-
133
- setTimeout(() => {
134
- expect(container.querySelector('span').textContent).toBe('Jane');
135
- done();
136
- }, 0);
113
+
114
+ await new Promise(r => setTimeout(r, 0));
115
+ expect(container.querySelector('span').textContent).toBe('Jane');
137
116
  });
138
117
  });
139
118
 
140
119
  describe('Directives - @hidden and @visible', () => {
141
- it('should hide element with @hidden when true', () => {
120
+ it('should hide element with @hidden when true', async () => {
142
121
  container.innerHTML = '<div @hidden="isHidden"></div>';
143
- helium({ isHidden: true });
122
+ await helium({ isHidden: true });
144
123
  expect(container.querySelector('div').hidden).toBe(true);
145
124
  });
146
125
 
147
- it('should show element with @hidden when false', () => {
126
+ it('should show element with @hidden when false', async () => {
148
127
  container.innerHTML = '<div @hidden="isHidden"></div>';
149
- helium({ isHidden: false });
128
+ await helium({ isHidden: false });
150
129
  expect(container.querySelector('div').hidden).toBe(false);
151
130
  });
152
131
 
153
- it('should show element with @visible when true', () => {
132
+ it('should show element with @visible when true', async () => {
154
133
  container.innerHTML = '<div @visible="isVisible"></div>';
155
- helium({ isVisible: true });
134
+ await helium({ isVisible: true });
156
135
  expect(container.querySelector('div').hidden).toBe(false);
157
136
  });
158
137
 
159
- it('should hide element with @visible when false', () => {
138
+ it('should hide element with @visible when false', async () => {
160
139
  container.innerHTML = '<div @visible="isVisible"></div>';
161
- helium({ isVisible: false });
140
+ await helium({ isVisible: false });
162
141
  expect(container.querySelector('div').hidden).toBe(true);
163
142
  });
164
143
  });
165
144
 
166
145
  describe('Directives - Dynamic Attributes (:attr)', () => {
167
- it('should bind attribute dynamically', () => {
146
+ it('should bind attribute dynamically', async () => {
168
147
  container.innerHTML = '<div :class="className"></div>';
169
- helium({ className: 'active' });
148
+ await helium({ className: 'active' });
170
149
  expect(container.querySelector('div').getAttribute('class')).toBe('active');
171
150
  });
172
151
 
173
- it('should update attribute on state change', (done) => {
152
+ it('should update attribute on state change', async () => {
174
153
  container.innerHTML = '<div :title="tooltip"></div>';
175
- const state = helium({ tooltip: 'Initial' });
176
-
177
- setTimeout(() => {
178
- state.tooltip = 'Updated';
179
- setTimeout(() => {
180
- expect(container.querySelector('div').getAttribute('title')).toBe('Updated');
181
- done();
182
- }, 0);
183
- }, 0);
154
+ const state = await helium({ tooltip: 'Initial' });
155
+
156
+ state.tooltip = 'Updated';
157
+ await new Promise(r => setTimeout(r, 0));
158
+ expect(container.querySelector('div').getAttribute('title')).toBe('Updated');
184
159
  });
185
160
  });
186
161
 
187
162
  describe('Event Handlers', () => {
188
- it('should handle click events', () => {
163
+ it('should handle click events', async () => {
189
164
  container.innerHTML = '<button @click="count++">Click</button><span @text="count"></span>';
190
- helium({ count: 0 });
191
-
165
+ await helium({ count: 0 });
166
+
192
167
  container.querySelector('button').click();
193
- setTimeout(() => {
194
- expect(container.querySelector('span').textContent).toBe('1');
195
- }, 0);
168
+ await new Promise(r => setTimeout(r, 0));
169
+ expect(container.querySelector('span').textContent).toBe('1');
196
170
  });
197
171
 
198
- it('should handle event with prevent modifier', () => {
172
+ it('should handle event with prevent modifier', async () => {
199
173
  container.innerHTML = '<form @submit.prevent="submitted = true"></form>';
200
- const state = helium({ submitted: false });
174
+ await helium({ submitted: false });
201
175
  const form = container.querySelector('form');
202
176
  const event = new Event('submit', { cancelable: true });
203
-
177
+
204
178
  form.dispatchEvent(event);
205
179
  expect(event.defaultPrevented).toBe(true);
206
180
  });
207
181
 
208
- it('should handle keyboard events with key modifiers', () => {
182
+ it('should handle keyboard events with key modifiers', async () => {
209
183
  container.innerHTML = '<input @keydown.enter="pressed = true" />';
210
- const state = helium({ pressed: false });
184
+ const state = await helium({ pressed: false });
211
185
  const input = container.querySelector('input');
212
-
186
+
213
187
  const event = new KeyboardEvent('keydown', { key: 'Enter' });
214
188
  input.dispatchEvent(event);
215
-
216
- setTimeout(() => {
217
- expect(state.pressed).toBe(true);
218
- }, 0);
189
+
190
+ await new Promise(r => setTimeout(r, 0));
191
+ expect(state.pressed).toBe(true);
219
192
  });
220
193
 
221
- it('should handle debounced events', (done) => {
194
+ it('should handle debounced events', async () => {
222
195
  container.innerHTML = '<input @input.debounce:100="value = $event.target.value" />';
223
- const state = helium({ value: '' });
196
+ const state = await helium({ value: '' });
224
197
  const input = container.querySelector('input');
225
-
198
+
226
199
  input.value = 'test';
227
200
  input.dispatchEvent(new Event('input'));
228
-
201
+
229
202
  // Should not update immediately
230
203
  expect(state.value).toBe('');
231
-
232
- setTimeout(() => {
233
- expect(state.value).toBe('test');
234
- done();
235
- }, 150);
204
+
205
+ await new Promise(resolve => setTimeout(resolve, 150));
206
+ expect(state.value).toBe('test');
236
207
  });
237
208
 
238
- it('should handle once modifier', () => {
209
+ it('should handle once modifier', async () => {
239
210
  container.innerHTML = '<button @click.once="count++">Click</button>';
240
- const state = helium({ count: 0 });
211
+ const state = await helium({ count: 0 });
241
212
  const button = container.querySelector('button');
242
-
213
+
243
214
  button.click();
244
215
  button.click();
245
216
  button.click();
246
-
247
- setTimeout(() => {
248
- expect(state.count).toBe(1);
249
- }, 0);
217
+
218
+ await new Promise(r => setTimeout(r, 0));
219
+ expect(state.count).toBe(1);
250
220
  });
251
221
  });
252
222
 
253
223
  describe('@ref Directive', () => {
254
- it('should create reference to element', () => {
224
+ it('should create reference to element', async () => {
255
225
  container.innerHTML = '<div @ref="myDiv"></div><button @click="$myDiv.textContent = \'Changed\'">Change</button>';
256
- helium({});
257
-
226
+ await helium({});
227
+
228
+ container.querySelector('button').click();
229
+ await new Promise(r => setTimeout(r, 0));
230
+ expect(container.querySelector('div').textContent).toBe('Changed');
231
+ });
232
+
233
+ it('should make refs available in @params for AJAX requests', async () => {
234
+ global.fetch = vi.fn().mockResolvedValueOnce({
235
+ headers: { get: () => 'application/json' },
236
+ json: async () => ({ success: true })
237
+ });
238
+
239
+ container.innerHTML = `
240
+ <form @ref="myForm">
241
+ <input name="title" value="Test Title">
242
+ <button @post="/api/test" @params="new FormData($myForm)">Submit</button>
243
+ </form>
244
+ `;
245
+ await helium({});
246
+
247
+ container.querySelector('button').click();
248
+ await new Promise(r => setTimeout(r, 100));
249
+
250
+ expect(global.fetch).toHaveBeenCalled();
251
+ const [url, options] = global.fetch.mock.calls[0];
252
+ expect(url).toBe('/api/test');
253
+ expect(options.body).toBeInstanceOf(FormData);
254
+ });
255
+
256
+ it('should make refs available with nested @data sections', async () => {
257
+ global.fetch = vi.fn().mockResolvedValueOnce({
258
+ headers: { get: () => 'application/json' },
259
+ json: async () => ({ success: true })
260
+ });
261
+
262
+ // Mimic the examples page structure with nested @data
263
+ container.innerHTML = `
264
+ <div @data="{ count: 0 }">
265
+ <div @data="{ templates: { post: data => data.title } }">
266
+ <section @ref="posts">
267
+ <form @ref="form">
268
+ <input name="title" value="Test">
269
+ <button @post.prevent="/api/posts" @params="new FormData($form)" @target="$posts:append" @template="templates.post">Submit</button>
270
+ </form>
271
+ </section>
272
+ </div>
273
+ </div>
274
+ `;
275
+ await helium({});
276
+
258
277
  container.querySelector('button').click();
259
- setTimeout(() => {
260
- expect(container.querySelector('div').textContent).toBe('Changed');
261
- }, 0);
278
+ await new Promise(r => setTimeout(r, 100));
279
+
280
+ expect(global.fetch).toHaveBeenCalled();
281
+ const [url, options] = global.fetch.mock.calls[0];
282
+ expect(url).toBe('/api/posts');
283
+ expect(options.body).toBeInstanceOf(FormData);
262
284
  });
263
285
  });
264
286
 
265
287
  describe('@init Directive', () => {
266
- it('should execute code on initialization', () => {
288
+ it('should execute code on initialization', async () => {
267
289
  container.innerHTML = '<div @init="initialized = true"></div>';
268
- const state = helium({ initialized: false });
290
+ const state = await helium({ initialized: false });
269
291
  expect(state.initialized).toBe(true);
270
292
  });
271
293
  });
272
294
 
273
295
  describe('@data Directive', () => {
274
- it('should merge data into state', () => {
296
+ it('should merge data into state', async () => {
275
297
  container.innerHTML = '<div @data="{ user: \'Alice\', age: 30 }"></div><span @text="user"></span>';
276
- helium({});
298
+ await helium({});
277
299
  expect(container.querySelector('span').textContent).toBe('Alice');
278
300
  });
279
301
  });
280
302
 
281
303
  describe('Helper Functions', () => {
282
- it('should provide $ selector helper', () => {
304
+ it('should provide $ selector helper', async () => {
283
305
  container.innerHTML = '<div id="target"></div><button @click="$(\'#target\').textContent = \'Found\'">Click</button>';
284
- helium({});
285
-
306
+ await helium({});
307
+
286
308
  container.querySelector('button').click();
287
- setTimeout(() => {
288
- expect(container.querySelector('#target').textContent).toBe('Found');
289
- }, 0);
309
+ await new Promise(r => setTimeout(r, 0));
310
+ expect(container.querySelector('#target').textContent).toBe('Found');
290
311
  });
291
312
 
292
- it('should provide $html helper to create elements', () => {
313
+ it('should provide $html helper to create elements', async () => {
293
314
  container.innerHTML = '<div @init="$el.appendChild($html(\'<span>Test</span>\'))"></div>';
294
- helium({});
315
+ await helium({});
295
316
  expect(container.querySelector('span').textContent).toBe('Test');
296
317
  });
297
318
  });
@@ -308,10 +329,10 @@ describe('Helium Reactive Library', () => {
308
329
  });
309
330
 
310
331
  container.innerHTML = '<button @click="$get(\'/api/data\', \'result\')">Load</button><div @text="result?.data"></div>';
311
- helium({ result: null });
312
-
332
+ await helium({ result: null });
333
+
313
334
  container.querySelector('button').click();
314
-
335
+
315
336
  await new Promise(resolve => setTimeout(resolve, 100));
316
337
  expect(global.fetch).toHaveBeenCalledWith('/api/data', expect.any(Object));
317
338
  });
@@ -323,10 +344,10 @@ describe('Helium Reactive Library', () => {
323
344
  });
324
345
 
325
346
  container.innerHTML = '<button @click="$post(\'/api/save\', { name: \'test\' }, \'response\')">Save</button>';
326
- helium({ response: null });
327
-
347
+ await helium({ response: null });
348
+
328
349
  container.querySelector('button').click();
329
-
350
+
330
351
  await new Promise(resolve => setTimeout(resolve, 100));
331
352
  expect(global.fetch).toHaveBeenCalledWith('/api/save', expect.objectContaining({
332
353
  method: 'POST',
@@ -335,107 +356,436 @@ describe('Helium Reactive Library', () => {
335
356
  });
336
357
  });
337
358
 
359
+ describe('Server-Sent Events (SSE)', () => {
360
+ // Helper to create a mock SSE ReadableStream
361
+ const createSSEStream = (events) => {
362
+ const encoder = new TextEncoder();
363
+ let index = 0;
364
+ return new ReadableStream({
365
+ pull(controller) {
366
+ if (index < events.length) {
367
+ controller.enqueue(encoder.encode(events[index]));
368
+ index++;
369
+ } else {
370
+ controller.close();
371
+ }
372
+ }
373
+ });
374
+ };
375
+
376
+ beforeEach(() => {
377
+ global.fetch = vi.fn();
378
+ });
379
+
380
+ it('should handle basic SSE stream updating DOM target', async () => {
381
+ // Simulate SSE response like Datastar hello-world example
382
+ const sseData = 'event: #message\ndata: <div>Hello from SSE!</div>\n\n';
383
+
384
+ global.fetch.mockResolvedValueOnce({
385
+ headers: { get: () => 'text/event-stream' },
386
+ body: createSSEStream([sseData])
387
+ });
388
+
389
+ container.innerHTML = `
390
+ <button @get="/api/hello-world" @target="#message">Start</button>
391
+ <div id="message"></div>
392
+ `;
393
+ await helium({});
394
+
395
+ container.querySelector('button').click();
396
+ await new Promise(resolve => setTimeout(resolve, 100));
397
+
398
+ expect(container.querySelector('#message').innerHTML).toBe('<div>Hello from SSE!</div>');
399
+ });
400
+
401
+ it('should handle multiple SSE events in sequence', async () => {
402
+ const sseData = [
403
+ 'event: #counter\ndata: <span>1</span>\n\n',
404
+ 'event: #counter\ndata: <span>2</span>\n\n',
405
+ 'event: #counter\ndata: <span>3</span>\n\n'
406
+ ];
407
+
408
+ global.fetch.mockResolvedValueOnce({
409
+ headers: { get: () => 'text/event-stream' },
410
+ body: createSSEStream(sseData)
411
+ });
412
+
413
+ container.innerHTML = `
414
+ <button @get="/api/count" @target="#counter">Count</button>
415
+ <div id="counter"></div>
416
+ `;
417
+ await helium({});
418
+
419
+ container.querySelector('button').click();
420
+ await new Promise(resolve => setTimeout(resolve, 100));
421
+
422
+ // Should have the last value after all events processed
423
+ expect(container.querySelector('#counter').innerHTML).toBe('<span>3</span>');
424
+ });
425
+
426
+ it('should update state when event targets a state property', async () => {
427
+ const sseData = 'event: message\ndata: Hello World\n\n';
428
+
429
+ global.fetch.mockResolvedValueOnce({
430
+ headers: { get: () => 'text/event-stream' },
431
+ body: createSSEStream([sseData])
432
+ });
433
+
434
+ container.innerHTML = `
435
+ <button @get="/api/message" @target="message">Load</button>
436
+ <span @text="message"></span>
437
+ `;
438
+ const state = await helium({ message: '' });
439
+
440
+ container.querySelector('button').click();
441
+ await new Promise(resolve => setTimeout(resolve, 100));
442
+
443
+ expect(state.message).toBe('Hello World');
444
+ expect(container.querySelector('span').textContent).toBe('Hello World');
445
+ });
446
+
447
+ it('should handle multiline data in SSE events', async () => {
448
+ const sseData = 'data: line 1\ndata: line 2\ndata: line 3\n\n';
449
+
450
+ global.fetch.mockResolvedValueOnce({
451
+ headers: { get: () => 'text/event-stream' },
452
+ body: createSSEStream([sseData])
453
+ });
454
+
455
+ container.innerHTML = `
456
+ <button @get="/api/multiline" @target="#output">Load</button>
457
+ <pre id="output"></pre>
458
+ `;
459
+ await helium({});
460
+
461
+ container.querySelector('button').click();
462
+ await new Promise(resolve => setTimeout(resolve, 100));
463
+
464
+ expect(container.querySelector('#output').innerHTML).toBe('line 1\nline 2\nline 3');
465
+ });
466
+
467
+ it('should use @target for SSE updates', async () => {
468
+ const sseData = 'data: <div>SSE content</div>\n\n';
469
+
470
+ global.fetch.mockResolvedValueOnce({
471
+ headers: { get: () => 'text/event-stream' },
472
+ body: createSSEStream([sseData])
473
+ });
474
+
475
+ container.innerHTML = `
476
+ <button @get="/api/simple" @target="#result">Load</button>
477
+ <div id="result"></div>
478
+ `;
479
+ await helium({});
480
+
481
+ container.querySelector('button').click();
482
+ await new Promise(resolve => setTimeout(resolve, 100));
483
+
484
+ expect(container.querySelector('#result').innerHTML).toBe('<div>SSE content</div>');
485
+ });
486
+
487
+ it('should track event id for potential reconnection', async () => {
488
+ const sseData = 'id: 42\ndata: <span>Connected</span>\n\n';
489
+
490
+ global.fetch.mockResolvedValueOnce({
491
+ headers: { get: () => 'text/event-stream' },
492
+ body: createSSEStream([sseData])
493
+ });
494
+
495
+ container.innerHTML = `
496
+ <button @get="/api/status" @target="#status">Connect</button>
497
+ <div id="status"></div>
498
+ `;
499
+ await helium({});
500
+
501
+ container.querySelector('button').click();
502
+ await new Promise(resolve => setTimeout(resolve, 100));
503
+
504
+ expect(container.querySelector('#status').innerHTML).toBe('<span>Connected</span>');
505
+ });
506
+
507
+ it('should handle chunked SSE data', async () => {
508
+ // Simulate data arriving in chunks (like real network)
509
+ const chunk1 = 'event: #result\nda';
510
+ const chunk2 = 'ta: <div>Chunked!</div>\n\n';
511
+
512
+ global.fetch.mockResolvedValueOnce({
513
+ headers: { get: () => 'text/event-stream' },
514
+ body: createSSEStream([chunk1, chunk2])
515
+ });
516
+
517
+ container.innerHTML = `
518
+ <button @get="/api/chunked" @target="#result">Load</button>
519
+ <div id="result"></div>
520
+ `;
521
+ await helium({});
522
+
523
+ container.querySelector('button').click();
524
+ await new Promise(resolve => setTimeout(resolve, 100));
525
+
526
+ expect(container.querySelector('#result').innerHTML).toBe('<div>Chunked!</div>');
527
+ });
528
+ });
529
+
338
530
  describe('Dynamic DOM Updates', () => {
339
- it('should process dynamically added elements', (done) => {
531
+ it('should process dynamically added elements', async () => {
340
532
  container.innerHTML = '<div id="container"></div>';
341
- const state = helium({ message: 'Dynamic' });
342
-
343
- setTimeout(() => {
344
- const newEl = document.createElement('span');
345
- newEl.setAttribute('@text', 'message');
346
- container.querySelector('#container').appendChild(newEl);
347
-
348
- setTimeout(() => {
349
- expect(newEl.textContent).toBe('Dynamic');
350
- done();
351
- }, 100);
352
- }, 0);
353
- });
354
-
355
- it('should handle innerHTML updates with new bindings', (done) => {
533
+ await helium({ message: 'Dynamic' });
534
+
535
+ const newEl = document.createElement('span');
536
+ newEl.setAttribute('data-he-text', 'message');
537
+ container.querySelector('#container').appendChild(newEl);
538
+
539
+ await new Promise(r => setTimeout(r, 100));
540
+ expect(newEl.textContent).toBe('Dynamic');
541
+ });
542
+
543
+ it('should handle innerHTML updates with new bindings', async () => {
356
544
  container.innerHTML = '<div @html="content"></div>';
357
- const state = helium({ content: '', name: 'Test' });
358
-
359
- setTimeout(() => {
360
- state.content = '<span @text="name"></span>';
361
- setTimeout(() => {
362
- expect(container.querySelector('span').textContent).toBe('Test');
363
- done();
364
- }, 100);
365
- }, 0);
545
+ const state = await helium({ content: '', name: 'Test' });
546
+
547
+ state.content = '<span data-he-text="name"></span>';
548
+ await new Promise(r => setTimeout(r, 100));
549
+ expect(container.querySelector('span').textContent).toBe('Test');
366
550
  });
367
551
  });
368
552
 
369
553
  describe('Edge Cases', () => {
370
- it('should handle undefined state values', () => {
554
+ it('should handle undefined state values', async () => {
371
555
  container.innerHTML = '<div @text="undefined"></div>';
372
- helium({});
373
- expect(container.querySelector('div').textContent).toBe('undefined');
556
+ await helium({});
557
+ expect(container.querySelector('div').textContent).toBe('');
374
558
  });
375
559
 
376
- it('should handle null state values', () => {
560
+ it('should handle null state values', async () => {
377
561
  container.innerHTML = '<div @text="value"></div>';
378
- helium({ value: null });
379
- expect(container.querySelector('div').textContent).toBe('null');
562
+ await helium({ value: null });
563
+ expect(container.querySelector('div').textContent).toBe('');
380
564
  });
381
565
 
382
566
  it('should handle circular references in state', () => {
383
567
  const obj = { name: 'test' };
384
568
  obj.self = obj;
385
-
569
+
386
570
  container.innerHTML = '<div @text="obj.name"></div>';
387
571
  expect(() => helium({ obj })).not.toThrow();
388
572
  });
389
573
 
390
- it('should not process already processed elements', () => {
574
+ it('should not process already processed elements', async () => {
391
575
  container.innerHTML = '<div @text="count"></div>';
392
- const state = helium({ count: 0 });
393
-
576
+ const state = await helium({ count: 0 });
577
+
394
578
  // Manually mark as processed
395
579
  container.querySelector('div').setAttribute('data-helium-processed', '');
396
-
580
+
397
581
  state.count = 5;
398
582
  // Should still work because binding was already created
399
- setTimeout(() => {
400
- expect(container.querySelector('div').textContent).toBe('5');
401
- }, 0);
583
+ await new Promise(r => setTimeout(r, 0));
584
+ expect(container.querySelector('div').textContent).toBe('5');
402
585
  });
403
586
  });
404
587
 
405
588
  describe('Performance', () => {
406
- it('should handle many bindings efficiently', () => {
407
- const html = Array.from({ length: 100 }, (_, i) =>
589
+ it('should handle many bindings efficiently', async () => {
590
+ const html = Array.from({ length: 100 }, (_, i) =>
408
591
  `<div @text="items[${i}]"></div>`
409
592
  ).join('');
410
-
593
+
411
594
  container.innerHTML = html;
412
595
  const items = Array.from({ length: 100 }, (_, i) => i);
413
-
596
+
414
597
  const start = performance.now();
415
- helium({ items });
598
+ await helium({ items });
416
599
  const end = performance.now();
417
-
600
+
418
601
  expect(end - start).toBeLessThan(100); // Should initialize in < 100ms
419
602
  });
420
603
 
421
- it('should not cause infinite loops with DOM mutations', (done) => {
604
+ it('should not cause infinite loops with DOM mutations', async () => {
422
605
  container.innerHTML = '<div @html="content"></div>';
423
- const state = helium({ content: '<span>Test</span>', updateCount: 0 });
424
-
606
+ const state = await helium({ content: '<span>Test</span>', updateCount: 0 });
607
+
425
608
  let mutationCount = 0;
426
609
  const observer = new MutationObserver(() => {
427
610
  mutationCount++;
428
611
  });
429
612
  observer.observe(container, { childList: true, subtree: true });
430
-
431
- setTimeout(() => {
432
- state.content = '<span>Updated</span>';
433
- setTimeout(() => {
434
- expect(mutationCount).toBeLessThan(10); // Should not cause excessive mutations
435
- observer.disconnect();
436
- done();
437
- }, 100);
438
- }, 0);
613
+
614
+ state.content = '<span>Updated</span>';
615
+ await new Promise(r => setTimeout(r, 100));
616
+ expect(mutationCount).toBeLessThan(10); // Should not cause excessive mutations
617
+ observer.disconnect();
439
618
  });
440
619
  });
441
620
  });
621
+
622
+ describe(':style Directive', () => {
623
+ let container;
624
+
625
+ beforeEach(() => {
626
+ container = document.createElement('div');
627
+ container.setAttribute('data-helium', '');
628
+ document.body.appendChild(container);
629
+ });
630
+
631
+ afterEach(() => {
632
+ window.heliumTeardown?.();
633
+ document.body.innerHTML = '';
634
+ });
635
+
636
+ it('should apply style object with camelCase properties', async () => {
637
+ container.innerHTML = '<div :style="{ backgroundColor: color }"></div>';
638
+ await helium({ color: 'blue' });
639
+ const div = container.querySelector('div');
640
+ expect(div.style.backgroundColor).toBe('blue');
641
+ });
642
+
643
+ it('should apply style object with kebab-case properties', async () => {
644
+ container.innerHTML = '<div :style="{ \'background-color\': color }"></div>';
645
+ await helium({ color: 'red' });
646
+ const div = container.querySelector('div');
647
+ expect(div.style.backgroundColor).toBe('red');
648
+ });
649
+
650
+ it('should apply multiple style properties', async () => {
651
+ container.innerHTML = '<div :style="{ backgroundColor: bg, fontSize: size }"></div>';
652
+ await helium({ bg: 'green', size: '20px' });
653
+ const div = container.querySelector('div');
654
+ expect(div.style.backgroundColor).toBe('green');
655
+ expect(div.style.fontSize).toBe('20px');
656
+ });
657
+
658
+ it('should update style when state changes', async () => {
659
+ container.innerHTML = '<div :style="{ backgroundColor: color }"></div>';
660
+ const state = await helium({ color: 'blue' });
661
+ const div = container.querySelector('div');
662
+ expect(div.style.backgroundColor).toBe('blue');
663
+
664
+ state.color = 'red';
665
+ await new Promise(r => setTimeout(r, 0));
666
+ expect(div.style.backgroundColor).toBe('red');
667
+ });
668
+
669
+ it('should filter out null and false values', async () => {
670
+ container.innerHTML = '<div :style="{ backgroundColor: bg, color: textColor }"></div>';
671
+ await helium({ bg: 'blue', textColor: null });
672
+ const div = container.querySelector('div');
673
+ expect(div.style.backgroundColor).toBe('blue');
674
+ expect(div.style.color).toBe('');
675
+ });
676
+
677
+ it('should handle width with percentage', async () => {
678
+ container.innerHTML = '<div :style="{ width: width + \'%\' }"></div>';
679
+ await helium({ width: 50 });
680
+ const div = container.querySelector('div');
681
+ expect(div.style.width).toBe('50%');
682
+ });
683
+ });
684
+
685
+ describe('@calculate', () => {
686
+ let container;
687
+
688
+ beforeEach(() => {
689
+ container = document.createElement('div');
690
+ container.setAttribute('data-helium', '');
691
+ document.body.appendChild(container);
692
+ });
693
+
694
+ afterEach(() => {
695
+ heliumTeardown();
696
+ document.body.innerHTML = '';
697
+ });
698
+
699
+ it('should compute derived values from state', async () => {
700
+ container.innerHTML = `
701
+ <div @data="{ firstname: 'John', lastname: 'Doe' }">
702
+ <span @calculate:fullname="firstname + ' ' + lastname"></span>
703
+ <span id="result" @text="fullname"></span>
704
+ </div>
705
+ `;
706
+ await helium();
707
+ expect(container.querySelector('#result').textContent).toBe('John Doe');
708
+ });
709
+
710
+ it('should update when dependencies change', async () => {
711
+ container.innerHTML = `
712
+ <div @data="{ x: 5, y: 3 }">
713
+ <span @calculate:sum="x + y"></span>
714
+ <span id="result" @text="sum"></span>
715
+ <button @click="x = 10">Update</button>
716
+ </div>
717
+ `;
718
+ const state = await helium();
719
+ expect(container.querySelector('#result').textContent).toBe('8');
720
+
721
+ container.querySelector('button').click();
722
+ await new Promise(r => setTimeout(r, 10));
723
+ expect(container.querySelector('#result').textContent).toBe('13');
724
+ });
725
+
726
+ it('should update @text with fallback expression when @calculate value changes', async () => {
727
+ container.innerHTML = `
728
+ <div @data="{ firstname: '', lastname: '' }">
729
+ <input id="first" @bind="firstname">
730
+ <input id="last" @bind="lastname">
731
+ <span @calculate:fullname="firstname && lastname ? firstname + ' ' + lastname : firstname || lastname || ''"></span>
732
+ <span id="result" @text="fullname || 'stranger'"></span>
733
+ </div>
734
+ `;
735
+ const state = await helium();
736
+
737
+ // Initially should show 'stranger' since fullname is ''
738
+ expect(container.querySelector('#result').textContent).toBe('stranger');
739
+
740
+ // Update firstname
741
+ const firstInput = container.querySelector('#first');
742
+ firstInput.value = 'John';
743
+ firstInput.dispatchEvent(new Event('input', { bubbles: true }));
744
+ await new Promise(r => setTimeout(r, 10));
745
+
746
+ // Should now show 'John' (since lastname is still empty, fullname = 'John')
747
+ expect(container.querySelector('#result').textContent).toBe('John');
748
+
749
+ // Update lastname
750
+ const lastInput = container.querySelector('#last');
751
+ lastInput.value = 'Doe';
752
+ lastInput.dispatchEvent(new Event('input', { bubbles: true }));
753
+ await new Promise(r => setTimeout(r, 10));
754
+
755
+ // Should now show 'John Doe'
756
+ expect(container.querySelector('#result').textContent).toBe('John Doe');
757
+ });
758
+
759
+ it('should handle password strength pattern with nested property access', async () => {
760
+ container.innerHTML = `
761
+ <div @data="{ password: '' }">
762
+ <input id="pw" type="password" @bind="password">
763
+ <span @calculate:strength="password.length < 4 ? 'Weak' : password.length < 8 ? 'Medium' : 'Strong'"></span>
764
+ <span id="result" @text="strength"></span>
765
+ </div>
766
+ `;
767
+ await helium();
768
+
769
+ // Initially should show 'Weak' (empty password)
770
+ expect(container.querySelector('#result').textContent).toBe('Weak');
771
+
772
+ // Type 'abc' (3 chars) - still Weak
773
+ const input = container.querySelector('#pw');
774
+ input.value = 'abc';
775
+ input.dispatchEvent(new Event('input', { bubbles: true }));
776
+ await new Promise(r => setTimeout(r, 10));
777
+ expect(container.querySelector('#result').textContent).toBe('Weak');
778
+
779
+ // Type 'abcdef' (6 chars) - Medium
780
+ input.value = 'abcdef';
781
+ input.dispatchEvent(new Event('input', { bubbles: true }));
782
+ await new Promise(r => setTimeout(r, 10));
783
+ expect(container.querySelector('#result').textContent).toBe('Medium');
784
+
785
+ // Type 'abcdefgh' (8 chars) - Strong
786
+ input.value = 'abcdefgh';
787
+ input.dispatchEvent(new Event('input', { bubbles: true }));
788
+ await new Promise(r => setTimeout(r, 10));
789
+ expect(container.querySelector('#result').textContent).toBe('Strong');
790
+ });
791
+ });