@pyreon/runtime-dom 0.12.7 → 0.12.8
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.
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-World Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests that simulate real application patterns — multiple features combined.
|
|
5
|
+
* Todo app, form validation, tab component, nested context.
|
|
6
|
+
*/
|
|
7
|
+
import type { VNodeChild } from '@pyreon/core'
|
|
8
|
+
import { createContext, For, Fragment, h, onMount, provide, Show, useContext } from '@pyreon/core'
|
|
9
|
+
import { signal } from '@pyreon/reactivity'
|
|
10
|
+
import { mount } from '../index'
|
|
11
|
+
|
|
12
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function container(): HTMLElement {
|
|
15
|
+
const el = document.createElement('div')
|
|
16
|
+
document.body.appendChild(el)
|
|
17
|
+
return el
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
document.body.innerHTML = ''
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// ─── Todo App Pattern ───────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('real-world — todo app pattern', () => {
|
|
27
|
+
type Todo = {
|
|
28
|
+
id: number
|
|
29
|
+
text: ReturnType<typeof signal<string>>
|
|
30
|
+
completed: ReturnType<typeof signal<boolean>>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createTodo(id: number, text: string, completed = false): Todo {
|
|
34
|
+
return { id, text: signal(text), completed: signal(completed) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TodoItem = (props: {
|
|
38
|
+
text: () => string
|
|
39
|
+
completed: () => boolean
|
|
40
|
+
onToggle: () => void
|
|
41
|
+
onDelete: () => void
|
|
42
|
+
}) =>
|
|
43
|
+
h('li', { class: () => (props.completed() ? 'done' : 'pending') },
|
|
44
|
+
h('span', { class: 'todo-text' }, () => props.text()),
|
|
45
|
+
h('button', { class: 'toggle', onClick: props.onToggle }, 'toggle'),
|
|
46
|
+
h('button', { class: 'delete', onClick: props.onDelete }, 'delete'),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
test('render list of todos with For', () => {
|
|
50
|
+
const el = container()
|
|
51
|
+
const todos = signal<Todo[]>([
|
|
52
|
+
createTodo(1, 'Buy milk'),
|
|
53
|
+
createTodo(2, 'Write tests', true),
|
|
54
|
+
createTodo(3, 'Ship feature'),
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
mount(
|
|
58
|
+
h('ul', null,
|
|
59
|
+
For({
|
|
60
|
+
each: todos,
|
|
61
|
+
by: (t: Todo) => t.id,
|
|
62
|
+
children: (t: Todo) =>
|
|
63
|
+
h(TodoItem, {
|
|
64
|
+
text: () => t.text(),
|
|
65
|
+
completed: () => t.completed(),
|
|
66
|
+
onToggle: () => t.completed.update((c) => !c),
|
|
67
|
+
onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
|
|
68
|
+
}),
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
el,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expect(el.querySelectorAll('li').length).toBe(3)
|
|
75
|
+
expect(el.querySelectorAll('.todo-text')[0]?.textContent).toBe('Buy milk')
|
|
76
|
+
expect(el.querySelectorAll('.todo-text')[1]?.textContent).toBe('Write tests')
|
|
77
|
+
expect(el.querySelectorAll('li')[1]?.className).toBe('done')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('add a todo -> appears in DOM', () => {
|
|
81
|
+
const el = container()
|
|
82
|
+
const todos = signal<Todo[]>([
|
|
83
|
+
createTodo(1, 'Existing'),
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
mount(
|
|
87
|
+
h('ul', null,
|
|
88
|
+
For({
|
|
89
|
+
each: todos,
|
|
90
|
+
by: (t: Todo) => t.id,
|
|
91
|
+
children: (t: Todo) =>
|
|
92
|
+
h(TodoItem, {
|
|
93
|
+
text: () => t.text(),
|
|
94
|
+
completed: () => t.completed(),
|
|
95
|
+
onToggle: () => t.completed.update((c) => !c),
|
|
96
|
+
onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
|
|
97
|
+
}),
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
el,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
expect(el.querySelectorAll('li').length).toBe(1)
|
|
104
|
+
|
|
105
|
+
// Add new todo
|
|
106
|
+
todos.update((list) => [...list, createTodo(2, 'New todo')])
|
|
107
|
+
expect(el.querySelectorAll('li').length).toBe(2)
|
|
108
|
+
expect(el.querySelectorAll('.todo-text')[1]?.textContent).toBe('New todo')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('toggle todo complete -> class changes on that item only', () => {
|
|
112
|
+
const el = container()
|
|
113
|
+
const todo1 = createTodo(1, 'First')
|
|
114
|
+
const todo2 = createTodo(2, 'Second')
|
|
115
|
+
const todos = signal<Todo[]>([todo1, todo2])
|
|
116
|
+
|
|
117
|
+
mount(
|
|
118
|
+
h('ul', null,
|
|
119
|
+
For({
|
|
120
|
+
each: todos,
|
|
121
|
+
by: (t: Todo) => t.id,
|
|
122
|
+
children: (t: Todo) =>
|
|
123
|
+
h(TodoItem, {
|
|
124
|
+
text: () => t.text(),
|
|
125
|
+
completed: () => t.completed(),
|
|
126
|
+
onToggle: () => t.completed.update((c) => !c),
|
|
127
|
+
onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
|
|
128
|
+
}),
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
131
|
+
el,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// Both start as pending
|
|
135
|
+
expect(el.querySelectorAll('li')[0]?.className).toBe('pending')
|
|
136
|
+
expect(el.querySelectorAll('li')[1]?.className).toBe('pending')
|
|
137
|
+
|
|
138
|
+
// Toggle first todo via click
|
|
139
|
+
el.querySelectorAll('.toggle')[0]?.dispatchEvent(new Event('click', { bubbles: true }))
|
|
140
|
+
expect(el.querySelectorAll('li')[0]?.className).toBe('done')
|
|
141
|
+
expect(el.querySelectorAll('li')[1]?.className).toBe('pending')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('delete todo -> removed from DOM, others unchanged', () => {
|
|
145
|
+
const el = container()
|
|
146
|
+
const todos = signal<Todo[]>([
|
|
147
|
+
createTodo(1, 'Keep'),
|
|
148
|
+
createTodo(2, 'Delete me'),
|
|
149
|
+
createTodo(3, 'Also keep'),
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
mount(
|
|
153
|
+
h('ul', null,
|
|
154
|
+
For({
|
|
155
|
+
each: todos,
|
|
156
|
+
by: (t: Todo) => t.id,
|
|
157
|
+
children: (t: Todo) =>
|
|
158
|
+
h(TodoItem, {
|
|
159
|
+
text: () => t.text(),
|
|
160
|
+
completed: () => t.completed(),
|
|
161
|
+
onToggle: () => t.completed.update((c) => !c),
|
|
162
|
+
onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
|
|
163
|
+
}),
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
166
|
+
el,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(el.querySelectorAll('li').length).toBe(3)
|
|
170
|
+
|
|
171
|
+
// Delete second item via click
|
|
172
|
+
el.querySelectorAll('.delete')[1]?.dispatchEvent(new Event('click', { bubbles: true }))
|
|
173
|
+
expect(el.querySelectorAll('li').length).toBe(2)
|
|
174
|
+
expect(el.querySelectorAll('.todo-text')[0]?.textContent).toBe('Keep')
|
|
175
|
+
expect(el.querySelectorAll('.todo-text')[1]?.textContent).toBe('Also keep')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('edit todo text -> only that text node updates', () => {
|
|
179
|
+
const el = container()
|
|
180
|
+
const todo1 = createTodo(1, 'Original text')
|
|
181
|
+
const todo2 = createTodo(2, 'Other text')
|
|
182
|
+
const todos = signal<Todo[]>([todo1, todo2])
|
|
183
|
+
|
|
184
|
+
mount(
|
|
185
|
+
h('ul', null,
|
|
186
|
+
For({
|
|
187
|
+
each: todos,
|
|
188
|
+
by: (t: Todo) => t.id,
|
|
189
|
+
children: (t: Todo) =>
|
|
190
|
+
h(TodoItem, {
|
|
191
|
+
text: () => t.text(),
|
|
192
|
+
completed: () => t.completed(),
|
|
193
|
+
onToggle: () => t.completed.update((c) => !c),
|
|
194
|
+
onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
|
|
195
|
+
}),
|
|
196
|
+
}),
|
|
197
|
+
),
|
|
198
|
+
el,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
// Edit first todo's text
|
|
202
|
+
todo1.text.set('Edited text')
|
|
203
|
+
expect(el.querySelectorAll('.todo-text')[0]?.textContent).toBe('Edited text')
|
|
204
|
+
// Second todo unchanged
|
|
205
|
+
expect(el.querySelectorAll('.todo-text')[1]?.textContent).toBe('Other text')
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ─── Form with Validation ───────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
describe('real-world — form with validation', () => {
|
|
212
|
+
test('render inputs with signals and display updates', () => {
|
|
213
|
+
const el = container()
|
|
214
|
+
const username = signal('')
|
|
215
|
+
const email = signal('')
|
|
216
|
+
|
|
217
|
+
const Form = () =>
|
|
218
|
+
h('form', null,
|
|
219
|
+
h('input', {
|
|
220
|
+
type: 'text',
|
|
221
|
+
class: 'username',
|
|
222
|
+
value: () => username(),
|
|
223
|
+
onInput: (e: Event) => username.set((e.target as HTMLInputElement).value),
|
|
224
|
+
}),
|
|
225
|
+
h('input', {
|
|
226
|
+
type: 'email',
|
|
227
|
+
class: 'email',
|
|
228
|
+
value: () => email(),
|
|
229
|
+
onInput: (e: Event) => email.set((e.target as HTMLInputElement).value),
|
|
230
|
+
}),
|
|
231
|
+
h('p', { class: 'preview' }, () => `${username()} <${email()}>`),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
mount(h(Form, null), el)
|
|
235
|
+
|
|
236
|
+
expect(el.querySelector('.preview')!.textContent).toBe(' <>')
|
|
237
|
+
|
|
238
|
+
// Simulate typing
|
|
239
|
+
username.set('alice')
|
|
240
|
+
expect(el.querySelector('.preview')!.textContent).toBe('alice <>')
|
|
241
|
+
|
|
242
|
+
email.set('alice@example.com')
|
|
243
|
+
expect(el.querySelector('.preview')!.textContent).toBe('alice <alice@example.com>')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('show/hide error message based on validation signal', () => {
|
|
247
|
+
const el = container()
|
|
248
|
+
const value = signal('')
|
|
249
|
+
const touched = signal(false)
|
|
250
|
+
const hasError = () => touched() && value().length < 3
|
|
251
|
+
|
|
252
|
+
const Field = () =>
|
|
253
|
+
h('div', null,
|
|
254
|
+
h('input', {
|
|
255
|
+
type: 'text',
|
|
256
|
+
value: () => value(),
|
|
257
|
+
onInput: (e: Event) => value.set((e.target as HTMLInputElement).value),
|
|
258
|
+
onBlur: () => touched.set(true),
|
|
259
|
+
}),
|
|
260
|
+
h(Show, { when: hasError },
|
|
261
|
+
h('span', { class: 'error' }, 'Must be at least 3 characters'),
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
mount(h(Field, null), el)
|
|
266
|
+
|
|
267
|
+
// No error initially (not touched)
|
|
268
|
+
expect(el.querySelector('.error')).toBeNull()
|
|
269
|
+
|
|
270
|
+
// Touch the field with short value
|
|
271
|
+
touched.set(true)
|
|
272
|
+
expect(el.querySelector('.error')).not.toBeNull()
|
|
273
|
+
expect(el.querySelector('.error')!.textContent).toBe('Must be at least 3 characters')
|
|
274
|
+
|
|
275
|
+
// Fix the error
|
|
276
|
+
value.set('valid')
|
|
277
|
+
expect(el.querySelector('.error')).toBeNull()
|
|
278
|
+
|
|
279
|
+
// Make it invalid again
|
|
280
|
+
value.set('ab')
|
|
281
|
+
expect(el.querySelector('.error')).not.toBeNull()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('form submission tracking', () => {
|
|
285
|
+
const el = container()
|
|
286
|
+
const submitting = signal(false)
|
|
287
|
+
const submitted = signal(false)
|
|
288
|
+
|
|
289
|
+
const Form = () =>
|
|
290
|
+
h('div', null,
|
|
291
|
+
h('button', {
|
|
292
|
+
class: 'submit',
|
|
293
|
+
disabled: () => submitting(),
|
|
294
|
+
onClick: () => {
|
|
295
|
+
submitting.set(true)
|
|
296
|
+
// Simulate async submit
|
|
297
|
+
submitted.set(true)
|
|
298
|
+
submitting.set(false)
|
|
299
|
+
},
|
|
300
|
+
}, () => (submitting() ? 'Submitting...' : 'Submit')),
|
|
301
|
+
h(Show, { when: submitted },
|
|
302
|
+
h('p', { class: 'success' }, 'Form submitted successfully!'),
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
mount(h(Form, null), el)
|
|
307
|
+
|
|
308
|
+
expect(el.querySelector('.success')).toBeNull()
|
|
309
|
+
expect(el.querySelector('.submit')!.textContent).toBe('Submit')
|
|
310
|
+
|
|
311
|
+
// Click submit
|
|
312
|
+
el.querySelector('.submit')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
313
|
+
expect(el.querySelector('.success')).not.toBeNull()
|
|
314
|
+
expect(el.querySelector('.submit')!.textContent).toBe('Submit')
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// ─── Tab Component ──────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
describe('real-world — tab component', () => {
|
|
321
|
+
test('render tabs and switch between them', () => {
|
|
322
|
+
const el = container()
|
|
323
|
+
const activeTab = signal(0)
|
|
324
|
+
|
|
325
|
+
const TabContent0 = () => h('div', { class: 'tab-content-0' }, 'First tab content')
|
|
326
|
+
const TabContent1 = () => h('div', { class: 'tab-content-1' }, 'Second tab content')
|
|
327
|
+
const TabContent2 = () => h('div', { class: 'tab-content-2' }, 'Third tab content')
|
|
328
|
+
|
|
329
|
+
const Tabs = () =>
|
|
330
|
+
h('div', null,
|
|
331
|
+
h('div', { class: 'tab-bar' },
|
|
332
|
+
h('button', { class: 'tab-0', onClick: () => activeTab.set(0) }, 'Tab 1'),
|
|
333
|
+
h('button', { class: 'tab-1', onClick: () => activeTab.set(1) }, 'Tab 2'),
|
|
334
|
+
h('button', { class: 'tab-2', onClick: () => activeTab.set(2) }, 'Tab 3'),
|
|
335
|
+
),
|
|
336
|
+
h(Show, { when: () => activeTab() === 0 }, h(TabContent0, null)),
|
|
337
|
+
h(Show, { when: () => activeTab() === 1 }, h(TabContent1, null)),
|
|
338
|
+
h(Show, { when: () => activeTab() === 2 }, h(TabContent2, null)),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
mount(h(Tabs, null), el)
|
|
342
|
+
|
|
343
|
+
// First tab visible by default
|
|
344
|
+
expect(el.querySelector('.tab-content-0')).not.toBeNull()
|
|
345
|
+
expect(el.querySelector('.tab-content-1')).toBeNull()
|
|
346
|
+
expect(el.querySelector('.tab-content-2')).toBeNull()
|
|
347
|
+
|
|
348
|
+
// Switch to second tab
|
|
349
|
+
el.querySelector('.tab-1')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
350
|
+
expect(el.querySelector('.tab-content-0')).toBeNull()
|
|
351
|
+
expect(el.querySelector('.tab-content-1')).not.toBeNull()
|
|
352
|
+
expect(el.querySelector('.tab-content-2')).toBeNull()
|
|
353
|
+
|
|
354
|
+
// Switch to third tab
|
|
355
|
+
el.querySelector('.tab-2')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
356
|
+
expect(el.querySelector('.tab-content-0')).toBeNull()
|
|
357
|
+
expect(el.querySelector('.tab-content-1')).toBeNull()
|
|
358
|
+
expect(el.querySelector('.tab-content-2')).not.toBeNull()
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
test('signal in active tab is reactive', () => {
|
|
362
|
+
const el = container()
|
|
363
|
+
const activeTab = signal(0)
|
|
364
|
+
const counter = signal(0)
|
|
365
|
+
|
|
366
|
+
const CounterTab = () =>
|
|
367
|
+
h('div', { class: 'counter-tab' },
|
|
368
|
+
h('span', { class: 'count' }, () => String(counter())),
|
|
369
|
+
h('button', { class: 'increment', onClick: () => counter.update((n) => n + 1) }, '+'),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
const OtherTab = () => h('div', { class: 'other-tab' }, 'Other content')
|
|
373
|
+
|
|
374
|
+
const Tabs = () =>
|
|
375
|
+
h('div', null,
|
|
376
|
+
h('button', { class: 'switch', onClick: () => activeTab.update((t) => (t === 0 ? 1 : 0)) }, 'switch'),
|
|
377
|
+
h(Show, { when: () => activeTab() === 0 }, h(CounterTab, null)),
|
|
378
|
+
h(Show, { when: () => activeTab() === 1 }, h(OtherTab, null)),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
mount(h(Tabs, null), el)
|
|
382
|
+
|
|
383
|
+
// Counter starts at 0
|
|
384
|
+
expect(el.querySelector('.count')!.textContent).toBe('0')
|
|
385
|
+
|
|
386
|
+
// Increment works
|
|
387
|
+
el.querySelector('.increment')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
388
|
+
expect(el.querySelector('.count')!.textContent).toBe('1')
|
|
389
|
+
|
|
390
|
+
// Switch to other tab
|
|
391
|
+
el.querySelector('.switch')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
392
|
+
expect(el.querySelector('.counter-tab')).toBeNull()
|
|
393
|
+
expect(el.querySelector('.other-tab')).not.toBeNull()
|
|
394
|
+
|
|
395
|
+
// Switch back — counter remounts with current signal value
|
|
396
|
+
el.querySelector('.switch')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
397
|
+
expect(el.querySelector('.counter-tab')).not.toBeNull()
|
|
398
|
+
expect(el.querySelector('.count')!.textContent).toBe('1')
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('previous tab unmounts when switching', () => {
|
|
402
|
+
const el = container()
|
|
403
|
+
const activeTab = signal(0)
|
|
404
|
+
let mountCount = 0
|
|
405
|
+
let unmountCount = 0
|
|
406
|
+
|
|
407
|
+
const TrackedTab = () => {
|
|
408
|
+
onMount(() => {
|
|
409
|
+
mountCount++
|
|
410
|
+
return () => { unmountCount++ }
|
|
411
|
+
})
|
|
412
|
+
return h('div', { class: 'tracked' }, 'tracked content')
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const Tabs = () =>
|
|
416
|
+
h('div', null,
|
|
417
|
+
h('button', { class: 'switch', onClick: () => activeTab.update((t) => (t === 0 ? 1 : 0)) }, 'switch'),
|
|
418
|
+
h(Show, { when: () => activeTab() === 0 }, h(TrackedTab, null)),
|
|
419
|
+
h(Show, { when: () => activeTab() === 1 }, h('div', null, 'other')),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
mount(h(Tabs, null), el)
|
|
423
|
+
expect(mountCount).toBe(1)
|
|
424
|
+
expect(unmountCount).toBe(0)
|
|
425
|
+
|
|
426
|
+
// Switch away — tracked tab unmounts
|
|
427
|
+
el.querySelector('.switch')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
428
|
+
expect(unmountCount).toBe(1)
|
|
429
|
+
|
|
430
|
+
// Switch back — tracked tab mounts again
|
|
431
|
+
el.querySelector('.switch')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
432
|
+
expect(mountCount).toBe(2)
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// ─── Nested Context ─────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
describe('real-world — nested context', () => {
|
|
439
|
+
const ThemeCtx = createContext<string>('light')
|
|
440
|
+
|
|
441
|
+
test('parent provides context, child reads it', () => {
|
|
442
|
+
const el = container()
|
|
443
|
+
let childTheme: string | undefined
|
|
444
|
+
|
|
445
|
+
const Child = () => {
|
|
446
|
+
childTheme = useContext(ThemeCtx)
|
|
447
|
+
return h('span', null, childTheme)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const Parent = () => {
|
|
451
|
+
provide(ThemeCtx, 'dark')
|
|
452
|
+
return h(Child, null)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
mount(h(Parent, null), el)
|
|
456
|
+
expect(childTheme).toBe('dark')
|
|
457
|
+
expect(el.querySelector('span')!.textContent).toBe('dark')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test('deeply nested child reads ancestor context', () => {
|
|
461
|
+
const el = container()
|
|
462
|
+
let deepTheme: string | undefined
|
|
463
|
+
|
|
464
|
+
const DeepChild = () => {
|
|
465
|
+
deepTheme = useContext(ThemeCtx)
|
|
466
|
+
return h('span', { class: 'deep' }, deepTheme)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const Middle = () => h('div', { class: 'middle' }, h(DeepChild, null))
|
|
470
|
+
|
|
471
|
+
const Root = () => {
|
|
472
|
+
provide(ThemeCtx, 'blue')
|
|
473
|
+
return h('div', null, h(Middle, null))
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
mount(h(Root, null), el)
|
|
477
|
+
expect(deepTheme).toBe('blue')
|
|
478
|
+
expect(el.querySelector('.deep')!.textContent).toBe('blue')
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('nested providers override parent context', () => {
|
|
482
|
+
const el = container()
|
|
483
|
+
let innerTheme: string | undefined
|
|
484
|
+
let outerTheme: string | undefined
|
|
485
|
+
|
|
486
|
+
const InnerChild = () => {
|
|
487
|
+
innerTheme = useContext(ThemeCtx)
|
|
488
|
+
return h('span', { class: 'inner' }, innerTheme)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const OuterChild = () => {
|
|
492
|
+
outerTheme = useContext(ThemeCtx)
|
|
493
|
+
return h('span', { class: 'outer' }, outerTheme)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const InnerProvider = () => {
|
|
497
|
+
provide(ThemeCtx, 'red')
|
|
498
|
+
return h(InnerChild, null)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const OuterProvider = () => {
|
|
502
|
+
provide(ThemeCtx, 'green')
|
|
503
|
+
return h(Fragment, null,
|
|
504
|
+
h(OuterChild, null),
|
|
505
|
+
h(InnerProvider, null),
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
mount(h(OuterProvider, null), el)
|
|
510
|
+
expect(outerTheme).toBe('green')
|
|
511
|
+
expect(innerTheme).toBe('red')
|
|
512
|
+
expect(el.querySelector('.outer')!.textContent).toBe('green')
|
|
513
|
+
expect(el.querySelector('.inner')!.textContent).toBe('red')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test('sibling providers isolate context', () => {
|
|
517
|
+
const el = container()
|
|
518
|
+
let themeA: string | undefined
|
|
519
|
+
let themeB: string | undefined
|
|
520
|
+
|
|
521
|
+
const ChildA = () => {
|
|
522
|
+
themeA = useContext(ThemeCtx)
|
|
523
|
+
return h('span', { class: 'a' }, themeA)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const ChildB = () => {
|
|
527
|
+
themeB = useContext(ThemeCtx)
|
|
528
|
+
return h('span', { class: 'b' }, themeB)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const ProviderA = () => {
|
|
532
|
+
provide(ThemeCtx, 'alpha')
|
|
533
|
+
return h(ChildA, null)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const ProviderB = () => {
|
|
537
|
+
provide(ThemeCtx, 'beta')
|
|
538
|
+
return h(ChildB, null)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
mount(h(Fragment, null, h(ProviderA, null), h(ProviderB, null)), el)
|
|
542
|
+
|
|
543
|
+
expect(themeA).toBe('alpha')
|
|
544
|
+
expect(themeB).toBe('beta')
|
|
545
|
+
expect(el.querySelector('.a')!.textContent).toBe('alpha')
|
|
546
|
+
expect(el.querySelector('.b')!.textContent).toBe('beta')
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
test('context with Show — context survives reactive boundary', async () => {
|
|
550
|
+
const el = container()
|
|
551
|
+
let childTheme: string | undefined
|
|
552
|
+
const visible = signal(false)
|
|
553
|
+
|
|
554
|
+
const Child = () => {
|
|
555
|
+
childTheme = useContext(ThemeCtx)
|
|
556
|
+
return h('span', { class: 'themed' }, childTheme)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const App = () => {
|
|
560
|
+
provide(ThemeCtx, 'purple')
|
|
561
|
+
return h('div', null,
|
|
562
|
+
h(Show, { when: visible }, h(Child, null)),
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
mount(h(App, null), el)
|
|
567
|
+
expect(el.querySelector('.themed')).toBeNull()
|
|
568
|
+
|
|
569
|
+
// Show child — context should be available
|
|
570
|
+
visible.set(true)
|
|
571
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
572
|
+
expect(childTheme).toBe('purple')
|
|
573
|
+
expect(el.querySelector('.themed')!.textContent).toBe('purple')
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
// ─── Complex Composition ────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
describe('real-world — complex composition', () => {
|
|
580
|
+
test('counter with derived display and reset', () => {
|
|
581
|
+
const el = container()
|
|
582
|
+
const count = signal(0)
|
|
583
|
+
|
|
584
|
+
const Counter = () =>
|
|
585
|
+
h('div', null,
|
|
586
|
+
h('span', { class: 'value' }, () => String(count())),
|
|
587
|
+
h('span', { class: 'doubled' }, () => String(count() * 2)),
|
|
588
|
+
h('span', { class: 'label' }, () => (count() === 0 ? 'zero' : count() > 0 ? 'positive' : 'negative')),
|
|
589
|
+
h('button', { class: 'inc', onClick: () => count.update((n) => n + 1) }, '+'),
|
|
590
|
+
h('button', { class: 'dec', onClick: () => count.update((n) => n - 1) }, '-'),
|
|
591
|
+
h('button', { class: 'reset', onClick: () => count.set(0) }, 'reset'),
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
mount(h(Counter, null), el)
|
|
595
|
+
|
|
596
|
+
expect(el.querySelector('.value')!.textContent).toBe('0')
|
|
597
|
+
expect(el.querySelector('.doubled')!.textContent).toBe('0')
|
|
598
|
+
expect(el.querySelector('.label')!.textContent).toBe('zero')
|
|
599
|
+
|
|
600
|
+
// Increment
|
|
601
|
+
el.querySelector('.inc')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
602
|
+
expect(el.querySelector('.value')!.textContent).toBe('1')
|
|
603
|
+
expect(el.querySelector('.doubled')!.textContent).toBe('2')
|
|
604
|
+
expect(el.querySelector('.label')!.textContent).toBe('positive')
|
|
605
|
+
|
|
606
|
+
// Increment again
|
|
607
|
+
el.querySelector('.inc')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
608
|
+
expect(el.querySelector('.value')!.textContent).toBe('2')
|
|
609
|
+
expect(el.querySelector('.doubled')!.textContent).toBe('4')
|
|
610
|
+
|
|
611
|
+
// Decrement 3 times
|
|
612
|
+
el.querySelector('.dec')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
613
|
+
el.querySelector('.dec')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
614
|
+
el.querySelector('.dec')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
615
|
+
expect(el.querySelector('.value')!.textContent).toBe('-1')
|
|
616
|
+
expect(el.querySelector('.label')!.textContent).toBe('negative')
|
|
617
|
+
|
|
618
|
+
// Reset
|
|
619
|
+
el.querySelector('.reset')!.dispatchEvent(new Event('click', { bubbles: true }))
|
|
620
|
+
expect(el.querySelector('.value')!.textContent).toBe('0')
|
|
621
|
+
expect(el.querySelector('.label')!.textContent).toBe('zero')
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
test('filterable list with search', () => {
|
|
625
|
+
const el = container()
|
|
626
|
+
type Item = { id: number; name: string }
|
|
627
|
+
|
|
628
|
+
const allItems: Item[] = [
|
|
629
|
+
{ id: 1, name: 'Apple' },
|
|
630
|
+
{ id: 2, name: 'Banana' },
|
|
631
|
+
{ id: 3, name: 'Cherry' },
|
|
632
|
+
{ id: 4, name: 'Apricot' },
|
|
633
|
+
]
|
|
634
|
+
|
|
635
|
+
const query = signal('')
|
|
636
|
+
const filtered = () =>
|
|
637
|
+
allItems.filter((i) => i.name.toLowerCase().includes(query().toLowerCase()))
|
|
638
|
+
|
|
639
|
+
const SearchList = () =>
|
|
640
|
+
h('div', null,
|
|
641
|
+
h('input', {
|
|
642
|
+
class: 'search',
|
|
643
|
+
value: () => query(),
|
|
644
|
+
onInput: (e: Event) => query.set((e.target as HTMLInputElement).value),
|
|
645
|
+
}),
|
|
646
|
+
h('ul', null,
|
|
647
|
+
For({
|
|
648
|
+
each: filtered,
|
|
649
|
+
by: (i: Item) => i.id,
|
|
650
|
+
children: (i: Item) => h('li', null, i.name),
|
|
651
|
+
}),
|
|
652
|
+
),
|
|
653
|
+
h('span', { class: 'count' }, () => `${filtered().length} results`),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
mount(h(SearchList, null), el)
|
|
657
|
+
|
|
658
|
+
expect(el.querySelectorAll('li').length).toBe(4)
|
|
659
|
+
expect(el.querySelector('.count')!.textContent).toBe('4 results')
|
|
660
|
+
|
|
661
|
+
// Filter to "ap" — matches Apple and Apricot
|
|
662
|
+
query.set('ap')
|
|
663
|
+
expect(el.querySelectorAll('li').length).toBe(2)
|
|
664
|
+
expect(el.querySelector('.count')!.textContent).toBe('2 results')
|
|
665
|
+
|
|
666
|
+
// Filter to "ban" — matches Banana
|
|
667
|
+
query.set('ban')
|
|
668
|
+
expect(el.querySelectorAll('li').length).toBe(1)
|
|
669
|
+
expect(el.querySelectorAll('li')[0]?.textContent).toBe('Banana')
|
|
670
|
+
|
|
671
|
+
// Clear filter
|
|
672
|
+
query.set('')
|
|
673
|
+
expect(el.querySelectorAll('li').length).toBe(4)
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
test('dynamic class list based on multiple signals', () => {
|
|
677
|
+
const el = container()
|
|
678
|
+
const active = signal(false)
|
|
679
|
+
const disabled = signal(false)
|
|
680
|
+
const size = signal<'sm' | 'md' | 'lg'>('md')
|
|
681
|
+
|
|
682
|
+
const Button = () =>
|
|
683
|
+
h('button', {
|
|
684
|
+
class: () => [
|
|
685
|
+
'btn',
|
|
686
|
+
active() && 'btn-active',
|
|
687
|
+
disabled() && 'btn-disabled',
|
|
688
|
+
`btn-${size()}`,
|
|
689
|
+
].filter(Boolean).join(' '),
|
|
690
|
+
disabled: () => disabled(),
|
|
691
|
+
}, 'Click')
|
|
692
|
+
|
|
693
|
+
mount(h(Button, null), el)
|
|
694
|
+
|
|
695
|
+
const btn = el.querySelector('button')!
|
|
696
|
+
expect(btn.className).toBe('btn btn-md')
|
|
697
|
+
|
|
698
|
+
active.set(true)
|
|
699
|
+
expect(btn.className).toBe('btn btn-active btn-md')
|
|
700
|
+
|
|
701
|
+
size.set('lg')
|
|
702
|
+
expect(btn.className).toBe('btn btn-active btn-lg')
|
|
703
|
+
|
|
704
|
+
disabled.set(true)
|
|
705
|
+
expect(btn.className).toBe('btn btn-active btn-disabled btn-lg')
|
|
706
|
+
expect(btn.disabled).toBe(true)
|
|
707
|
+
|
|
708
|
+
active.set(false)
|
|
709
|
+
disabled.set(false)
|
|
710
|
+
size.set('sm')
|
|
711
|
+
expect(btn.className).toBe('btn btn-sm')
|
|
712
|
+
expect(btn.disabled).toBe(false)
|
|
713
|
+
})
|
|
714
|
+
})
|