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.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. 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
+ });