@invisibleloop/pulse 0.20.37 → 0.20.39
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/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/src/mcp/guards.js +94 -0
- package/src/mcp/server.js +22 -3
- package/src/runtime/ssr.js +21 -5
- package/src/server/index.js +2 -2
- package/scripts/css-purge.test.js +0 -147
- package/scripts/strip-server.test.js +0 -491
- package/src/cli/check-bundles.test.js +0 -137
- package/src/cli/cli.test.js +0 -341
- package/src/cli/diagnose.test.js +0 -124
- package/src/cli/process-utils.test.js +0 -114
- package/src/cli/review.test.js +0 -143
- package/src/cli/stamp.test.js +0 -149
- package/src/cli/validate.test.js +0 -153
- package/src/mcp/mcp.test.js +0 -617
- package/src/md/md.test.js +0 -217
- package/src/runtime/image.test.js +0 -111
- package/src/runtime/morph.test.js +0 -317
- package/src/runtime/navigate.test.js +0 -304
- package/src/runtime/runtime.test.js +0 -1069
- package/src/runtime/ssr.test.js +0 -709
- package/src/server/server.test.js +0 -2474
- package/src/spec/schema.test.js +0 -467
- package/src/store/store.test.js +0 -210
- package/src/testing/testing.test.js +0 -508
- package/src/ui/ui.test.js +0 -1157
|
@@ -1,1069 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pulse 2 — Runtime tests
|
|
3
|
-
* run: node src/runtime/runtime.test.js
|
|
4
|
-
*
|
|
5
|
-
* Uses a minimal DOM shim — no browser, no jsdom dependency.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { mount, validateFields, debounce, throttle } from './index.js'
|
|
9
|
-
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// Minimal DOM shim
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
class FakeElement {
|
|
15
|
-
constructor() {
|
|
16
|
-
this.innerHTML = ''
|
|
17
|
-
this._listeners = {}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
querySelectorAll(selector) {
|
|
21
|
-
// Parse our own innerHTML to find matching elements
|
|
22
|
-
return parseElements(this.innerHTML, selector)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
addEventListener() {}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Tiny HTML parser — finds elements with a given attribute.
|
|
30
|
-
* Good enough for our tests. Not a real DOM parser.
|
|
31
|
-
*/
|
|
32
|
-
function parseElements(html, selector) {
|
|
33
|
-
const attr = selector.match(/\[([^\]]+)\]/)?.[1]
|
|
34
|
-
if (!attr) return []
|
|
35
|
-
|
|
36
|
-
const regex = new RegExp(`<[^>]+${attr}="([^"]*)"[^>]*>`, 'g')
|
|
37
|
-
const matches = []
|
|
38
|
-
let m
|
|
39
|
-
|
|
40
|
-
while ((m = regex.exec(html)) !== null) {
|
|
41
|
-
const attrValue = m[1]
|
|
42
|
-
const el = {
|
|
43
|
-
dataset: {},
|
|
44
|
-
_fired: {},
|
|
45
|
-
addEventListener(event, fn) { this._fn = fn; this._event = event },
|
|
46
|
-
dispatchEvent(event, data) { this._fn?.(data) }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (attr === 'data-event') el.dataset.event = attrValue
|
|
50
|
-
if (attr === 'data-action') el.dataset.action = attrValue
|
|
51
|
-
matches.push(el)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return matches
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* A more realistic element for exercising bindEvents() delegation directly —
|
|
59
|
-
* unlike FakeElement above (which stubs addEventListener as a no-op and is
|
|
60
|
-
* only used to assert on rendered innerHTML), this one wires real listeners
|
|
61
|
-
* via the native EventTarget and supports closest()/dataset/pointer capture,
|
|
62
|
-
* so pointerdown/pointermove/pointerup/keydown routing can be verified.
|
|
63
|
-
*/
|
|
64
|
-
class DelegatingElement extends EventTarget {
|
|
65
|
-
constructor() {
|
|
66
|
-
super()
|
|
67
|
-
this.innerHTML = ''
|
|
68
|
-
this._captured = new Map()
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
class ChildElement extends EventTarget {
|
|
73
|
-
constructor(attrs = {}, parent = null) {
|
|
74
|
-
super()
|
|
75
|
-
this.dataset = {}
|
|
76
|
-
for (const [k, v] of Object.entries(attrs)) this.dataset[k] = v
|
|
77
|
-
this._parent = parent
|
|
78
|
-
}
|
|
79
|
-
closest(selector) {
|
|
80
|
-
const attr = selector.match(/\[([^\]]+)\]/)?.[1]
|
|
81
|
-
const key = attr === 'data-event' ? 'event'
|
|
82
|
-
: attr === 'data-action' ? 'action'
|
|
83
|
-
: attr === 'data-store-event' ? 'storeEvent'
|
|
84
|
-
: attr === 'data-dialog-open' ? 'dialogOpen'
|
|
85
|
-
: attr === 'data-dialog-close' ? 'dialogClose'
|
|
86
|
-
: null
|
|
87
|
-
let node = this
|
|
88
|
-
while (node) {
|
|
89
|
-
if (key && node.dataset && node.dataset[key] !== undefined) return node
|
|
90
|
-
node = node._parent
|
|
91
|
-
}
|
|
92
|
-
return null
|
|
93
|
-
}
|
|
94
|
-
setPointerCapture(id) { this._root?._captured.set(id, this) }
|
|
95
|
-
releasePointerCapture(id) { this._root?._captured.delete(id) }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function fireOn(root, target, type, props = {}) {
|
|
99
|
-
const event = Object.assign(new Event(type, { bubbles: true }), props)
|
|
100
|
-
Object.defineProperty(event, 'target', { value: target, configurable: true })
|
|
101
|
-
root.dispatchEvent(event)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// Test runner
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
let passed = 0
|
|
109
|
-
let failed = 0
|
|
110
|
-
|
|
111
|
-
function test(label, fn) {
|
|
112
|
-
try {
|
|
113
|
-
fn()
|
|
114
|
-
console.log(` ✓ ${label}`)
|
|
115
|
-
passed++
|
|
116
|
-
} catch (e) {
|
|
117
|
-
console.log(` ✗ ${label}`)
|
|
118
|
-
console.log(` ${e.message}`)
|
|
119
|
-
failed++
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async function testAsync(label, fn) {
|
|
124
|
-
try {
|
|
125
|
-
await fn()
|
|
126
|
-
console.log(` ✓ ${label}`)
|
|
127
|
-
passed++
|
|
128
|
-
} catch (e) {
|
|
129
|
-
console.log(` ✗ ${label}`)
|
|
130
|
-
console.log(` ${e.message}`)
|
|
131
|
-
failed++
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function assert(condition, msg) {
|
|
136
|
-
if (!condition) throw new Error(msg || 'Assertion failed')
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ---------------------------------------------------------------------------
|
|
140
|
-
// Browser globals shim (localStorage, location)
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
|
|
143
|
-
const _store = {}
|
|
144
|
-
global.localStorage = {
|
|
145
|
-
getItem: (k) => _store[k] ?? null,
|
|
146
|
-
setItem: (k, v) => { _store[k] = v },
|
|
147
|
-
removeItem: (k) => { delete _store[k] },
|
|
148
|
-
}
|
|
149
|
-
global.location = { pathname: '/test' }
|
|
150
|
-
|
|
151
|
-
// ---------------------------------------------------------------------------
|
|
152
|
-
// Counter spec — used across multiple tests
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
|
|
155
|
-
const counterSpec = {
|
|
156
|
-
route: '/counter',
|
|
157
|
-
state: { count: 0 },
|
|
158
|
-
constraints: {
|
|
159
|
-
count: { min: 0, max: 10 }
|
|
160
|
-
},
|
|
161
|
-
view: (state) =>
|
|
162
|
-
`<div>
|
|
163
|
-
<button data-event="decrement">-</button>
|
|
164
|
-
<span id="count">${state.count}</span>
|
|
165
|
-
<button data-event="increment">+</button>
|
|
166
|
-
<button data-event="reset">reset</button>
|
|
167
|
-
</div>`,
|
|
168
|
-
mutations: {
|
|
169
|
-
increment: (state) => ({ count: state.count + 1 }),
|
|
170
|
-
decrement: (state) => ({ count: state.count - 1 }),
|
|
171
|
-
reset: () => ({ count: 0 })
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ---------------------------------------------------------------------------
|
|
176
|
-
|
|
177
|
-
console.log('\nMount & initial render\n')
|
|
178
|
-
|
|
179
|
-
test('renders initial state to element', () => {
|
|
180
|
-
const el = new FakeElement()
|
|
181
|
-
mount(counterSpec, el)
|
|
182
|
-
assert(el.innerHTML.includes('>0<'), `Expected count 0 in HTML, got: ${el.innerHTML}`)
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
test('throws on invalid spec', () => {
|
|
186
|
-
const el = new FakeElement()
|
|
187
|
-
let threw = false
|
|
188
|
-
try { mount({ route: '/bad' }, el) } catch { threw = true }
|
|
189
|
-
assert(threw, 'Expected mount to throw on invalid spec')
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
test('accepts server state', () => {
|
|
193
|
-
const el = new FakeElement()
|
|
194
|
-
const spec = {
|
|
195
|
-
route: '/hello',
|
|
196
|
-
state: {},
|
|
197
|
-
view: (state, server) => `<h1>${server.greeting}</h1>`
|
|
198
|
-
}
|
|
199
|
-
mount(spec, el, { greeting: 'Hello world' })
|
|
200
|
-
assert(el.innerHTML.includes('Hello world'), `Got: ${el.innerHTML}`)
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
// ---------------------------------------------------------------------------
|
|
204
|
-
|
|
205
|
-
console.log('\nMutations\n')
|
|
206
|
-
|
|
207
|
-
test('dispatch applies mutation and re-renders', () => {
|
|
208
|
-
const el = new FakeElement()
|
|
209
|
-
const instance = mount(counterSpec, el)
|
|
210
|
-
|
|
211
|
-
instance.dispatch('increment')
|
|
212
|
-
assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
|
|
213
|
-
|
|
214
|
-
instance.dispatch('increment')
|
|
215
|
-
assert(el.innerHTML.includes('>2<'), `Expected count 2, got: ${el.innerHTML}`)
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
test('mutation receives current state', () => {
|
|
219
|
-
const el = new FakeElement()
|
|
220
|
-
const instance = mount(counterSpec, el)
|
|
221
|
-
|
|
222
|
-
instance.dispatch('increment')
|
|
223
|
-
instance.dispatch('increment')
|
|
224
|
-
instance.dispatch('decrement')
|
|
225
|
-
assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
test('reset mutation works', () => {
|
|
229
|
-
const el = new FakeElement()
|
|
230
|
-
const instance = mount(counterSpec, el)
|
|
231
|
-
|
|
232
|
-
instance.dispatch('increment')
|
|
233
|
-
instance.dispatch('increment')
|
|
234
|
-
instance.dispatch('reset')
|
|
235
|
-
assert(el.innerHTML.includes('>0<'), `Expected count 0 after reset, got: ${el.innerHTML}`)
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
test('getState returns deep clone of current state', () => {
|
|
239
|
-
const el = new FakeElement()
|
|
240
|
-
const instance = mount(counterSpec, el)
|
|
241
|
-
|
|
242
|
-
instance.dispatch('increment')
|
|
243
|
-
const state = instance.getState()
|
|
244
|
-
assert(state.count === 1, `Expected count 1, got ${state.count}`)
|
|
245
|
-
|
|
246
|
-
// Mutating returned state should not affect internal state
|
|
247
|
-
state.count = 999
|
|
248
|
-
assert(instance.getState().count === 1, 'getState should return a clone, not a reference')
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
test('warns on unknown mutation', () => {
|
|
252
|
-
const el = new FakeElement()
|
|
253
|
-
const instance = mount(counterSpec, el)
|
|
254
|
-
const warnings = []
|
|
255
|
-
const orig = console.warn
|
|
256
|
-
console.warn = (...args) => warnings.push(args.join(' '))
|
|
257
|
-
|
|
258
|
-
instance.dispatch('nonexistent')
|
|
259
|
-
|
|
260
|
-
console.warn = orig
|
|
261
|
-
assert(warnings.some(w => w.includes('nonexistent')), 'Expected warning for unknown mutation')
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
// ---------------------------------------------------------------------------
|
|
265
|
-
|
|
266
|
-
console.log('\nConstraints\n')
|
|
267
|
-
|
|
268
|
-
test('max constraint prevents count exceeding limit', () => {
|
|
269
|
-
const el = new FakeElement()
|
|
270
|
-
const instance = mount(counterSpec, el)
|
|
271
|
-
|
|
272
|
-
for (let i = 0; i < 15; i++) instance.dispatch('increment')
|
|
273
|
-
|
|
274
|
-
const state = instance.getState()
|
|
275
|
-
assert(state.count === 10, `Expected count capped at 10, got ${state.count}`)
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
test('min constraint prevents count going below 0', () => {
|
|
279
|
-
const el = new FakeElement()
|
|
280
|
-
const instance = mount(counterSpec, el)
|
|
281
|
-
|
|
282
|
-
for (let i = 0; i < 5; i++) instance.dispatch('decrement')
|
|
283
|
-
|
|
284
|
-
const state = instance.getState()
|
|
285
|
-
assert(state.count === 0, `Expected count floored at 0, got ${state.count}`)
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
test('constraints on nested paths', () => {
|
|
289
|
-
const el = new FakeElement()
|
|
290
|
-
const spec = {
|
|
291
|
-
route: '/nested',
|
|
292
|
-
state: { slider: { value: 5 } },
|
|
293
|
-
constraints: { 'slider.value': { min: 0, max: 100 } },
|
|
294
|
-
view: (s) => `<span>${s.slider.value}</span>`,
|
|
295
|
-
mutations: {
|
|
296
|
-
set: (state, val) => ({ slider: { ...state.slider, value: val } })
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
const instance = mount(spec, el)
|
|
300
|
-
instance.dispatch('set', 150)
|
|
301
|
-
assert(instance.getState().slider.value === 100, `Expected 100, got ${instance.getState().slider.value}`)
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
test('render is skipped when mutation produces no state change', () => {
|
|
305
|
-
const el = new FakeElement()
|
|
306
|
-
let renderCount = 0
|
|
307
|
-
const spec = {
|
|
308
|
-
route: '/nochange',
|
|
309
|
-
state: { count: 10 },
|
|
310
|
-
constraints: { count: { min: 0, max: 10 } },
|
|
311
|
-
view: (s) => { renderCount++; return `<span>${s.count}</span>` },
|
|
312
|
-
mutations: {
|
|
313
|
-
increment: (state) => ({ count: state.count + 1 }), // clamped to 10
|
|
314
|
-
noop: () => ({}), // empty partial
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
const instance = mount(spec, el)
|
|
318
|
-
renderCount = 0 // reset after initial render
|
|
319
|
-
|
|
320
|
-
instance.dispatch('increment') // already at max — state unchanged
|
|
321
|
-
instance.dispatch('noop') // empty partial — state unchanged
|
|
322
|
-
assert(renderCount === 0, `Expected 0 renders for no-change dispatches, got ${renderCount}`)
|
|
323
|
-
|
|
324
|
-
instance.dispatch('increment') // state unchanged by constraint
|
|
325
|
-
assert(instance.getState().count === 10)
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
// ---------------------------------------------------------------------------
|
|
329
|
-
|
|
330
|
-
console.log('\nActions\n')
|
|
331
|
-
|
|
332
|
-
await testAsync('action run is called and onSuccess updates state', async () => {
|
|
333
|
-
const el = new FakeElement()
|
|
334
|
-
let ran = false
|
|
335
|
-
|
|
336
|
-
const spec = {
|
|
337
|
-
route: '/form',
|
|
338
|
-
state: { status: 'idle' },
|
|
339
|
-
view: (s) => `<form data-action="submit"><span>${s.status}</span></form>`,
|
|
340
|
-
actions: {
|
|
341
|
-
submit: {
|
|
342
|
-
run: async () => { ran = true },
|
|
343
|
-
onSuccess: () => ({ status: 'success' }),
|
|
344
|
-
onError: () => ({ status: 'error' })
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const instance = mount(spec, el)
|
|
350
|
-
await instance.dispatch('submit')
|
|
351
|
-
|
|
352
|
-
assert(ran, 'Expected action.run to be called')
|
|
353
|
-
assert(instance.getState().status === 'success', `Expected status "success", got "${instance.getState().status}"`)
|
|
354
|
-
})
|
|
355
|
-
|
|
356
|
-
await testAsync('onSuccess receives the return value of run()', async () => {
|
|
357
|
-
const el = new FakeElement()
|
|
358
|
-
|
|
359
|
-
const spec = {
|
|
360
|
-
route: '/api-test',
|
|
361
|
-
state: { items: [] },
|
|
362
|
-
view: (s) => `<div>${JSON.stringify(s.items)}</div>`,
|
|
363
|
-
actions: {
|
|
364
|
-
load: {
|
|
365
|
-
run: async () => [{ id: 1, name: 'transformed' }],
|
|
366
|
-
onSuccess: (state, result) => ({ items: result }),
|
|
367
|
-
onError: () => ({})
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const instance = mount(spec, el)
|
|
373
|
-
await instance.dispatch('load')
|
|
374
|
-
|
|
375
|
-
const { items } = instance.getState()
|
|
376
|
-
assert(Array.isArray(items) && items.length === 1, `Expected 1 item, got ${items.length}`)
|
|
377
|
-
assert(items[0].name === 'transformed', `Expected transformed data, got ${JSON.stringify(items[0])}`)
|
|
378
|
-
})
|
|
379
|
-
|
|
380
|
-
await testAsync('action onError is called when run throws', async () => {
|
|
381
|
-
const el = new FakeElement()
|
|
382
|
-
|
|
383
|
-
const spec = {
|
|
384
|
-
route: '/form',
|
|
385
|
-
state: { status: 'idle' },
|
|
386
|
-
view: (s) => `<span>${s.status}</span>`,
|
|
387
|
-
actions: {
|
|
388
|
-
submit: {
|
|
389
|
-
run: async () => { throw new Error('network error') },
|
|
390
|
-
onSuccess: () => ({ status: 'success' }),
|
|
391
|
-
onError: () => ({ status: 'error' })
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const instance = mount(spec, el)
|
|
397
|
-
|
|
398
|
-
const errors = []
|
|
399
|
-
const orig = console.error
|
|
400
|
-
console.error = (...args) => errors.push(args)
|
|
401
|
-
|
|
402
|
-
await instance.dispatch('submit')
|
|
403
|
-
|
|
404
|
-
console.error = orig
|
|
405
|
-
assert(instance.getState().status === 'error', `Expected "error", got "${instance.getState().status}"`)
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
await testAsync('onStart fires immediately before async work', async () => {
|
|
409
|
-
const el = new FakeElement()
|
|
410
|
-
const states = []
|
|
411
|
-
|
|
412
|
-
const spec = {
|
|
413
|
-
route: '/form',
|
|
414
|
-
state: { status: 'idle' },
|
|
415
|
-
view: (s) => `<span>${s.status}</span>`,
|
|
416
|
-
actions: {
|
|
417
|
-
submit: {
|
|
418
|
-
onStart: () => ({ status: 'loading' }),
|
|
419
|
-
run: async () => { states.push('during') },
|
|
420
|
-
onSuccess: () => ({ status: 'done' }),
|
|
421
|
-
onError: () => ({ status: 'error' })
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
const instance = mount(spec, el)
|
|
427
|
-
|
|
428
|
-
// Capture state at each render
|
|
429
|
-
let renderCount = 0
|
|
430
|
-
const origRefresh = instance.refresh
|
|
431
|
-
const capturedStates = []
|
|
432
|
-
|
|
433
|
-
await instance.dispatch('submit')
|
|
434
|
-
|
|
435
|
-
assert(instance.getState().status === 'done', `Final status should be "done", got "${instance.getState().status}"`)
|
|
436
|
-
})
|
|
437
|
-
|
|
438
|
-
await testAsync('validate:true blocks action when fields invalid', async () => {
|
|
439
|
-
const el = new FakeElement()
|
|
440
|
-
let ran = false
|
|
441
|
-
|
|
442
|
-
const spec = {
|
|
443
|
-
route: '/form',
|
|
444
|
-
state: { fields: { email: 'not-an-email' } },
|
|
445
|
-
validation: { 'fields.email': { format: 'email' } },
|
|
446
|
-
view: (s) => `<span>${s.fields.email}</span>`,
|
|
447
|
-
actions: {
|
|
448
|
-
submit: {
|
|
449
|
-
validate: true,
|
|
450
|
-
run: async () => { ran = true },
|
|
451
|
-
onSuccess: () => ({}),
|
|
452
|
-
onError: () => ({})
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const instance = mount(spec, el)
|
|
458
|
-
|
|
459
|
-
const warnings = []
|
|
460
|
-
const orig = console.warn
|
|
461
|
-
console.warn = (...args) => warnings.push(args.join(' '))
|
|
462
|
-
await instance.dispatch('submit')
|
|
463
|
-
console.warn = orig
|
|
464
|
-
|
|
465
|
-
assert(!ran, 'Expected action.run NOT to be called when validation fails')
|
|
466
|
-
assert(warnings.some(w => w.includes('Validation failed')), 'Expected validation warning')
|
|
467
|
-
})
|
|
468
|
-
|
|
469
|
-
// ---------------------------------------------------------------------------
|
|
470
|
-
|
|
471
|
-
console.log('\nValidation\n')
|
|
472
|
-
|
|
473
|
-
test('required rule catches empty string', () => {
|
|
474
|
-
const errors = validateFields(
|
|
475
|
-
{ fields: { name: '' } },
|
|
476
|
-
{ 'fields.name': { required: true } }
|
|
477
|
-
)
|
|
478
|
-
assert(errors.length === 1, `Expected 1 error, got ${errors.length}`)
|
|
479
|
-
assert(errors[0].rule === 'required', `Expected required error`)
|
|
480
|
-
})
|
|
481
|
-
|
|
482
|
-
test('email format rule', () => {
|
|
483
|
-
const errors = validateFields(
|
|
484
|
-
{ fields: { email: 'notanemail' } },
|
|
485
|
-
{ 'fields.email': { format: 'email' } }
|
|
486
|
-
)
|
|
487
|
-
assert(errors.length === 1, `Expected 1 error`)
|
|
488
|
-
assert(errors[0].rule === 'format', `Expected format error`)
|
|
489
|
-
})
|
|
490
|
-
|
|
491
|
-
test('valid email passes', () => {
|
|
492
|
-
const errors = validateFields(
|
|
493
|
-
{ fields: { email: 'test@example.com' } },
|
|
494
|
-
{ 'fields.email': { format: 'email' } }
|
|
495
|
-
)
|
|
496
|
-
assert(errors.length === 0, `Expected no errors, got ${errors.length}`)
|
|
497
|
-
})
|
|
498
|
-
|
|
499
|
-
test('minLength rule', () => {
|
|
500
|
-
const errors = validateFields(
|
|
501
|
-
{ fields: { message: 'hi' } },
|
|
502
|
-
{ 'fields.message': { minLength: 10 } }
|
|
503
|
-
)
|
|
504
|
-
assert(errors.length === 1, `Expected 1 error`)
|
|
505
|
-
assert(errors[0].rule === 'minLength')
|
|
506
|
-
})
|
|
507
|
-
|
|
508
|
-
test('maxLength rule', () => {
|
|
509
|
-
const errors = validateFields(
|
|
510
|
-
{ fields: { message: 'a'.repeat(1001) } },
|
|
511
|
-
{ 'fields.message': { maxLength: 1000 } }
|
|
512
|
-
)
|
|
513
|
-
assert(errors.length === 1, `Expected 1 error`)
|
|
514
|
-
assert(errors[0].rule === 'maxLength')
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
test('multiple rules — all errors returned', () => {
|
|
518
|
-
const errors = validateFields(
|
|
519
|
-
{ fields: { name: '', email: 'bad', message: 'hi' } },
|
|
520
|
-
{
|
|
521
|
-
'fields.name': { required: true },
|
|
522
|
-
'fields.email': { required: true, format: 'email' },
|
|
523
|
-
'fields.message': { minLength: 10 }
|
|
524
|
-
}
|
|
525
|
-
)
|
|
526
|
-
assert(errors.length === 3, `Expected 3 errors, got ${errors.length}: ${errors.map(e => e.rule).join(', ')}`)
|
|
527
|
-
})
|
|
528
|
-
|
|
529
|
-
test('no errors on valid state', () => {
|
|
530
|
-
const errors = validateFields(
|
|
531
|
-
{ fields: { name: 'Andy', email: 'andy@example.com', message: 'Hello world this is a message' } },
|
|
532
|
-
{
|
|
533
|
-
'fields.name': { required: true, minLength: 2 },
|
|
534
|
-
'fields.email': { required: true, format: 'email' },
|
|
535
|
-
'fields.message': { required: true, minLength: 10, maxLength: 1000 }
|
|
536
|
-
}
|
|
537
|
-
)
|
|
538
|
-
assert(errors.length === 0, `Expected no errors, got: ${errors.map(e => e.message).join(', ')}`)
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
// ---------------------------------------------------------------------------
|
|
542
|
-
|
|
543
|
-
console.log('\nDestroy\n')
|
|
544
|
-
|
|
545
|
-
test('destroy clears the element', () => {
|
|
546
|
-
const el = new FakeElement()
|
|
547
|
-
const instance = mount(counterSpec, el)
|
|
548
|
-
assert(el.innerHTML !== '', 'Should have content after mount')
|
|
549
|
-
instance.destroy()
|
|
550
|
-
assert(el.innerHTML === '', 'Should be empty after destroy')
|
|
551
|
-
})
|
|
552
|
-
|
|
553
|
-
// ---------------------------------------------------------------------------
|
|
554
|
-
|
|
555
|
-
console.log('\nPersistence\n')
|
|
556
|
-
|
|
557
|
-
const persistSpec = {
|
|
558
|
-
route: '/persist-test',
|
|
559
|
-
state: { count: 0, name: 'default' },
|
|
560
|
-
persist: ['count'],
|
|
561
|
-
view: (s) => `<span>${s.count}</span>`,
|
|
562
|
-
mutations: {
|
|
563
|
-
increment: (s) => ({ count: s.count + 1 }),
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
test('persist writes state to localStorage after mutation', () => {
|
|
568
|
-
localStorage.removeItem('pulse:/persist-test')
|
|
569
|
-
const el = new FakeElement()
|
|
570
|
-
const instance = mount(persistSpec, el)
|
|
571
|
-
instance.dispatch('increment')
|
|
572
|
-
const saved = JSON.parse(localStorage.getItem('pulse:/persist-test'))
|
|
573
|
-
assert(saved?.count === 1, `Expected saved count 1, got ${saved?.count}`)
|
|
574
|
-
})
|
|
575
|
-
|
|
576
|
-
test('persist restores state from localStorage on mount', () => {
|
|
577
|
-
localStorage.setItem('pulse:/persist-test', JSON.stringify({ count: 7 }))
|
|
578
|
-
const el = new FakeElement()
|
|
579
|
-
const instance = mount(persistSpec, el)
|
|
580
|
-
assert(instance.getState().count === 7, `Expected restored count 7, got ${instance.getState().count}`)
|
|
581
|
-
})
|
|
582
|
-
|
|
583
|
-
test('persist only saves declared keys, not full state', () => {
|
|
584
|
-
localStorage.removeItem('pulse:/persist-test')
|
|
585
|
-
const el = new FakeElement()
|
|
586
|
-
const instance = mount(persistSpec, el)
|
|
587
|
-
instance.dispatch('increment')
|
|
588
|
-
const saved = JSON.parse(localStorage.getItem('pulse:/persist-test'))
|
|
589
|
-
assert(!('name' in saved), `Should not persist undeclared key "name"`)
|
|
590
|
-
})
|
|
591
|
-
|
|
592
|
-
test('persist re-renders on SSR mount when saved value differs from default', () => {
|
|
593
|
-
localStorage.setItem('pulse:/persist-test', JSON.stringify({ count: 5 }))
|
|
594
|
-
const el = new FakeElement()
|
|
595
|
-
mount(persistSpec, el, {}, { ssr: true })
|
|
596
|
-
assert(el.innerHTML.includes('5'), `Expected restored count 5 in HTML, got: ${el.innerHTML}`)
|
|
597
|
-
})
|
|
598
|
-
|
|
599
|
-
test('persist skips re-render on SSR mount when saved value matches default', () => {
|
|
600
|
-
localStorage.setItem('pulse:/persist-test', JSON.stringify({ count: 0 }))
|
|
601
|
-
const el = new FakeElement()
|
|
602
|
-
el.innerHTML = '<span>server-rendered</span>'
|
|
603
|
-
mount(persistSpec, el, {}, { ssr: true })
|
|
604
|
-
assert(el.innerHTML === '<span>server-rendered</span>', 'Should preserve SSR HTML when state matches default')
|
|
605
|
-
})
|
|
606
|
-
|
|
607
|
-
test('spec without persist does not write to localStorage', () => {
|
|
608
|
-
const key = 'pulse:/counter'
|
|
609
|
-
localStorage.removeItem(key)
|
|
610
|
-
const el = new FakeElement()
|
|
611
|
-
const instance = mount(counterSpec, el)
|
|
612
|
-
instance.dispatch('increment')
|
|
613
|
-
assert(localStorage.getItem(key) === null, 'Should not write to localStorage without persist')
|
|
614
|
-
})
|
|
615
|
-
|
|
616
|
-
// ---------------------------------------------------------------------------
|
|
617
|
-
|
|
618
|
-
console.log('\nStore mutations\n')
|
|
619
|
-
|
|
620
|
-
import { registerStoreMutations, dispatchStoreMutation, getStoreState, updateStore, initLiveStore } from './store.js'
|
|
621
|
-
|
|
622
|
-
// Register all mutations once — the singleton ignores subsequent calls (by design)
|
|
623
|
-
registerStoreMutations({
|
|
624
|
-
bump: (store) => ({ count: (store.count ?? 0) + 1 }),
|
|
625
|
-
setTheme: (store, e) => ({ theme: e.target?.value ?? e }),
|
|
626
|
-
inc: (store) => ({ counter: (store.counter ?? 0) + 1 }),
|
|
627
|
-
})
|
|
628
|
-
|
|
629
|
-
test('registerStoreMutations registers mutation functions', () => {
|
|
630
|
-
dispatchStoreMutation('bump')
|
|
631
|
-
assert(getStoreState().count === 1, `Expected count 1, got ${getStoreState().count}`)
|
|
632
|
-
})
|
|
633
|
-
|
|
634
|
-
test('dispatchStoreMutation with payload passes it to the mutation', () => {
|
|
635
|
-
dispatchStoreMutation('setTheme', { target: { value: 'light' } })
|
|
636
|
-
assert(getStoreState().theme === 'light', `Expected theme "light", got ${getStoreState().theme}`)
|
|
637
|
-
})
|
|
638
|
-
|
|
639
|
-
test('dispatchStoreMutation warns on unknown mutation', () => {
|
|
640
|
-
const warnings = []
|
|
641
|
-
const orig = console.warn
|
|
642
|
-
console.warn = (...a) => warnings.push(a.join(' '))
|
|
643
|
-
dispatchStoreMutation('doesNotExist')
|
|
644
|
-
console.warn = orig
|
|
645
|
-
assert(warnings.some(w => w.includes('doesNotExist')), 'Expected warning for unknown store mutation')
|
|
646
|
-
})
|
|
647
|
-
|
|
648
|
-
await testAsync('dispatchStoreMutation notifies subscribed pages', async () => {
|
|
649
|
-
updateStore({ counter: 0 })
|
|
650
|
-
const el = new FakeElement()
|
|
651
|
-
const spec = {
|
|
652
|
-
route: '/store-sub',
|
|
653
|
-
store: ['counter'],
|
|
654
|
-
state: {},
|
|
655
|
-
view: (state, server) => `<span>${server.counter ?? 0}</span>`,
|
|
656
|
-
}
|
|
657
|
-
mount(spec, el, { counter: 0 })
|
|
658
|
-
// store.js is lazy-loaded by mount() when spec.store is non-empty — its
|
|
659
|
-
// subscription is wired up after the dynamic import() resolves. Resolving
|
|
660
|
-
// a dynamic import is scheduled as a macrotask even for an already-cached
|
|
661
|
-
// module (verified empirically — microtask-only yields are not enough),
|
|
662
|
-
// so a setTimeout(0) tick-yield is required here, not just Promise.resolve().
|
|
663
|
-
await new Promise(r => setTimeout(r, 0))
|
|
664
|
-
const before = el.innerHTML
|
|
665
|
-
dispatchStoreMutation('inc')
|
|
666
|
-
assert(el.innerHTML !== before, `Page should re-render after store mutation`)
|
|
667
|
-
assert(el.innerHTML.includes('1'), `Expected count 1 in HTML, got: ${el.innerHTML}`)
|
|
668
|
-
})
|
|
669
|
-
|
|
670
|
-
test('registerStoreMutations is a no-op after first call', () => {
|
|
671
|
-
const before = getStoreState().count
|
|
672
|
-
registerStoreMutations({ bump: () => ({ count: 999 }) }) // different fn — should be ignored
|
|
673
|
-
dispatchStoreMutation('bump')
|
|
674
|
-
assert(getStoreState().count === before + 1, `Should still use original mutation, got ${getStoreState().count}`)
|
|
675
|
-
})
|
|
676
|
-
|
|
677
|
-
console.log('\nLive store push (client)\n')
|
|
678
|
-
|
|
679
|
-
await testAsync('initLiveStore connects once, merges SSE store events, survives bad frames', async () => {
|
|
680
|
-
const instances = []
|
|
681
|
-
globalThis.EventSource = class {
|
|
682
|
-
constructor(url) { this.url = url; this._listeners = {}; instances.push(this) }
|
|
683
|
-
addEventListener(type, fn) { this._listeners[type] = fn }
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
initLiveStore('/__pulse/live')
|
|
687
|
-
initLiveStore('/__pulse/live') // singleton — second call is a no-op
|
|
688
|
-
assert(instances.length === 1, `Expected one EventSource, got ${instances.length}`)
|
|
689
|
-
assert(instances[0].url === '/__pulse/live', `Wrong URL: ${instances[0].url}`)
|
|
690
|
-
|
|
691
|
-
// A store event merges into the client store and notifies subscribers
|
|
692
|
-
const el = new FakeElement()
|
|
693
|
-
mount({ route: '/stock', store: ['stock'], state: {}, view: (s, server) => `<b>${server.stock ?? '—'}</b>` }, el, {})
|
|
694
|
-
// store.js is lazy-loaded when spec.store is non-empty — wait for the dynamic
|
|
695
|
-
// import to resolve before the subscription (wired inside it) is live.
|
|
696
|
-
await new Promise(r => setTimeout(r, 0))
|
|
697
|
-
instances[0]._listeners.store({ data: JSON.stringify({ stock: 7 }) })
|
|
698
|
-
assert(getStoreState().stock === 7, `Expected stock 7, got ${getStoreState().stock}`)
|
|
699
|
-
assert(el.innerHTML.includes('7'), `Subscribed page should re-render: ${el.innerHTML}`)
|
|
700
|
-
|
|
701
|
-
// Malformed frames must never throw or break the page
|
|
702
|
-
instances[0]._listeners.store({ data: 'not-json' })
|
|
703
|
-
instances[0]._listeners.store({ data: '"a-string"' })
|
|
704
|
-
assert(getStoreState().stock === 7, 'Bad frames must not corrupt the store')
|
|
705
|
-
|
|
706
|
-
delete globalThis.EventSource
|
|
707
|
-
})
|
|
708
|
-
|
|
709
|
-
console.log('\nLazy store loading (through mount())\n')
|
|
710
|
-
|
|
711
|
-
await testAsync('mount() with spec.store lazy-loads store.js and wires reactivity end to end', async () => {
|
|
712
|
-
updateStore({ counter: 100 })
|
|
713
|
-
const el = new FakeElement()
|
|
714
|
-
const spec = {
|
|
715
|
-
route: '/lazy-store',
|
|
716
|
-
store: ['counter'],
|
|
717
|
-
state: {},
|
|
718
|
-
view: (state, server) => `<span>${server.counter ?? 0}</span>`,
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
const instance = mount(spec, el, { counter: 100 })
|
|
722
|
-
|
|
723
|
-
// mount() returns synchronously, before store.js's dynamic import has resolved —
|
|
724
|
-
// the view renders with the page-level serverState snapshot passed to mount()
|
|
725
|
-
// (not yet the live store slice), and store-driven reactivity is not wired yet.
|
|
726
|
-
assert(el.innerHTML.includes('100'), `Expected initial render to use the snapshot: ${el.innerHTML}`)
|
|
727
|
-
|
|
728
|
-
// Yield a macrotask so the lazy import('./store.js') resolves. This is the
|
|
729
|
-
// "one microtask/tick later" cost documented for the lazy-loading change —
|
|
730
|
-
// after this point store subscription, registerStoreMutations, and (when
|
|
731
|
-
// window.__PULSE_LIVE__ is set) initLiveStore have all been wired up.
|
|
732
|
-
await new Promise(r => setTimeout(r, 0))
|
|
733
|
-
|
|
734
|
-
// Store-driven re-render now works correctly end to end through mount():
|
|
735
|
-
// a mutation dispatched on the singleton reaches this page's subscription.
|
|
736
|
-
const before = el.innerHTML
|
|
737
|
-
dispatchStoreMutation('inc')
|
|
738
|
-
assert(el.innerHTML !== before, 'Page should re-render once store.js has loaded and subscribed')
|
|
739
|
-
assert(el.innerHTML.includes('101'), `Expected counter 101 in HTML, got: ${el.innerHTML}`)
|
|
740
|
-
|
|
741
|
-
instance.destroy()
|
|
742
|
-
})
|
|
743
|
-
|
|
744
|
-
// ---------------------------------------------------------------------------
|
|
745
|
-
|
|
746
|
-
console.log('\nDebounce / throttle\n')
|
|
747
|
-
|
|
748
|
-
await testAsync('debounce: fires once after delay, not on every call', async () => {
|
|
749
|
-
let calls = 0
|
|
750
|
-
const fn = debounce(() => { calls++ }, 20)
|
|
751
|
-
fn(); fn(); fn()
|
|
752
|
-
assert(calls === 0, 'Should not have fired yet')
|
|
753
|
-
await new Promise(r => setTimeout(r, 40))
|
|
754
|
-
assert(calls === 1, `Expected 1 call, got ${calls}`)
|
|
755
|
-
})
|
|
756
|
-
|
|
757
|
-
await testAsync('debounce: resets timer on each call', async () => {
|
|
758
|
-
let calls = 0
|
|
759
|
-
const fn = debounce(() => { calls++ }, 30)
|
|
760
|
-
fn()
|
|
761
|
-
await new Promise(r => setTimeout(r, 15))
|
|
762
|
-
fn() // reset
|
|
763
|
-
await new Promise(r => setTimeout(r, 15))
|
|
764
|
-
assert(calls === 0, 'Should not have fired — timer was reset')
|
|
765
|
-
await new Promise(r => setTimeout(r, 25))
|
|
766
|
-
assert(calls === 1, `Expected 1 call after full delay, got ${calls}`)
|
|
767
|
-
})
|
|
768
|
-
|
|
769
|
-
await testAsync('debounce: passes latest args to handler', async () => {
|
|
770
|
-
let last
|
|
771
|
-
const fn = debounce((v) => { last = v }, 20)
|
|
772
|
-
fn('a'); fn('b'); fn('c')
|
|
773
|
-
await new Promise(r => setTimeout(r, 40))
|
|
774
|
-
assert(last === 'c', `Expected "c", got "${last}"`)
|
|
775
|
-
})
|
|
776
|
-
|
|
777
|
-
await testAsync('throttle: fires immediately on first call', async () => {
|
|
778
|
-
let calls = 0
|
|
779
|
-
const fn = throttle(() => { calls++ }, 50)
|
|
780
|
-
fn()
|
|
781
|
-
assert(calls === 1, `Expected 1 call, got ${calls}`)
|
|
782
|
-
})
|
|
783
|
-
|
|
784
|
-
await testAsync('throttle: suppresses calls within the delay window', async () => {
|
|
785
|
-
let calls = 0
|
|
786
|
-
const fn = throttle(() => { calls++ }, 50)
|
|
787
|
-
fn(); fn(); fn()
|
|
788
|
-
assert(calls === 1, `Expected 1 call, got ${calls}`)
|
|
789
|
-
})
|
|
790
|
-
|
|
791
|
-
await testAsync('throttle: allows another call after delay has passed', async () => {
|
|
792
|
-
let calls = 0
|
|
793
|
-
const fn = throttle(() => { calls++ }, 30)
|
|
794
|
-
fn()
|
|
795
|
-
await new Promise(r => setTimeout(r, 40))
|
|
796
|
-
fn()
|
|
797
|
-
assert(calls === 2, `Expected 2 calls, got ${calls}`)
|
|
798
|
-
})
|
|
799
|
-
|
|
800
|
-
// ---------------------------------------------------------------------------
|
|
801
|
-
|
|
802
|
-
console.log('\nError boundaries\n')
|
|
803
|
-
|
|
804
|
-
test('view error: renders default fallback when view throws', () => {
|
|
805
|
-
const el = new FakeElement()
|
|
806
|
-
const spec = {
|
|
807
|
-
route: '/err',
|
|
808
|
-
state: {},
|
|
809
|
-
view: () => { throw new Error('boom') }
|
|
810
|
-
}
|
|
811
|
-
mount(spec, el)
|
|
812
|
-
assert(el.innerHTML.includes('View error'), `Expected fallback HTML, got: ${el.innerHTML}`)
|
|
813
|
-
assert(el.innerHTML.includes('boom'), `Expected error message in fallback, got: ${el.innerHTML}`)
|
|
814
|
-
})
|
|
815
|
-
|
|
816
|
-
test('view error: calls spec.onViewError when view throws', () => {
|
|
817
|
-
const el = new FakeElement()
|
|
818
|
-
let capturedErr, capturedState
|
|
819
|
-
const spec = {
|
|
820
|
-
route: '/err',
|
|
821
|
-
state: { x: 42 },
|
|
822
|
-
view: () => { throw new Error('oops') },
|
|
823
|
-
onViewError: (err, state) => {
|
|
824
|
-
capturedErr = err
|
|
825
|
-
capturedState = state
|
|
826
|
-
return '<p>custom fallback</p>'
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
mount(spec, el)
|
|
830
|
-
assert(el.innerHTML.includes('custom fallback'), `Expected custom fallback, got: ${el.innerHTML}`)
|
|
831
|
-
assert(capturedErr?.message === 'oops', `Expected err.message "oops", got "${capturedErr?.message}"`)
|
|
832
|
-
assert(capturedState?.x === 42, `Expected state.x 42, got ${capturedState?.x}`)
|
|
833
|
-
})
|
|
834
|
-
|
|
835
|
-
test('view error: continues working after recovery — next dispatch re-renders', () => {
|
|
836
|
-
const el = new FakeElement()
|
|
837
|
-
let shouldThrow = true
|
|
838
|
-
const spec = {
|
|
839
|
-
route: '/err',
|
|
840
|
-
state: { ok: false },
|
|
841
|
-
view: (state) => {
|
|
842
|
-
if (shouldThrow) throw new Error('transient')
|
|
843
|
-
return `<p>${state.ok}</p>`
|
|
844
|
-
},
|
|
845
|
-
mutations: { fix: () => ({ ok: true }) }
|
|
846
|
-
}
|
|
847
|
-
const app = mount(spec, el)
|
|
848
|
-
assert(el.innerHTML.includes('View error'), 'Should show fallback initially')
|
|
849
|
-
shouldThrow = false
|
|
850
|
-
app.dispatch('fix')
|
|
851
|
-
assert(el.innerHTML.includes('true'), `Expected recovered render, got: ${el.innerHTML}`)
|
|
852
|
-
})
|
|
853
|
-
|
|
854
|
-
// ---------------------------------------------------------------------------
|
|
855
|
-
|
|
856
|
-
console.log('\nToast (_toast)\n')
|
|
857
|
-
|
|
858
|
-
test('_toast in mutation is stripped from spec state', () => {
|
|
859
|
-
const el = new FakeElement()
|
|
860
|
-
const spec = {
|
|
861
|
-
route: '/t',
|
|
862
|
-
state: { count: 0 },
|
|
863
|
-
view: (state) => `<p>${state.count}${state._toast ?? ''}</p>`,
|
|
864
|
-
mutations: {
|
|
865
|
-
inc: (state) => ({ count: state.count + 1, _toast: { message: 'Done!' } })
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
const app = mount(spec, el)
|
|
869
|
-
app.dispatch('inc')
|
|
870
|
-
assert(app.getState().count === 1, 'count should increment')
|
|
871
|
-
assert(app.getState()._toast === undefined, '_toast must not be in state')
|
|
872
|
-
})
|
|
873
|
-
|
|
874
|
-
await testAsync('_toast in onSuccess is stripped from spec state', async () => {
|
|
875
|
-
const el = new FakeElement()
|
|
876
|
-
const spec = {
|
|
877
|
-
route: '/t',
|
|
878
|
-
state: { saved: false },
|
|
879
|
-
view: (state) => `<p>${state.saved}${state._toast ?? ''}</p>`,
|
|
880
|
-
actions: {
|
|
881
|
-
save: {
|
|
882
|
-
run: async () => 'ok',
|
|
883
|
-
onSuccess: () => ({ saved: true, _toast: { message: 'Saved!', variant: 'success' } }),
|
|
884
|
-
onError: () => ({ saved: false, _toast: { message: 'Error', variant: 'error' } }),
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
const app = mount(spec, el)
|
|
889
|
-
await app.dispatch('save')
|
|
890
|
-
assert(app.getState().saved === true, 'saved should be true')
|
|
891
|
-
assert(app.getState()._toast === undefined, '_toast must not be in state')
|
|
892
|
-
})
|
|
893
|
-
|
|
894
|
-
await testAsync('_toast in onError is stripped from spec state', async () => {
|
|
895
|
-
const el = new FakeElement()
|
|
896
|
-
const spec = {
|
|
897
|
-
route: '/t',
|
|
898
|
-
state: { failed: false },
|
|
899
|
-
view: (state) => `${state.failed}`,
|
|
900
|
-
actions: {
|
|
901
|
-
save: {
|
|
902
|
-
run: async () => { throw new Error('network') },
|
|
903
|
-
onSuccess: () => ({}),
|
|
904
|
-
onError: () => ({ failed: true, _toast: { message: 'Failed', variant: 'error' } }),
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
const app = mount(spec, el)
|
|
909
|
-
await app.dispatch('save')
|
|
910
|
-
assert(app.getState().failed === true, 'failed should be true')
|
|
911
|
-
assert(app.getState()._toast === undefined, '_toast must not be in state')
|
|
912
|
-
})
|
|
913
|
-
|
|
914
|
-
// ---------------------------------------------------------------------------
|
|
915
|
-
|
|
916
|
-
console.log('\nPointer / keyboard event binding\n')
|
|
917
|
-
|
|
918
|
-
test('pointerdown dispatches mutation and captures the pointer', () => {
|
|
919
|
-
const el = new DelegatingElement()
|
|
920
|
-
const dragSpec = {
|
|
921
|
-
route: '/drag',
|
|
922
|
-
state: { dragging: false },
|
|
923
|
-
view: (state) => `<div data-event="pointerdown:startDrag">${state.dragging}</div>`,
|
|
924
|
-
mutations: { startDrag: (state) => ({ dragging: true }) },
|
|
925
|
-
}
|
|
926
|
-
mount(dragSpec, el)
|
|
927
|
-
|
|
928
|
-
const child = new ChildElement({ event: 'pointerdown:startDrag' })
|
|
929
|
-
child._root = el
|
|
930
|
-
let captured = null
|
|
931
|
-
child.setPointerCapture = (id) => { captured = id }
|
|
932
|
-
fireOn(el, child, 'pointerdown', { pointerId: 1 })
|
|
933
|
-
|
|
934
|
-
assert(el.innerHTML.includes('true'), `Expected dragging=true, got: ${el.innerHTML}`)
|
|
935
|
-
assert(captured === 1, `Expected pointer capture with id 1, got ${captured}`)
|
|
936
|
-
})
|
|
937
|
-
|
|
938
|
-
test('pointermove dispatches mutation while captured, ignores elements without data-event', () => {
|
|
939
|
-
const el = new DelegatingElement()
|
|
940
|
-
const dragSpec = {
|
|
941
|
-
route: '/drag-move',
|
|
942
|
-
state: { x: 0 },
|
|
943
|
-
view: (state) => `<div data-event="pointermove:move">${state.x}</div>`,
|
|
944
|
-
mutations: { move: (state, e) => ({ x: e.clientX }) },
|
|
945
|
-
}
|
|
946
|
-
mount(dragSpec, el)
|
|
947
|
-
|
|
948
|
-
const child = new ChildElement({ event: 'pointermove:move' })
|
|
949
|
-
child._root = el
|
|
950
|
-
fireOn(el, child, 'pointermove', { clientX: 42 })
|
|
951
|
-
assert(el.innerHTML.includes('42'), `Expected x=42, got: ${el.innerHTML}`)
|
|
952
|
-
|
|
953
|
-
const plainChild = new ChildElement({})
|
|
954
|
-
plainChild._root = el
|
|
955
|
-
fireOn(el, plainChild, 'pointermove', { clientX: 999 })
|
|
956
|
-
assert(el.innerHTML.includes('42'), `Element without data-event should not dispatch: ${el.innerHTML}`)
|
|
957
|
-
})
|
|
958
|
-
|
|
959
|
-
test('pointerup dispatches mutation and releases capture', () => {
|
|
960
|
-
const el = new DelegatingElement()
|
|
961
|
-
const dragSpec = {
|
|
962
|
-
route: '/drag-up',
|
|
963
|
-
state: { dragging: true },
|
|
964
|
-
view: (state) => `<div data-event="pointerup:endDrag">${state.dragging}</div>`,
|
|
965
|
-
mutations: { endDrag: () => ({ dragging: false }) },
|
|
966
|
-
}
|
|
967
|
-
mount(dragSpec, el)
|
|
968
|
-
|
|
969
|
-
const child = new ChildElement({ event: 'pointerup:endDrag' })
|
|
970
|
-
child._root = el
|
|
971
|
-
let released = null
|
|
972
|
-
child.releasePointerCapture = (id) => { released = id }
|
|
973
|
-
fireOn(el, child, 'pointerup', { pointerId: 7 })
|
|
974
|
-
|
|
975
|
-
assert(el.innerHTML.includes('false'), `Expected dragging=false, got: ${el.innerHTML}`)
|
|
976
|
-
assert(released === 7, `Expected pointer capture released with id 7, got ${released}`)
|
|
977
|
-
})
|
|
978
|
-
|
|
979
|
-
test('pointercancel dispatches the same mutation as pointerup and releases capture', () => {
|
|
980
|
-
const el = new DelegatingElement()
|
|
981
|
-
const dragSpec = {
|
|
982
|
-
route: '/drag-cancel',
|
|
983
|
-
state: { dragging: true },
|
|
984
|
-
view: (state) => `<div data-event="pointerup:endDrag">${state.dragging}</div>`,
|
|
985
|
-
mutations: { endDrag: () => ({ dragging: false }) },
|
|
986
|
-
}
|
|
987
|
-
mount(dragSpec, el)
|
|
988
|
-
|
|
989
|
-
const child = new ChildElement({ event: 'pointerup:endDrag' })
|
|
990
|
-
child._root = el
|
|
991
|
-
let released = null
|
|
992
|
-
child.releasePointerCapture = (id) => { released = id }
|
|
993
|
-
fireOn(el, child, 'pointercancel', { pointerId: 3 })
|
|
994
|
-
|
|
995
|
-
assert(el.innerHTML.includes('false'), `Expected dragging=false after cancel, got: ${el.innerHTML}`)
|
|
996
|
-
assert(released === 3, `Expected pointer capture released on cancel, got ${released}`)
|
|
997
|
-
})
|
|
998
|
-
|
|
999
|
-
test('keydown dispatches mutation with the key', () => {
|
|
1000
|
-
const el = new DelegatingElement()
|
|
1001
|
-
const keySpec = {
|
|
1002
|
-
route: '/key',
|
|
1003
|
-
state: { key: '' },
|
|
1004
|
-
view: (state) => `<div data-event="keydown:onKey">${state.key}</div>`,
|
|
1005
|
-
mutations: { onKey: (state, e) => ({ key: e.key }) },
|
|
1006
|
-
}
|
|
1007
|
-
mount(keySpec, el)
|
|
1008
|
-
|
|
1009
|
-
const child = new ChildElement({ event: 'keydown:onKey' })
|
|
1010
|
-
child._root = el
|
|
1011
|
-
fireOn(el, child, 'keydown', { key: 'ArrowLeft' })
|
|
1012
|
-
|
|
1013
|
-
assert(el.innerHTML.includes('ArrowLeft'), `Expected key=ArrowLeft, got: ${el.innerHTML}`)
|
|
1014
|
-
})
|
|
1015
|
-
|
|
1016
|
-
test('data-event supports multiple space-separated bindings on one element', () => {
|
|
1017
|
-
const el = new DelegatingElement()
|
|
1018
|
-
const dragSpec = {
|
|
1019
|
-
route: '/drag-multi',
|
|
1020
|
-
state: { dragging: false, x: 0 },
|
|
1021
|
-
view: (state) => `<div data-event="pointerdown:startDrag pointermove:dragMove pointerup:endDrag">${state.dragging}:${state.x}</div>`,
|
|
1022
|
-
mutations: {
|
|
1023
|
-
startDrag: () => ({ dragging: true }),
|
|
1024
|
-
dragMove: (state, e) => state.dragging ? { x: e.clientX } : {},
|
|
1025
|
-
endDrag: () => ({ dragging: false }),
|
|
1026
|
-
},
|
|
1027
|
-
}
|
|
1028
|
-
mount(dragSpec, el)
|
|
1029
|
-
|
|
1030
|
-
const child = new ChildElement({ event: 'pointerdown:startDrag pointermove:dragMove pointerup:endDrag' })
|
|
1031
|
-
child._root = el
|
|
1032
|
-
|
|
1033
|
-
fireOn(el, child, 'pointerdown', { pointerId: 1 })
|
|
1034
|
-
assert(el.innerHTML.includes('true:0'), `Expected dragging=true, got: ${el.innerHTML}`)
|
|
1035
|
-
|
|
1036
|
-
fireOn(el, child, 'pointermove', { clientX: 55 })
|
|
1037
|
-
assert(el.innerHTML.includes('true:55'), `Expected x=55 while dragging, got: ${el.innerHTML}`)
|
|
1038
|
-
|
|
1039
|
-
fireOn(el, child, 'pointerup', { pointerId: 1 })
|
|
1040
|
-
assert(el.innerHTML.includes('false:55'), `Expected dragging=false, got: ${el.innerHTML}`)
|
|
1041
|
-
|
|
1042
|
-
// pointermove after release is a no-op because the mutation gates on `dragging`
|
|
1043
|
-
fireOn(el, child, 'pointermove', { clientX: 999 })
|
|
1044
|
-
assert(el.innerHTML.includes('false:55'), `Expected no change after release, got: ${el.innerHTML}`)
|
|
1045
|
-
})
|
|
1046
|
-
|
|
1047
|
-
test('pointerdown/pointerup ignore wrong event type on the same element', () => {
|
|
1048
|
-
const el = new DelegatingElement()
|
|
1049
|
-
const spec = {
|
|
1050
|
-
route: '/only-down',
|
|
1051
|
-
state: { count: 0 },
|
|
1052
|
-
view: (state) => `<div data-event="pointerdown:bump">${state.count}</div>`,
|
|
1053
|
-
mutations: { bump: (state) => ({ count: state.count + 1 }) },
|
|
1054
|
-
}
|
|
1055
|
-
mount(spec, el)
|
|
1056
|
-
|
|
1057
|
-
const child = new ChildElement({ event: 'pointerdown:bump' })
|
|
1058
|
-
child._root = el
|
|
1059
|
-
fireOn(el, child, 'pointerup', { pointerId: 1 }) // wrong type — should not fire
|
|
1060
|
-
assert(el.innerHTML.includes('>0<'), `Should not have dispatched on pointerup: ${el.innerHTML}`)
|
|
1061
|
-
|
|
1062
|
-
fireOn(el, child, 'pointerdown', { pointerId: 1 }) // correct type — should fire
|
|
1063
|
-
assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
|
|
1064
|
-
})
|
|
1065
|
-
|
|
1066
|
-
// ---------------------------------------------------------------------------
|
|
1067
|
-
|
|
1068
|
-
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
|
1069
|
-
if (failed > 0) process.exit(1)
|