mvc-kit 2.12.0 → 2.12.2
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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Controller
|
|
2
|
+
|
|
3
|
+
A minimal base class for stateless orchestrators. Controllers coordinate between ViewModels, Models, and Services when a single ViewModel can't do the job alone.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Most orchestration fits in a single ViewModel. Use a Controller only when coordinating multiple ViewModels in a single workflow — multi-step checkout, drag-and-drop between lists, complex form wizards.
|
|
10
|
+
|
|
11
|
+
| Question | Use |
|
|
12
|
+
|---|---|
|
|
13
|
+
| Does the coordination involve UI state, getters, or async tracking? | **ViewModel** |
|
|
14
|
+
| Is there pure cross-cutting coordination with no state of its own? | **Controller** |
|
|
15
|
+
|
|
16
|
+
If you're reaching for a Controller, first ask whether one ViewModel can own the whole workflow. Usually it can.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What Controller Provides
|
|
21
|
+
|
|
22
|
+
Controller is deliberately minimal. It provides lifecycle management and subscription utilities — nothing more.
|
|
23
|
+
|
|
24
|
+
| Feature | Included |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `init()` / `dispose()` lifecycle | Yes |
|
|
27
|
+
| `onInit()` / `onDispose()` hooks | Yes |
|
|
28
|
+
| `disposeSignal` (AbortSignal) | Yes |
|
|
29
|
+
| `subscribeTo(source, listener)` | Yes |
|
|
30
|
+
| `addCleanup(fn)` | Yes |
|
|
31
|
+
| `initialized` / `disposed` flags | Yes |
|
|
32
|
+
| State management | No |
|
|
33
|
+
| Computed getters | No |
|
|
34
|
+
| Async tracking | No |
|
|
35
|
+
| Events / emit | No |
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
### `init(): void | Promise<void>`
|
|
42
|
+
|
|
43
|
+
Initializes the Controller. Calls `onInit()` if defined. Idempotent — calling it multiple times has no effect. No-op if already disposed.
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
const controller = new CheckoutController();
|
|
47
|
+
controller.init();
|
|
48
|
+
controller.init(); // no-op
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Supports async initialization:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
class SetupController extends Controller {
|
|
55
|
+
protected async onInit() {
|
|
56
|
+
await someAsyncSetup();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const controller = new SetupController();
|
|
61
|
+
await controller.init();
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### `dispose(): void`
|
|
65
|
+
|
|
66
|
+
Tears down the Controller. In order:
|
|
67
|
+
1. Sets `disposed` to `true`
|
|
68
|
+
2. Aborts `disposeSignal`
|
|
69
|
+
3. Runs all registered cleanups
|
|
70
|
+
4. Calls `onDispose()`
|
|
71
|
+
|
|
72
|
+
Idempotent — safe to call multiple times. `onDispose()` only runs once.
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
controller.dispose();
|
|
76
|
+
controller.dispose(); // no-op
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `disposeSignal: AbortSignal`
|
|
80
|
+
|
|
81
|
+
A lazily-created AbortSignal that aborts when the Controller is disposed. Pass it to `fetch()` or `AbortSignal.any()` to cancel in-flight operations on teardown.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
class FetchController extends Controller {
|
|
85
|
+
async fetchData() {
|
|
86
|
+
const res = await fetch('/api/data', { signal: this.disposeSignal });
|
|
87
|
+
return res.json();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The signal is:
|
|
93
|
+
- **Lazy** — zero cost if never accessed. No AbortController is allocated unless you read `disposeSignal`.
|
|
94
|
+
- **Stable** — returns the same signal on every access.
|
|
95
|
+
- **Aborted before `onDispose()` runs** — cleanup code can check `this.disposeSignal.aborted` and it will be `true`.
|
|
96
|
+
|
|
97
|
+
### `subscribeTo<T>(source: Subscribable<T>, listener): () => void`
|
|
98
|
+
|
|
99
|
+
Subscribes to any `Subscribable` source (Collection, ViewModel, Channel) and auto-unsubscribes on dispose. Returns an unsubscribe function for manual early cleanup.
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
class SyncController extends Controller {
|
|
103
|
+
protected onInit() {
|
|
104
|
+
this.subscribeTo(this.collection, (items) => {
|
|
105
|
+
// react to collection changes
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
this.subscribeTo(this.viewModel, (newState, oldState) => {
|
|
109
|
+
// react to ViewModel state changes
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `listenTo(source, event, handler): () => void`
|
|
116
|
+
|
|
117
|
+
Subscribes to a typed event on a Channel or EventBus with automatic cleanup on dispose. The event counterpart to `subscribeTo`.
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
class ChatController extends Controller {
|
|
121
|
+
protected onInit() {
|
|
122
|
+
this.listenTo(this.channel, 'message', (msg) => {
|
|
123
|
+
// coordinate between ViewModels
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `addCleanup(fn: () => void): void`
|
|
130
|
+
|
|
131
|
+
Registers a cleanup function that runs on dispose. Use this for teardown that isn't covered by `subscribeTo` or `listenTo`.
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
class TimerController extends Controller {
|
|
135
|
+
protected onInit() {
|
|
136
|
+
const id = setInterval(() => this.tick(), 1000);
|
|
137
|
+
this.addCleanup(() => clearInterval(id));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `onInit?(): void | Promise<void>` (protected)
|
|
143
|
+
|
|
144
|
+
Override to run setup logic when `init()` is called. This is where you wire up subscriptions, start timers, or kick off initial coordination.
|
|
145
|
+
|
|
146
|
+
### `onDispose?(): void` (protected)
|
|
147
|
+
|
|
148
|
+
Override to run teardown logic when `dispose()` is called. Runs after the signal is aborted and cleanups have fired.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Lifecycle
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
new Controller() → disposed: false, initialized: false
|
|
156
|
+
│
|
|
157
|
+
init() → initialized: true, onInit() called
|
|
158
|
+
│
|
|
159
|
+
dispose() → disposed: true
|
|
160
|
+
1. abort signal
|
|
161
|
+
2. run cleanups
|
|
162
|
+
3. onDispose() called
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- `init()` after `dispose()` is a no-op — the Controller stays uninitialized.
|
|
166
|
+
- `dispose()` before `init()` works — the Controller is disposed without ever initializing.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Abstract Class
|
|
171
|
+
|
|
172
|
+
Controller is `abstract`. You must subclass it:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// Minimal subclass
|
|
176
|
+
class MyController extends Controller {
|
|
177
|
+
doSomething(): string {
|
|
178
|
+
return 'done';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Singleton Usage
|
|
186
|
+
|
|
187
|
+
Controllers can be registered as singletons when they coordinate app-wide concerns:
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
import { singleton, teardown, teardownAll } from 'mvc-kit';
|
|
191
|
+
|
|
192
|
+
const controller = singleton(MyController);
|
|
193
|
+
|
|
194
|
+
// Same instance on repeated calls
|
|
195
|
+
singleton(MyController) === controller; // true
|
|
196
|
+
|
|
197
|
+
// Teardown disposes and removes from registry
|
|
198
|
+
teardown(MyController);
|
|
199
|
+
controller.disposed; // true
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Example: Coordinating Two ViewModels
|
|
205
|
+
|
|
206
|
+
A drag-and-drop controller that moves items between two list ViewModels:
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
class DragDropController extends Controller {
|
|
210
|
+
private sourceVM: TaskListViewModel;
|
|
211
|
+
private targetVM: TaskListViewModel;
|
|
212
|
+
|
|
213
|
+
constructor(source: TaskListViewModel, target: TaskListViewModel) {
|
|
214
|
+
super();
|
|
215
|
+
this.sourceVM = source;
|
|
216
|
+
this.targetVM = target;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
moveItem(itemId: string) {
|
|
220
|
+
const item = this.sourceVM.state.items.find(i => i.id === itemId);
|
|
221
|
+
if (!item) return;
|
|
222
|
+
this.sourceVM.removeItem(itemId);
|
|
223
|
+
this.targetVM.addItem(item);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Example: Subscribing to Multiple Sources
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
class SyncController extends Controller {
|
|
232
|
+
private usersCollection = singleton(UsersCollection);
|
|
233
|
+
private locationsCollection = singleton(LocationsCollection);
|
|
234
|
+
|
|
235
|
+
protected onInit() {
|
|
236
|
+
this.subscribeTo(this.usersCollection, () => {
|
|
237
|
+
this.reconcile();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
this.subscribeTo(this.locationsCollection, () => {
|
|
241
|
+
this.reconcile();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private reconcile() {
|
|
246
|
+
// Cross-cutting logic when either collection changes
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Example: Async Initialization with Cancellation
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
class BootstrapController extends Controller {
|
|
255
|
+
private authService = singleton(AuthService);
|
|
256
|
+
private configService = singleton(ConfigService);
|
|
257
|
+
|
|
258
|
+
protected async onInit() {
|
|
259
|
+
const [user, config] = await Promise.all([
|
|
260
|
+
this.authService.getCurrentUser(this.disposeSignal),
|
|
261
|
+
this.configService.load(this.disposeSignal),
|
|
262
|
+
]);
|
|
263
|
+
// coordinate initial app state
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Best Practices
|
|
271
|
+
|
|
272
|
+
1. **Reach for ViewModel first.** Controller is a last resort for coordination that doesn't fit a single ViewModel.
|
|
273
|
+
2. **Keep it stateless.** Controllers don't have `set()` or state. If you need reactive state, use a ViewModel.
|
|
274
|
+
3. **Use `subscribeTo` for subscriptions.** It auto-cleans up on dispose — no manual bookkeeping.
|
|
275
|
+
4. **Pass `disposeSignal` to async calls.** Ensures in-flight operations cancel on teardown.
|
|
276
|
+
5. **Keep Controllers small.** If a Controller grows large, the workflow it manages should probably be restructured into better-scoped ViewModels.
|
|
277
|
+
|
|
278
|
+
## Anti-Patterns
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// Don't store reactive state on a Controller
|
|
282
|
+
class BadController extends Controller {
|
|
283
|
+
items: Item[] = []; // not reactive — use a ViewModel or Collection
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Don't use Controller when a ViewModel works
|
|
287
|
+
class OverEngineered extends Controller {
|
|
288
|
+
// If this needs state, getters, or async tracking — it's a ViewModel
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Don't forget to clean up non-subscription resources
|
|
292
|
+
class LeakyController extends Controller {
|
|
293
|
+
protected onInit() {
|
|
294
|
+
setInterval(() => this.tick(), 1000); // leaked — use addCleanup
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Method Binding
|
|
300
|
+
|
|
301
|
+
All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
const { doAction } = controller;
|
|
305
|
+
<button onClick={doAction}>Run</button> // point-free works
|
|
306
|
+
```
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Controller } from './Controller';
|
|
3
|
+
import { ViewModel } from './ViewModel';
|
|
4
|
+
import { Collection } from './Collection';
|
|
5
|
+
import { EventBus } from './EventBus';
|
|
6
|
+
import { singleton, teardown, teardownAll } from './singleton';
|
|
7
|
+
|
|
8
|
+
class TestController extends Controller {
|
|
9
|
+
onDisposeCalled = false;
|
|
10
|
+
actionCalled = false;
|
|
11
|
+
|
|
12
|
+
protected onDispose(): void {
|
|
13
|
+
this.onDisposeCalled = true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
doAction(): void {
|
|
17
|
+
this.actionCalled = true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class SimpleController extends Controller {
|
|
22
|
+
doSomething(): string {
|
|
23
|
+
return 'done';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('Controller', () => {
|
|
28
|
+
describe('initialization', () => {
|
|
29
|
+
it('starts not disposed', () => {
|
|
30
|
+
const controller = new TestController();
|
|
31
|
+
expect(controller.disposed).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('dispose', () => {
|
|
36
|
+
it('sets disposed to true', () => {
|
|
37
|
+
const controller = new TestController();
|
|
38
|
+
controller.dispose();
|
|
39
|
+
expect(controller.disposed).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('calls onDispose hook', () => {
|
|
43
|
+
const controller = new TestController();
|
|
44
|
+
expect(controller.onDisposeCalled).toBe(false);
|
|
45
|
+
controller.dispose();
|
|
46
|
+
expect(controller.onDisposeCalled).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('is idempotent', () => {
|
|
50
|
+
let callCount = 0;
|
|
51
|
+
class CountingController extends Controller {
|
|
52
|
+
protected onDispose(): void {
|
|
53
|
+
callCount++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const controller = new CountingController();
|
|
57
|
+
controller.dispose();
|
|
58
|
+
controller.dispose();
|
|
59
|
+
controller.dispose();
|
|
60
|
+
expect(callCount).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('init', () => {
|
|
65
|
+
it('starts not initialized', () => {
|
|
66
|
+
const controller = new TestController();
|
|
67
|
+
expect(controller.initialized).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('sets initialized to true after init()', () => {
|
|
71
|
+
const controller = new TestController();
|
|
72
|
+
controller.init();
|
|
73
|
+
expect(controller.initialized).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('calls onInit hook', () => {
|
|
77
|
+
let called = false;
|
|
78
|
+
class InitController extends Controller {
|
|
79
|
+
protected onInit() {
|
|
80
|
+
called = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const controller = new InitController();
|
|
84
|
+
controller.init();
|
|
85
|
+
expect(called).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('is idempotent — onInit called only once', () => {
|
|
89
|
+
let callCount = 0;
|
|
90
|
+
class CountingController extends Controller {
|
|
91
|
+
protected onInit() {
|
|
92
|
+
callCount++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const controller = new CountingController();
|
|
96
|
+
controller.init();
|
|
97
|
+
controller.init();
|
|
98
|
+
controller.init();
|
|
99
|
+
expect(callCount).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('supports async onInit', async () => {
|
|
103
|
+
let resolved = false;
|
|
104
|
+
class AsyncController extends Controller {
|
|
105
|
+
protected async onInit() {
|
|
106
|
+
await Promise.resolve();
|
|
107
|
+
resolved = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const controller = new AsyncController();
|
|
111
|
+
await controller.init();
|
|
112
|
+
expect(resolved).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('is a no-op after dispose', () => {
|
|
116
|
+
let called = false;
|
|
117
|
+
class InitController extends Controller {
|
|
118
|
+
protected onInit() {
|
|
119
|
+
called = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const controller = new InitController();
|
|
123
|
+
controller.dispose();
|
|
124
|
+
controller.init();
|
|
125
|
+
expect(called).toBe(false);
|
|
126
|
+
expect(controller.initialized).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('methods', () => {
|
|
131
|
+
it('can execute actions', () => {
|
|
132
|
+
const controller = new TestController();
|
|
133
|
+
controller.doAction();
|
|
134
|
+
expect(controller.actionCalled).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('singleton integration', () => {
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
teardownAll();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('can be used with singleton registry', () => {
|
|
144
|
+
const c1 = singleton(SimpleController);
|
|
145
|
+
const c2 = singleton(SimpleController);
|
|
146
|
+
expect(c1).toBe(c2);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('can be torn down', () => {
|
|
150
|
+
const controller = singleton(SimpleController);
|
|
151
|
+
teardown(SimpleController);
|
|
152
|
+
expect(controller.disposed).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('signal and addCleanup', () => {
|
|
157
|
+
it('signal returns an AbortSignal', () => {
|
|
158
|
+
const controller = new TestController();
|
|
159
|
+
expect(controller.disposeSignal).toBeInstanceOf(AbortSignal);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('returns the same signal on multiple accesses', () => {
|
|
163
|
+
const controller = new TestController();
|
|
164
|
+
const s1 = controller.disposeSignal;
|
|
165
|
+
const s2 = controller.disposeSignal;
|
|
166
|
+
expect(s1).toBe(s2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('signal is not aborted before dispose', () => {
|
|
170
|
+
const controller = new TestController();
|
|
171
|
+
expect(controller.disposeSignal.aborted).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('signal is aborted after dispose', () => {
|
|
175
|
+
const controller = new TestController();
|
|
176
|
+
const signal = controller.disposeSignal;
|
|
177
|
+
controller.dispose();
|
|
178
|
+
expect(signal.aborted).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('signal is aborted before onDispose runs', () => {
|
|
182
|
+
let wasAbortedDuringDispose = false;
|
|
183
|
+
class CheckController extends Controller {
|
|
184
|
+
protected onDispose(): void {
|
|
185
|
+
wasAbortedDuringDispose = this.disposeSignal.aborted;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const controller = new CheckController();
|
|
189
|
+
controller.disposeSignal; // force lazy creation
|
|
190
|
+
controller.dispose();
|
|
191
|
+
expect(wasAbortedDuringDispose).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('addCleanup fires on dispose', () => {
|
|
195
|
+
let cleaned = false;
|
|
196
|
+
class CleanupController extends Controller {
|
|
197
|
+
setup() {
|
|
198
|
+
this.addCleanup(() => { cleaned = true; });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const controller = new CleanupController();
|
|
202
|
+
controller.setup();
|
|
203
|
+
expect(cleaned).toBe(false);
|
|
204
|
+
controller.dispose();
|
|
205
|
+
expect(cleaned).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('dispose works without accessing signal (lazy, zero cost)', () => {
|
|
209
|
+
const controller = new TestController();
|
|
210
|
+
controller.dispose();
|
|
211
|
+
expect(controller.disposed).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('subscribeTo', () => {
|
|
216
|
+
interface Item { id: string; value: number }
|
|
217
|
+
|
|
218
|
+
it('listener is called when source changes', () => {
|
|
219
|
+
const collection = new Collection<Item>();
|
|
220
|
+
|
|
221
|
+
class SubController extends Controller {
|
|
222
|
+
values: number[] = [];
|
|
223
|
+
setup(col: Collection<Item>) {
|
|
224
|
+
this.subscribeTo(col, (items) => {
|
|
225
|
+
this.values = items.map(i => i.value);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const ctrl = new SubController();
|
|
231
|
+
ctrl.setup(collection);
|
|
232
|
+
|
|
233
|
+
collection.add({ id: '1', value: 42 });
|
|
234
|
+
expect(ctrl.values).toEqual([42]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('auto-unsubscribes on dispose', () => {
|
|
238
|
+
const collection = new Collection<Item>();
|
|
239
|
+
const listener = vi.fn();
|
|
240
|
+
|
|
241
|
+
class SubController extends Controller {
|
|
242
|
+
setup(col: Collection<Item>) {
|
|
243
|
+
this.subscribeTo(col, listener);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const ctrl = new SubController();
|
|
248
|
+
ctrl.setup(collection);
|
|
249
|
+
|
|
250
|
+
collection.add({ id: '1', value: 1 });
|
|
251
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
252
|
+
|
|
253
|
+
ctrl.dispose();
|
|
254
|
+
|
|
255
|
+
collection.add({ id: '2', value: 2 });
|
|
256
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('returns unsubscribe function for manual cleanup', () => {
|
|
260
|
+
const collection = new Collection<Item>();
|
|
261
|
+
const listener = vi.fn();
|
|
262
|
+
|
|
263
|
+
class SubController extends Controller {
|
|
264
|
+
setup(col: Collection<Item>): () => void {
|
|
265
|
+
return this.subscribeTo(col, listener);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const ctrl = new SubController();
|
|
270
|
+
const unsub = ctrl.setup(collection);
|
|
271
|
+
|
|
272
|
+
collection.add({ id: '1', value: 1 });
|
|
273
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
274
|
+
|
|
275
|
+
unsub();
|
|
276
|
+
|
|
277
|
+
collection.add({ id: '2', value: 2 });
|
|
278
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('works with a ViewModel as source', () => {
|
|
282
|
+
interface CountState { count: number }
|
|
283
|
+
class CountVM extends ViewModel<CountState> {
|
|
284
|
+
increment() { this.set({ count: this.state.count + 1 }); }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const vm = new CountVM({ count: 0 });
|
|
288
|
+
const listener = vi.fn();
|
|
289
|
+
|
|
290
|
+
class SubController extends Controller {
|
|
291
|
+
setup(src: CountVM) {
|
|
292
|
+
this.subscribeTo(src, listener);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const ctrl = new SubController();
|
|
297
|
+
ctrl.setup(vm);
|
|
298
|
+
|
|
299
|
+
vm.increment();
|
|
300
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
301
|
+
expect(listener).toHaveBeenCalledWith({ count: 1 }, { count: 0 });
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe('listenTo', () => {
|
|
306
|
+
interface TestEvents {
|
|
307
|
+
alert: { message: string };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
it('handler is called when event is emitted', () => {
|
|
311
|
+
const bus = new EventBus<TestEvents>();
|
|
312
|
+
const alerts: TestEvents['alert'][] = [];
|
|
313
|
+
|
|
314
|
+
class ListenCtrl extends Controller {
|
|
315
|
+
setup(b: EventBus<TestEvents>) {
|
|
316
|
+
this.listenTo(b, 'alert', (a) => alerts.push(a));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const ctrl = new ListenCtrl();
|
|
321
|
+
ctrl.setup(bus);
|
|
322
|
+
|
|
323
|
+
bus.emit('alert', { message: 'fire' });
|
|
324
|
+
expect(alerts).toEqual([{ message: 'fire' }]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('auto-unsubscribes on dispose', () => {
|
|
328
|
+
const bus = new EventBus<TestEvents>();
|
|
329
|
+
const handler = vi.fn();
|
|
330
|
+
|
|
331
|
+
class ListenCtrl extends Controller {
|
|
332
|
+
setup(b: EventBus<TestEvents>) {
|
|
333
|
+
this.listenTo(b, 'alert', handler);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const ctrl = new ListenCtrl();
|
|
338
|
+
ctrl.setup(bus);
|
|
339
|
+
|
|
340
|
+
bus.emit('alert', { message: 'one' });
|
|
341
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
342
|
+
|
|
343
|
+
ctrl.dispose();
|
|
344
|
+
|
|
345
|
+
bus.emit('alert', { message: 'two' });
|
|
346
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('returns unsubscribe function for manual cleanup', () => {
|
|
350
|
+
const bus = new EventBus<TestEvents>();
|
|
351
|
+
const handler = vi.fn();
|
|
352
|
+
|
|
353
|
+
class ListenCtrl extends Controller {
|
|
354
|
+
setup(b: EventBus<TestEvents>): () => void {
|
|
355
|
+
return this.listenTo(b, 'alert', handler);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const ctrl = new ListenCtrl();
|
|
360
|
+
const unsub = ctrl.setup(bus);
|
|
361
|
+
|
|
362
|
+
bus.emit('alert', { message: 'one' });
|
|
363
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
364
|
+
|
|
365
|
+
unsub();
|
|
366
|
+
|
|
367
|
+
bus.emit('alert', { message: 'two' });
|
|
368
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('method binding', () => {
|
|
373
|
+
it('subclass methods work point-free', () => {
|
|
374
|
+
const ctrl = new TestController();
|
|
375
|
+
const { doAction } = ctrl;
|
|
376
|
+
doAction();
|
|
377
|
+
expect(ctrl.actionCalled).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
});
|