@uistate/core 5.4.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,11 @@
1
- # UIstate v5
1
+ # @uistate/core
2
2
 
3
- **Lightweight event-driven state management for modern web applications**
3
+ Path-based state management with wildcard subscriptions and async support.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@uistate/core.svg)](https://www.npmjs.com/package/@uistate/core)
6
- [![License: MIT + Commercial](https://img.shields.io/badge/License-MIT%20%2B%20Commercial-blue.svg)](#license)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE)
7
7
 
8
- After many experiments exploring different state management paradigms, UIstate v5 emerges as a focused, production-ready solution for complex UIs. Born from real-world challenges like building data tables, dashboards, and interactive applications.
9
-
10
- ## What's New in v5
11
-
12
- - **EventState Core**: Path-based subscriptions with wildcard support (~80 LOC)
13
- - **Slot Orchestration**: Atomic component swapping without layout shift
14
- - **Intent-Driven**: Declarative command handling for decoupled architecture
15
- - **Zero Dependencies**: Pure JavaScript, works in any modern browser
16
- - **Framework-Free**: No build step required (but works great with Vite)
17
-
18
- ## Installation
8
+ ## Install
19
9
 
20
10
  ```bash
21
11
  npm install @uistate/core
@@ -26,424 +16,101 @@ npm install @uistate/core
26
16
  ```javascript
27
17
  import { createEventState } from '@uistate/core';
28
18
 
29
- const store = createEventState({
30
- user: { name: 'Alice' },
31
- count: 0
32
- });
19
+ const store = createEventState({ user: { name: 'Alice' }, count: 0 });
33
20
 
34
- // Subscribe to specific paths
35
- store.subscribe('user.name', (value) => {
36
- console.log('Name changed:', value);
37
- });
21
+ // Exact path
22
+ store.subscribe('count', (value) => console.log('Count:', value));
38
23
 
39
- // Subscribe with wildcards
40
- store.subscribe('user.*', ({ path, value }) => {
41
- console.log(`${path} changed to:`, value);
42
- });
24
+ // Wildcard fires on any child of 'user'
25
+ store.subscribe('user.*', ({ path, value }) => console.log(path, value));
26
+
27
+ // Global — fires on every change
28
+ store.subscribe('*', ({ path, value }) => console.log(path, value));
43
29
 
44
- // Update state
45
- store.set('user.name', 'Bob'); // → Name changed: Bob
30
+ store.set('user.name', 'Bob');
46
31
  store.set('count', 1);
47
32
  ```
48
33
 
49
- ## Core API
50
-
51
- ### `createEventState(initial?)`
34
+ ## API
52
35
 
53
- Creates a new reactive store.
54
-
55
- ```javascript
56
- const store = createEventState({ count: 0 });
57
- ```
36
+ ### `createEventState(initialState)`
58
37
 
59
- ### `store.get(path)`
38
+ Returns a store with `get`, `set`, `subscribe`, `setAsync`, `cancel`, `destroy`.
60
39
 
61
- Retrieves a value by path.
40
+ ### `store.get(path?)`
62
41
 
63
42
  ```javascript
64
- const count = store.get('count');
65
- const user = store.get('user'); // Returns entire user object
66
- const all = store.get(); // Returns entire store
43
+ store.get('user.name'); // 'Alice'
44
+ store.get('user'); // { name: 'Alice' }
45
+ store.get(); // entire state
67
46
  ```
68
47
 
69
48
  ### `store.set(path, value)`
70
49
 
71
- Sets a value and triggers subscriptions.
50
+ Sets a value and notifies subscribers. Creates intermediate objects if needed.
72
51
 
73
52
  ```javascript
74
- store.set('count', 42);
75
53
  store.set('user.email', 'alice@example.com');
54
+ // state.user.email now exists even if it didn't before
76
55
  ```
77
56
 
78
57
  ### `store.subscribe(path, handler)`
79
58
 
80
- Subscribes to changes. Returns an unsubscribe function.
59
+ Returns an unsubscribe function. Exact subscribers get `(value, detail)`. Wildcard/global subscribers get `(detail)` where detail is `{ path, value, oldValue }`.
81
60
 
82
61
  ```javascript
83
- // Exact path
84
- const unsub = store.subscribe('count', (value) => {
85
- console.log('Count:', value);
86
- });
87
-
88
- // Wildcard (child changes)
89
- store.subscribe('user.*', ({ path, value }) => {
90
- console.log(`User property ${path} changed`);
62
+ const unsub = store.subscribe('count', (value, { oldValue }) => {
63
+ console.log(`${oldValue} → ${value}`);
91
64
  });
92
-
93
- // Global wildcard (all changes)
94
- store.subscribe('*', ({ path, value }) => {
95
- console.log('Something changed');
96
- });
97
-
98
- // Cleanup
99
- unsub();
65
+ unsub(); // cleanup
100
66
  ```
101
67
 
102
- ### `store.destroy()`
103
-
104
- Cleans up the event bus and prevents further updates.
105
-
106
- ```javascript
107
- store.destroy();
108
- ```
109
-
110
- # Changelog
111
-
112
- ## [5.0.0] - 2025-12-09
68
+ ### `store.setAsync(path, fetcher)`
113
69
 
114
- ### Breaking Changes
115
- - eventState is now primary export (was cssState)
116
- - DOM element event bus replaced with EventTarget
117
- - ...
118
-
119
- ### Added
120
- - Slot orchestration pattern
121
- - Intent-driven architecture
122
- - Event-sequence testing (experimental)
123
- - Example 009: Financial dashboard
124
- - Example 010: Event testing
125
-
126
- ## Architecture Patterns
127
-
128
- UIstate v5 introduces battle-tested patterns from many experiments:
129
-
130
- ### 1. **Intent-Driven Updates** (Declarative Commands)
131
-
132
- Intents are just regular state paths—no special API needed. Components subscribe to intent paths and execute logic when triggered:
70
+ Manages async state at `${path}.status`, `${path}.data`, `${path}.error`. Supports abort on re-call.
133
71
 
134
72
  ```javascript
135
- // Subscribe to an intent path
136
- store.subscribe('intent.todo.add', ({ text }) => {
137
- const items = store.get('todos.items') || [];
138
- store.set('todos.items', [...items, { id: Date.now(), text, done: false }]);
139
- });
140
-
141
- // Trigger intent from UI by setting the path
142
- button.addEventListener('click', () => {
143
- store.set('intent.todo.add', { text: input.value });
73
+ await store.setAsync('users', async (signal) => {
74
+ const res = await fetch('/api/users', { signal });
75
+ return res.json();
144
76
  });
77
+ // store.get('users.data') → [...]
78
+ // store.get('users.status') → 'success'
145
79
  ```
146
80
 
147
- **Key insight**: Intents are paths, not events. This means:
148
- - Subscribe once at component mount
149
- - Multiple components can listen to the same intent
150
- - Easy to test (just `set()` the intent path)
151
- - No special event emitter needed
152
-
153
- ### 2. **Slot Orchestration** (Flicker-free component loading)
154
-
155
- Load layouts and components separately for atomic, layout-shift-free updates:
156
-
157
- ```javascript
158
- // 1. Mount persistent layout once
159
- async function mountMaster(master) {
160
- const layout = await fetchHTML(master.layout);
161
- document.getElementById('app').innerHTML = layout;
162
- }
163
-
164
- // 2. Load components into slots atomically
165
- async function loadSlot({ slot, url }) {
166
- const config = await fetchJSON(url);
167
- const html = await fetchHTML(config.component);
168
-
169
- // Find slot container
170
- const slotHost = document.querySelector(`[data-slot="${slot}"]`);
171
-
172
- // Atomic replacement - no layout shift!
173
- const fragment = createFragment(html);
174
- slotHost.replaceChildren(fragment);
175
-
176
- // Wire up component subscriptions
177
- wireComponent(slotHost, config.bindings);
178
- }
179
-
180
- // Trigger slot loading via intent
181
- store.subscribe('intent.slot.load', loadSlot);
182
- store.set('intent.slot.load', { slot: 'main', url: '/slots/todo.json' });
183
- ```
184
-
185
- **Key insight**: Separate layout (skeleton) from content (slots):
186
- - Layout provides stable structure
187
- - Slots swap atomically without reflow
188
- - Each component manages its own subscriptions
189
- - Clean separation of concerns
190
-
191
- ### 3. **Component Bindings** (Path-based reactivity)
192
-
193
- Components subscribe to specific state paths, not global wildcards:
194
-
195
- ```javascript
196
- function wireComponent(root, bindings) {
197
- const input = root.querySelector('#input');
198
- const list = root.querySelector('#list');
199
- const itemsPath = bindings.items || 'domain.items';
200
-
201
- // Subscribe to specific path for this component
202
- const unsubscribe = store.subscribe(itemsPath, (items) => {
203
- list.replaceChildren();
204
- items.forEach(item => {
205
- const li = document.createElement('li');
206
- li.textContent = item.text;
207
- list.appendChild(li);
208
- });
209
- });
210
-
211
- // Initial render
212
- const items = store.get(itemsPath);
213
- if (items) store.set(itemsPath, items);
214
-
215
- // Return cleanup function
216
- return unsubscribe;
217
- }
218
- ```
81
+ ### `store.cancel(path)` / `store.destroy()`
219
82
 
220
- **Key insight**: Each component subscribes to its own paths:
221
- - No global `*` wildcards in production code
222
- - Components are isolated and testable
223
- - Easy cleanup when unmounting
224
- - Clear data dependencies
83
+ Cancel an in-flight async operation, or tear down the entire store.
225
84
 
226
- ### 4. **Component Lifecycle Pattern**
85
+ ## Query Client
227
86
 
228
- Proper mount/unmount with subscription cleanup:
87
+ Convenience wrapper around `setAsync` for data-fetching patterns.
229
88
 
230
89
  ```javascript
231
- const componentRegistry = new Map();
232
-
233
- async function loadSlot({ slot, url }) {
234
- // Cleanup previous component in this slot
235
- const existing = componentRegistry.get(slot);
236
- if (existing?.cleanup) {
237
- existing.cleanup();
238
- }
239
-
240
- // Load and mount new component
241
- const config = await fetchJSON(url);
242
- const html = await fetchHTML(config.component);
243
- const slotHost = document.querySelector(`[data-slot="${slot}"]`);
244
- slotHost.replaceChildren(createFragment(html));
245
-
246
- // Wire up with cleanup function
247
- const cleanup = wireComponent(slotHost, config.bindings);
248
-
249
- // Store for later cleanup
250
- componentRegistry.set(slot, { cleanup });
251
- }
90
+ import { createQueryClient } from '@uistate/core/query';
91
+
92
+ const qc = createQueryClient(store);
93
+ await qc.query('users', (signal) => fetch('/api/users', { signal }).then(r => r.json()));
94
+ qc.getData('users');
95
+ qc.getStatus('users');
96
+ qc.cancel('users');
97
+ qc.invalidate('users');
252
98
  ```
253
99
 
254
- **Key insight**: Always clean up subscriptions:
255
- - Prevents memory leaks
256
- - Allows safe component swapping
257
- - Components are truly hot-swappable
258
- - No zombie subscriptions
100
+ ## Ecosystem
259
101
 
260
- ### 5. **Content-Driven Layout** (Layout morphs based on active content)
261
-
262
- One layout HTML can adapt to different use cases by subscribing to state and toggling CSS classes:
263
-
264
- ```javascript
265
- function wireLayout(root, bindings) {
266
- // ...existing button wiring...
267
-
268
- // Layout adapts based on which slot is active
269
- store.subscribe('ui.view.active', (active) => {
270
- const kpiCol = root.querySelector('[data-slot="kpi"]').closest('.col-12');
271
- const newsCol = root.querySelector('[data-slot="news"]').closest('.col-12');
272
- const positionsCol = root.querySelector('[data-slot="positions"]').closest('.col-12');
273
-
274
- if (active === 'todos') {
275
- // Full-width editing mode
276
- kpiCol.classList.add('d-none');
277
- newsCol.classList.add('d-none');
278
- positionsCol.classList.remove('col-md-6', 'col-lg-4');
279
- positionsCol.classList.add('col-md-12', 'col-lg-8');
280
- } else {
281
- // Multi-column dashboard mode
282
- kpiCol.classList.remove('d-none');
283
- newsCol.classList.remove('d-none');
284
- positionsCol.classList.remove('col-md-12', 'col-lg-8');
285
- positionsCol.classList.add('col-md-6', 'col-lg-4');
286
- }
287
- });
288
- }
289
- ```
290
-
291
- **Key insight**: Content configures layout, not the other way around:
292
- - One layout HTML handles all scenarios
293
- - Panels show/hide based on active content
294
- - Sections expand/collapse responsively
295
- - Pure CSS class manipulation - no DOM restructuring
296
- - Declarative layout rules via state subscription
297
-
298
- **Real-world applications**:
299
- ```javascript
300
- // E-commerce: hide sidebar, expand product grid
301
- if (active === 'products') hideSlots(['filters', 'cart']); expandSlot('grid');
302
-
303
- // Admin: hide navigation, expand table
304
- if (active === 'report') hideSlots(['nav', 'sidebar']); expandSlot('table');
305
-
306
- // Editor: focus mode - hide everything except content
307
- if (active === 'editor') hideSlots(['toolbar', 'preview']); expandSlot('content');
308
- ```
309
-
310
- This pattern eliminates the "rigid layout" criticism - UIstate layouts are as flexible as any framework, with zero VDOM overhead.
311
-
312
- ## Complete Example
313
-
314
- See `examples/009-financial-dashboard` for a real-world application featuring:
315
-
316
- - Multi-slot dashboard layout
317
- - TodoList with filtering
318
- - Financial data tables (KPI, Positions, Debts, Sales, News)
319
- - Intent-driven architecture
320
- - Atomic slot swapping
321
- - State inspector
322
-
323
- Run the example:
324
-
325
- ```bash
326
- cd node_modules/@uistate/core/examples/009-financial-dashboard
327
- npx serve .
328
- ```
329
-
330
- Open http://localhost:3000 and explore the multi-slot dashboard!
331
-
332
- ## Why UIstate v5?
333
-
334
- ### vs. React/Vue
335
-
336
- - ✅ **No VDOM overhead** - Direct DOM manipulation with surgical updates
337
- - ✅ **No build step required** - Works in browsers directly
338
- - ✅ **Simpler mental model** - Paths + subscriptions, not components + lifecycle hooks
339
- - ✅ **Smaller bundle** - ~3KB core vs ~40KB+ frameworks
340
- - ✅ **Explicit reactivity** - Know exactly what triggers what
341
- - ❌ No component ecosystem
342
- - ❌ Manual DOM updates (but that's the point!)
343
-
344
- ### vs. Alpine.js
345
-
346
- - ✅ **Better for complex state** - Nested paths, cross-component communication
347
- - ✅ **First-class SPA patterns** - Slot orchestration, intent-driven updates
348
- - ✅ **More powerful subscriptions** - Path-based, wildcard support, granular control
349
- - ✅ **Explicit data flow** - No magic, clear cause and effect
350
- - ❌ More JavaScript required (Alpine is more declarative HTML)
351
-
352
- ### vs. Zustand/Jotai
353
-
354
- - ✅ **Framework-agnostic** - Not tied to React
355
- - ✅ **Path-based state** - More intuitive for deeply nested data
356
- - ✅ **Wildcard subscriptions** - Subscribe to entire subtrees when needed
357
- - ✅ **Simpler API** - Just get/set/subscribe, no atoms or stores
358
- - ❌ Smaller ecosystem
359
- - ❌ No React DevTools integration
360
-
361
- ## Use Cases
362
-
363
- UIstate v5 excels at:
364
-
365
- - ✅ **Internal dashboards** - Complex state, multiple data sources
366
- - ✅ **Admin panels** - CRUD operations, forms, data tables
367
- - ✅ **Financial applications** - Real-time updates, calculations
368
- - ✅ **Data-heavy UIs** - Large datasets, filtered views
369
- - ✅ **Multi-pane interfaces** - Independent but coordinated components
370
- - ✅ **Progressive enhancement** - Add interactivity to server-rendered HTML
371
-
372
- ## Browser Support
373
-
374
- - Chrome 60+
375
- - Firefox 54+
376
- - Safari 10.1+
377
- - Edge 79+
378
-
379
- ## Migration from v4
380
-
381
- UIstate v5 shifts focus from CSS-driven state to event-driven state. Key changes:
382
-
383
- 1. **eventState is now primary** - Import from root or `/eventState`
384
- 2. **cssState still available** - Import from `/cssState`
385
- 3. **New examples** - See `009-financial-dashboard`
386
- 4. **Breaking changes** - Major version bump
387
-
388
- ## Philosophy
389
-
390
- UIstate challenges traditional assumptions:
391
-
392
- - **State should be simple** - Paths + subscriptions, not selectors + reducers
393
- - **Reactivity should be explicit** - Know what updates what
394
- - **DOM updates can be fast** - Atomic replacements beat VDOM for many use cases
395
- - **Components should own their subscriptions** - No global wildcards in production
396
- - **Frameworks aren't always needed** - Pure JS + patterns can go far
397
- - **Build steps should be optional** - ESM works in browsers
398
- - **Intents are just paths** - No special event system needed
399
-
400
- UIstate v5 represents lessons learned from building:
401
- - Data tables with 1M+ rows
402
- - Multi-tab synchronized state
403
- - Workflowy-style nested lists
404
- - Financial dashboards
405
- - Admin panels
406
-
407
- The core insight: **Most web UIs need reactive state and component orchestration, not a full framework.**
408
-
409
- ## Testing (Experimental)
410
-
411
- UIstate includes `eventTest.js` for event-sequence testing:
412
-
413
- **Note:** `eventTest.js` is dual-licensed. Free for personal/OSS use. Commercial use requires a separate license. See LICENSE-eventTest.md for details.
414
-
415
- ```javascript
416
- import { createEventTest } from '@uistate/core/eventTest';
417
-
418
- const test = createEventTest({ count: 0 });
419
-
420
- test
421
- .trigger('intent.increment')
422
- .assertPath('count', 1)
423
- .assertEventFired('count', 1);
424
- ```
425
-
426
- See `examples/010-event-testing` for more.
102
+ | Package | Description |
103
+ |---|---|
104
+ | [@uistate/core](https://www.npmjs.com/package/@uistate/core) | This package event-driven state management |
105
+ | [@uistate/css](https://www.npmjs.com/package/@uistate/css) | CSS-native state via custom properties and data attributes |
106
+ | [@uistate/event-test](https://www.npmjs.com/package/@uistate/event-test) | Event-sequence testing (proprietary license) |
107
+ | [@uistate/examples](https://www.npmjs.com/package/@uistate/examples) | Example applications and patterns |
427
108
 
428
109
  ## License
429
110
 
430
- **@uistate/core** is licensed under the **MIT License**, with an exception, as decribed next.
431
-
432
- **Exception:** `eventTest.js` is licensed under a **proprietary license** that permits:
433
- - ✅ Personal use
434
- - ✅ Open-source projects
435
- - ✅ Educational use
436
-
437
- For **commercial use** of `eventTest.js`, please contact: [ajdika@live.com](mailto:ajdika@live.com)
438
-
439
- See the file header in `eventTest.js` for full terms.
440
-
441
- ---
442
-
443
- Copyright © 2025 Ajdin Imsirovic (ajdika@live.com)
111
+ MIT see [LICENSE](./LICENSE).
444
112
 
445
- - Core library: MIT License
446
- - eventTest.js: Commercial License (free for personal/OSS)
113
+ Copyright © 2025 Ajdin Imsirovic
447
114
 
448
115
  ## Links
449
116
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@uistate/core",
3
- "version": "5.4.0",
4
- "description": "Lightweight event-driven state management with slot orchestration and experimental event-sequence testing (eventTest.js available under dual license)",
3
+ "version": "5.5.1",
4
+ "description": "Lightweight event-driven state management with path-based subscriptions, wildcards, and async support",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -15,9 +15,7 @@
15
15
  "eventState.js",
16
16
  "eventStateNew.js",
17
17
  "queryClient.js",
18
- "eventTest.js",
19
- "LICENSE",
20
- "LICENSE-eventTest.md"
18
+ "LICENSE"
21
19
  ],
22
20
  "keywords": [
23
21
  "state-management",
@@ -28,8 +26,8 @@
28
26
  "zero-dependency",
29
27
  "framework-free",
30
28
  "micro-framework",
31
- "testing",
32
- "event-testing"
29
+ "async-state",
30
+ "query-client"
33
31
  ],
34
32
  "author": "Ajdin Imsirovic",
35
33
  "license": "MIT",
@@ -1,26 +0,0 @@
1
- # Proprietary License for eventTest.js
2
-
3
- Copyright (c) 2025 Ajdin Imsirovic
4
-
5
- ## Permitted Uses
6
-
7
- You may use `eventTest.js` for:
8
- - Personal projects
9
- - Open-source projects
10
- - Educational purposes
11
-
12
- ## Restrictions
13
-
14
- You may NOT:
15
- - Use this file in commercial products without a license
16
- - Modify or create derivative works
17
- - Redistribute this file separately from @uistate/core
18
- - Remove or alter this license notice
19
-
20
- ## Commercial Licensing
21
-
22
- For commercial use, please contact: your@email.com
23
-
24
- ---
25
-
26
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.
package/eventTest.js DELETED
@@ -1,196 +0,0 @@
1
- /**
2
- * eventTest.js - Event-Sequence Testing for UIstate
3
- *
4
- * Copyright (c) 2025 Ajdin Imsirovic
5
- *
6
- * This file is licensed under a PROPRIETARY LICENSE.
7
- *
8
- * Permission is hereby granted to USE this software for:
9
- * - Personal projects
10
- * - Open-source projects
11
- * - Educational purposes
12
- *
13
- * RESTRICTIONS:
14
- * - Commercial use requires a separate license (contact: your@email.com)
15
- * - Modification and redistribution of this file are NOT permitted
16
- * - This file may not be included in derivative works without permission
17
- *
18
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
19
- *
20
- * For commercial licensing inquiries: your@email.com
21
- *
22
- * eventTest.js - Event-Sequence Testing for EventState
23
- *
24
- * Provides TDD-style testing with type extraction capabilities
25
- */
26
-
27
- import { createEventState } from './eventStateNew.js';
28
-
29
- export function createEventTest(initialState = {}) {
30
- const store = createEventState(initialState);
31
- const eventLog = [];
32
- const typeAssertions = [];
33
-
34
- // Spy on all events
35
- store.subscribe('*', (detail) => {
36
- const { path, value } = detail;
37
- eventLog.push({ timestamp: Date.now(), path, value });
38
- });
39
-
40
- const api = {
41
- store,
42
-
43
- // Trigger a state change
44
- trigger(path, value) {
45
- store.set(path, value);
46
- return this;
47
- },
48
-
49
- // Assert exact value
50
- assertPath(path, expected) {
51
- const actual = store.get(path);
52
- if (JSON.stringify(actual) !== JSON.stringify(expected)) {
53
- throw new Error(`Expected ${path} to be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
54
- }
55
- return this;
56
- },
57
-
58
- // Assert type (for type generation)
59
- assertType(path, expectedType) {
60
- const actual = store.get(path);
61
- const actualType = typeof actual;
62
-
63
- if (actualType !== expectedType) {
64
- throw new Error(`Expected ${path} to be type ${expectedType}, got ${actualType}`);
65
- }
66
-
67
- // Store for type generation
68
- typeAssertions.push({ path, type: expectedType });
69
- return this;
70
- },
71
-
72
- // Assert array with element shape (for type generation)
73
- assertArrayOf(path, elementShape) {
74
- const actual = store.get(path);
75
-
76
- if (!Array.isArray(actual)) {
77
- throw new Error(`Expected ${path} to be an array, got ${typeof actual}`);
78
- }
79
-
80
- // Validate first element matches shape (if array not empty)
81
- if (actual.length > 0) {
82
- validateShape(actual[0], elementShape, path);
83
- }
84
-
85
- // Store for type generation
86
- typeAssertions.push({ path, type: 'array', elementShape });
87
- return this;
88
- },
89
-
90
- // Assert object shape (for type generation)
91
- assertShape(path, objectShape) {
92
- const actual = store.get(path);
93
-
94
- if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) {
95
- throw new Error(`Expected ${path} to be an object, got ${typeof actual}`);
96
- }
97
-
98
- validateShape(actual, objectShape, path);
99
-
100
- // Store for type generation
101
- typeAssertions.push({ path, type: 'object', shape: objectShape });
102
- return this;
103
- },
104
-
105
- // Assert array length
106
- assertArrayLength(path, expectedLength) {
107
- const actual = store.get(path);
108
-
109
- if (!Array.isArray(actual)) {
110
- throw new Error(`Expected ${path} to be an array`);
111
- }
112
-
113
- if (actual.length !== expectedLength) {
114
- throw new Error(`Expected ${path} to have length ${expectedLength}, got ${actual.length}`);
115
- }
116
-
117
- return this;
118
- },
119
-
120
- // Assert event fired N times
121
- assertEventFired(path, times) {
122
- const count = eventLog.filter(e => e.path === path).length;
123
- if (times !== undefined && count !== times) {
124
- throw new Error(`Expected ${path} to fire ${times} times, fired ${count}`);
125
- }
126
- return this;
127
- },
128
-
129
- // Get event log
130
- getEventLog() {
131
- return [...eventLog];
132
- },
133
-
134
- // Get type assertions (for type generation)
135
- getTypeAssertions() {
136
- return [...typeAssertions];
137
- }
138
- };
139
-
140
- return api;
141
- }
142
-
143
- // Helper to validate object shape
144
- function validateShape(actual, shape, path) {
145
- for (const [key, expectedType] of Object.entries(shape)) {
146
- if (!(key in actual)) {
147
- throw new Error(`Expected ${path} to have property ${key}`);
148
- }
149
-
150
- const actualValue = actual[key];
151
-
152
- // Handle nested objects
153
- if (typeof expectedType === 'object' && !Array.isArray(expectedType)) {
154
- validateShape(actualValue, expectedType, `${path}.${key}`);
155
- } else {
156
- // Primitive type check
157
- const actualType = typeof actualValue;
158
- if (actualType !== expectedType) {
159
- throw new Error(`Expected ${path}.${key} to be type ${expectedType}, got ${actualType}`);
160
- }
161
- }
162
- }
163
- }
164
-
165
- // Simple test runner
166
- export function test(name, fn) {
167
- try {
168
- fn();
169
- console.log(`✓ ${name}`);
170
- return true;
171
- } catch (error) {
172
- console.error(`✗ ${name}`);
173
- console.error(` ${error.message}`);
174
- return false;
175
- }
176
- }
177
-
178
- // Run multiple tests
179
- export function runTests(tests) {
180
- console.log('\n🧪 Running tests...\n');
181
-
182
- let passed = 0;
183
- let failed = 0;
184
-
185
- for (const [name, fn] of Object.entries(tests)) {
186
- if (test(name, fn)) {
187
- passed++;
188
- } else {
189
- failed++;
190
- }
191
- }
192
-
193
- console.log(`\n📊 Results: ${passed} passed, ${failed} failed\n`);
194
-
195
- return { passed, failed };
196
- }