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
package/src/Model.ts ADDED
@@ -0,0 +1,260 @@
1
+ import type { Listener, Updater, Subscribable, ValidationErrors, EventPayload } from './types';
2
+ import { bindPublicMethods } from './bindPublicMethods';
3
+ import { resolveDraftUpdater } from './produceDraft';
4
+
5
+ const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
6
+ const PROTECTED_KEYS = new Set(['set', 'validate', 'addCleanup', 'subscribeTo', 'listenTo']);
7
+
8
+ function freeze<T>(obj: T): T {
9
+ return __DEV__ ? Object.freeze(obj) as T : obj;
10
+ }
11
+
12
+ /**
13
+ * Reactive entity with validation and dirty tracking.
14
+ */
15
+ export abstract class Model<S extends object> implements Subscribable<S> {
16
+ private _state: Readonly<S>;
17
+ private _committed: Readonly<S>;
18
+ private _disposed = false;
19
+ private _initialized = false;
20
+ private _listeners = new Set<Listener<S>>();
21
+ private _abortController: AbortController | null = null;
22
+ private _cleanups: (() => void)[] | null = null;
23
+ private _cachedDirty: boolean | null = null;
24
+ private _cachedErrors: ValidationErrors<S> | null = null;
25
+
26
+ constructor(initialState: S) {
27
+ const frozen = freeze({ ...initialState });
28
+ this._state = frozen;
29
+ this._committed = frozen;
30
+ bindPublicMethods(this, Object.prototype, PROTECTED_KEYS);
31
+ }
32
+
33
+ /** Current frozen state object. */
34
+ get state(): S {
35
+ return this._state;
36
+ }
37
+
38
+ /**
39
+ * The baseline state for dirty tracking.
40
+ */
41
+ get committed(): S {
42
+ return this._committed;
43
+ }
44
+
45
+ /**
46
+ * True if current state differs from committed state.
47
+ */
48
+ get dirty(): boolean {
49
+ if (this._cachedDirty === null) {
50
+ this._cachedDirty = !this._shallowEqual(this._state, this._committed);
51
+ }
52
+ return this._cachedDirty;
53
+ }
54
+
55
+ /**
56
+ * Validation errors for the current state.
57
+ */
58
+ get errors(): ValidationErrors<S> {
59
+ if (this._cachedErrors === null) {
60
+ this._cachedErrors = this.validate(this._state);
61
+ }
62
+ return this._cachedErrors;
63
+ }
64
+
65
+ /**
66
+ * True if there are no validation errors.
67
+ */
68
+ get valid(): boolean {
69
+ return Object.keys(this.errors).length === 0;
70
+ }
71
+
72
+ /** Whether this instance has been disposed. */
73
+ get disposed(): boolean {
74
+ return this._disposed;
75
+ }
76
+
77
+ /** Whether init() has been called. */
78
+ get initialized(): boolean {
79
+ return this._initialized;
80
+ }
81
+
82
+ /** AbortSignal that fires when this instance is disposed. Lazily created. */
83
+ get disposeSignal(): AbortSignal {
84
+ if (!this._abortController) {
85
+ this._abortController = new AbortController();
86
+ }
87
+ return this._abortController.signal;
88
+ }
89
+
90
+ /** Initializes the instance. Called automatically by React hooks after mount. */
91
+ init(): void | Promise<void> {
92
+ if (this._initialized || this._disposed) return;
93
+ this._initialized = true;
94
+ return this.onInit?.();
95
+ }
96
+
97
+ /**
98
+ * Merges partial state with validation. No-op if no values changed by reference.
99
+ * @protected
100
+ */
101
+ protected set(partialOrUpdater: Partial<S> | Updater<S> | ((draft: S) => void)): void {
102
+ if (this._disposed) {
103
+ throw new Error('Cannot set state on disposed Model');
104
+ }
105
+
106
+ let partial: Partial<S>;
107
+ if (typeof partialOrUpdater === 'function') {
108
+ const result = resolveDraftUpdater<S>(this._state, partialOrUpdater as (s: S) => Partial<S> | void);
109
+ if (!result) return;
110
+ partial = result;
111
+ } else {
112
+ partial = partialOrUpdater;
113
+ }
114
+
115
+ // Check if any values actually changed (shallow equality)
116
+ const keys = Object.keys(partial) as (keyof S)[];
117
+ const hasChanges = keys.some(
118
+ (key) => partial[key] !== this._state[key]
119
+ );
120
+
121
+ if (!hasChanges) {
122
+ return;
123
+ }
124
+
125
+ const prev = this._state;
126
+ const next = freeze({ ...prev, ...partial });
127
+ this._state = next;
128
+ this._cachedDirty = null;
129
+ this._cachedErrors = null;
130
+
131
+ this.onSet?.(prev, next);
132
+
133
+ for (const listener of this._listeners) {
134
+ listener(next, prev);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Mark current state as the new baseline (not dirty).
140
+ */
141
+ commit(): void {
142
+ if (this._disposed) {
143
+ throw new Error('Cannot commit on disposed Model');
144
+ }
145
+ this._committed = this._state;
146
+ this._cachedDirty = null;
147
+ this._cachedErrors = null;
148
+ }
149
+
150
+ /**
151
+ * Revert state to committed baseline.
152
+ */
153
+ rollback(): void {
154
+ if (this._disposed) {
155
+ throw new Error('Cannot rollback on disposed Model');
156
+ }
157
+
158
+ if (this._shallowEqual(this._state, this._committed)) {
159
+ return;
160
+ }
161
+
162
+ const prev = this._state;
163
+ this._state = this._committed;
164
+ this._cachedDirty = null;
165
+ this._cachedErrors = null;
166
+
167
+ this.onSet?.(prev, this._state);
168
+
169
+ for (const listener of this._listeners) {
170
+ listener(this._state, prev);
171
+ }
172
+ }
173
+
174
+ /** Subscribes to state changes. Returns an unsubscribe function. */
175
+ subscribe(listener: Listener<S>): () => void {
176
+ if (this._disposed) {
177
+ return () => {};
178
+ }
179
+
180
+ this._listeners.add(listener);
181
+
182
+ return () => {
183
+ this._listeners.delete(listener);
184
+ };
185
+ }
186
+
187
+ /** Tears down the instance, releasing all subscriptions and resources. */
188
+ dispose(): void {
189
+ if (this._disposed) {
190
+ return;
191
+ }
192
+
193
+ this._disposed = true;
194
+ this._abortController?.abort();
195
+ if (this._cleanups) {
196
+ for (const fn of this._cleanups) fn();
197
+ this._cleanups = null;
198
+ }
199
+ this.onDispose?.();
200
+ this._listeners.clear();
201
+ }
202
+
203
+ /**
204
+ * Override to provide validation logic.
205
+ * Return an object mapping field keys to error messages.
206
+ */
207
+ protected validate(_state: S): ValidationErrors<S> {
208
+ return {};
209
+ }
210
+
211
+ /** Registers a cleanup function to be called on dispose. @protected */
212
+ protected addCleanup(fn: () => void): void {
213
+ if (!this._cleanups) {
214
+ this._cleanups = [];
215
+ }
216
+ this._cleanups.push(fn);
217
+ }
218
+
219
+ /** Subscribes to an external Subscribable with automatic cleanup on dispose. @protected */
220
+ protected subscribeTo<T>(source: Subscribable<T>, listener: Listener<T>): () => void {
221
+ const unsubscribe = source.subscribe(listener);
222
+ this.addCleanup(unsubscribe);
223
+ return unsubscribe;
224
+ }
225
+
226
+ /** Subscribes to a typed event on a Channel or EventBus with automatic cleanup on dispose. @protected */
227
+ protected listenTo<K extends string, S extends { on(event: K, handler: (payload: any) => void): () => void }>(
228
+ source: S,
229
+ event: K,
230
+ handler: (payload: EventPayload<S, K>) => void,
231
+ ): () => void {
232
+ const unsubscribe = source.on(event, handler);
233
+ this.addCleanup(unsubscribe);
234
+ return unsubscribe;
235
+ }
236
+
237
+ /** Lifecycle hook called after every set() with the previous state. @protected */
238
+ protected onSet?(prev: S, next: S): void;
239
+ /** Lifecycle hook called at the end of init(). Override to load initial data. @protected */
240
+ protected onInit?(): void | Promise<void>;
241
+ /** Lifecycle hook called during dispose(). Override for custom teardown. @protected */
242
+ protected onDispose?(): void;
243
+
244
+ private _shallowEqual(a: S, b: S): boolean {
245
+ const keysA = Object.keys(a) as (keyof S)[];
246
+ const keysB = Object.keys(b) as (keyof S)[];
247
+
248
+ if (keysA.length !== keysB.length) {
249
+ return false;
250
+ }
251
+
252
+ for (const key of keysA) {
253
+ if (a[key] !== b[key]) {
254
+ return false;
255
+ }
256
+ }
257
+
258
+ return true;
259
+ }
260
+ }
@@ -0,0 +1,168 @@
1
+ # Pagination
2
+
3
+ Page-based pagination helper. Manages page number and page size, slices arrays for the current page. Extends [`Trackable`](./Trackable.md) — subscribable, disposable, and auto-bound.
4
+
5
+ ---
6
+
7
+ ## When to Use
8
+
9
+ Use Pagination as a ViewModel property for client-side paginated tables and lists. Auto-tracking ensures ViewModel getters that read from Pagination auto-invalidate when page/pageSize changes.
10
+
11
+ For server-side cursor-based pagination (infinite scroll), use `Feed` instead.
12
+
13
+ ---
14
+
15
+ ## Creating a Pagination Instance
16
+
17
+ ```typescript
18
+ import { Pagination } from 'mvc-kit';
19
+
20
+ // Default: pageSize 10
21
+ readonly pagination = new Pagination();
22
+
23
+ // Custom pageSize
24
+ readonly pagination = new Pagination({ pageSize: 25 });
25
+ ```
26
+
27
+ ---
28
+
29
+ ## API
30
+
31
+ ### Readable State
32
+
33
+ #### `page: number`
34
+
35
+ Current page number (1-indexed).
36
+
37
+ #### `pageSize: number`
38
+
39
+ Number of items per page.
40
+
41
+ ### Derived Methods (Require Total)
42
+
43
+ #### `pageCount(total: number): number`
44
+
45
+ Total number of pages. Minimum 1.
46
+
47
+ #### `hasNext(total: number): boolean`
48
+
49
+ Whether there's a next page given the total item count.
50
+
51
+ #### `hasPrev(): boolean`
52
+
53
+ Whether there's a previous page (`page > 1`).
54
+
55
+ ### Actions
56
+
57
+ #### `setPage(page: number): void`
58
+
59
+ Set the current page. Clamped to minimum 1. No-op if already on that page.
60
+
61
+ #### `setPageSize(size: number): void`
62
+
63
+ Set page size and reset to page 1. Ignored if size < 1.
64
+
65
+ #### `nextPage(): void`
66
+
67
+ Advance to next page.
68
+
69
+ #### `prevPage(): void`
70
+
71
+ Go to previous page. No-op if already on page 1.
72
+
73
+ #### `reset(): void`
74
+
75
+ Reset to page 1. No-op if already on page 1.
76
+
77
+ ### Pipeline
78
+
79
+ #### `apply<T>(items: T[]): T[]`
80
+
81
+ Slice the array for the current page. Returns an empty array if the page is beyond the data.
82
+
83
+ ### Subscribable Interface
84
+
85
+ #### `subscribe(cb: () => void): () => void`
86
+
87
+ Subscribe to state changes. Returns an unsubscribe function.
88
+
89
+ ---
90
+
91
+ ## ViewModel Integration
92
+
93
+ ```typescript
94
+ class UsersVM extends ViewModel<FilterState> {
95
+ readonly pagination = new Pagination({ pageSize: 25 });
96
+ readonly sorting = new Sorting<User>();
97
+ private users = singleton(UsersResource);
98
+
99
+ get filtered(): User[] {
100
+ // ... filter logic using this.state ...
101
+ return this.users.items;
102
+ }
103
+
104
+ get items(): User[] {
105
+ return this.pagination.apply(this.sorting.apply(this.filtered));
106
+ }
107
+
108
+ get total() { return this.filtered.length; }
109
+
110
+ setSearch(search: string) {
111
+ this.set({ search });
112
+ this.pagination.reset(); // Back to page 1 on filter change
113
+ }
114
+ }
115
+ ```
116
+
117
+ ---
118
+
119
+ ## React Usage
120
+
121
+ Pass the Pagination helper directly to DataTable with `paginationTotal`:
122
+
123
+ ```tsx
124
+ <DataTable
125
+ items={vm.items}
126
+ columns={columns}
127
+ pagination={vm.pagination}
128
+ paginationTotal={vm.total}
129
+ renderPagination={info => (
130
+ <div>
131
+ <button disabled={!info.hasPrev} onClick={info.goPrev}>Prev</button>
132
+ <span>Page {info.page} of {info.pageCount}</span>
133
+ <button disabled={!info.hasNext} onClick={info.goNext}>Next</button>
134
+ </div>
135
+ )}
136
+ />
137
+ ```
138
+
139
+ Or use the object-literal form for custom usage:
140
+
141
+ ```tsx
142
+ <DataTable
143
+ items={vm.items}
144
+ columns={columns}
145
+ pageSize={vm.pagination.pageSize}
146
+ pagination={{
147
+ page: vm.pagination.page,
148
+ total: vm.total,
149
+ onPageChange: p => vm.pagination.setPage(p),
150
+ }}
151
+ renderPagination={info => (
152
+ <div>
153
+ <button disabled={!info.hasPrev} onClick={info.goPrev}>Prev</button>
154
+ <span>Page {info.page} of {info.pageCount}</span>
155
+ <button disabled={!info.hasNext} onClick={info.goNext}>Next</button>
156
+ </div>
157
+ )}
158
+ />
159
+ ```
160
+
161
+ ## Method Binding
162
+
163
+ All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
164
+
165
+ ```tsx
166
+ const { nextPage, prevPage } = pagination;
167
+ <button onClick={nextPage}>Next</button> // point-free works
168
+ ```
@@ -0,0 +1,244 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Pagination } from './Pagination';
3
+ import { ViewModel } from './ViewModel';
4
+
5
+ describe('Pagination', () => {
6
+ describe('construction', () => {
7
+ it('defaults to page 1, pageSize 10', () => {
8
+ const p = new Pagination();
9
+ expect(p.page).toBe(1);
10
+ expect(p.pageSize).toBe(10);
11
+ });
12
+
13
+ it('accepts custom pageSize', () => {
14
+ const p = new Pagination({ pageSize: 25 });
15
+ expect(p.pageSize).toBe(25);
16
+ });
17
+ });
18
+
19
+ describe('page navigation', () => {
20
+ it('setPage changes page', () => {
21
+ const p = new Pagination();
22
+ p.setPage(3);
23
+ expect(p.page).toBe(3);
24
+ });
25
+
26
+ it('setPage clamps to minimum 1', () => {
27
+ const p = new Pagination();
28
+ p.setPage(0);
29
+ expect(p.page).toBe(1);
30
+ p.setPage(-5);
31
+ expect(p.page).toBe(1);
32
+ });
33
+
34
+ it('setPage floors fractional values', () => {
35
+ const p = new Pagination();
36
+ p.setPage(2.7);
37
+ expect(p.page).toBe(2);
38
+ });
39
+
40
+ it('setPage does not notify if page unchanged', () => {
41
+ const p = new Pagination();
42
+ const listener = vi.fn();
43
+ p.subscribe(listener);
44
+ p.setPage(1); // already on page 1
45
+ expect(listener).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it('nextPage increments', () => {
49
+ const p = new Pagination();
50
+ p.nextPage();
51
+ expect(p.page).toBe(2);
52
+ p.nextPage();
53
+ expect(p.page).toBe(3);
54
+ });
55
+
56
+ it('prevPage decrements', () => {
57
+ const p = new Pagination();
58
+ p.setPage(3);
59
+ p.prevPage();
60
+ expect(p.page).toBe(2);
61
+ });
62
+
63
+ it('prevPage does not go below 1', () => {
64
+ const p = new Pagination();
65
+ const listener = vi.fn();
66
+ p.subscribe(listener);
67
+ p.prevPage();
68
+ expect(p.page).toBe(1);
69
+ expect(listener).not.toHaveBeenCalled();
70
+ });
71
+ });
72
+
73
+ describe('derived methods', () => {
74
+ it('pageCount computes correctly', () => {
75
+ const p = new Pagination({ pageSize: 10 });
76
+ expect(p.pageCount(0)).toBe(1);
77
+ expect(p.pageCount(10)).toBe(1);
78
+ expect(p.pageCount(11)).toBe(2);
79
+ expect(p.pageCount(100)).toBe(10);
80
+ });
81
+
82
+ it('hasNext checks against total', () => {
83
+ const p = new Pagination({ pageSize: 10 });
84
+ expect(p.hasNext(25)).toBe(true);
85
+ p.setPage(3);
86
+ expect(p.hasNext(25)).toBe(false);
87
+ });
88
+
89
+ it('hasPrev checks page > 1', () => {
90
+ const p = new Pagination();
91
+ expect(p.hasPrev()).toBe(false);
92
+ p.setPage(2);
93
+ expect(p.hasPrev()).toBe(true);
94
+ });
95
+ });
96
+
97
+ describe('setPageSize', () => {
98
+ it('changes pageSize and resets to page 1', () => {
99
+ const p = new Pagination({ pageSize: 10 });
100
+ p.setPage(5);
101
+ p.setPageSize(25);
102
+ expect(p.pageSize).toBe(25);
103
+ expect(p.page).toBe(1);
104
+ });
105
+
106
+ it('ignores size < 1', () => {
107
+ const p = new Pagination({ pageSize: 10 });
108
+ const listener = vi.fn();
109
+ p.subscribe(listener);
110
+ p.setPageSize(0);
111
+ expect(p.pageSize).toBe(10);
112
+ expect(listener).not.toHaveBeenCalled();
113
+ });
114
+ });
115
+
116
+ describe('reset', () => {
117
+ it('resets to page 1', () => {
118
+ const p = new Pagination();
119
+ p.setPage(5);
120
+ p.reset();
121
+ expect(p.page).toBe(1);
122
+ });
123
+
124
+ it('does not notify if already on page 1', () => {
125
+ const p = new Pagination();
126
+ const listener = vi.fn();
127
+ p.subscribe(listener);
128
+ p.reset();
129
+ expect(listener).not.toHaveBeenCalled();
130
+ });
131
+ });
132
+
133
+ describe('apply', () => {
134
+ const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
135
+
136
+ it('slices for page 1', () => {
137
+ const p = new Pagination({ pageSize: 5 });
138
+ expect(p.apply(items)).toEqual([1, 2, 3, 4, 5]);
139
+ });
140
+
141
+ it('slices for page 2', () => {
142
+ const p = new Pagination({ pageSize: 5 });
143
+ p.setPage(2);
144
+ expect(p.apply(items)).toEqual([6, 7, 8, 9, 10]);
145
+ });
146
+
147
+ it('handles last partial page', () => {
148
+ const p = new Pagination({ pageSize: 5 });
149
+ p.setPage(3);
150
+ expect(p.apply(items)).toEqual([11, 12]);
151
+ });
152
+
153
+ it('returns empty array for page beyond total', () => {
154
+ const p = new Pagination({ pageSize: 5 });
155
+ p.setPage(10);
156
+ expect(p.apply(items)).toEqual([]);
157
+ });
158
+ });
159
+
160
+ describe('subscribe notifications', () => {
161
+ it('notifies on setPage', () => {
162
+ const p = new Pagination();
163
+ const listener = vi.fn();
164
+ p.subscribe(listener);
165
+ p.setPage(2);
166
+ expect(listener).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ it('notifies on setPageSize', () => {
170
+ const p = new Pagination();
171
+ const listener = vi.fn();
172
+ p.subscribe(listener);
173
+ p.setPageSize(25);
174
+ expect(listener).toHaveBeenCalledTimes(1);
175
+ });
176
+
177
+ it('notifies on nextPage', () => {
178
+ const p = new Pagination();
179
+ const listener = vi.fn();
180
+ p.subscribe(listener);
181
+ p.nextPage();
182
+ expect(listener).toHaveBeenCalledTimes(1);
183
+ });
184
+
185
+ it('unsubscribe stops notifications', () => {
186
+ const p = new Pagination();
187
+ const listener = vi.fn();
188
+ const unsub = p.subscribe(listener);
189
+ unsub();
190
+ p.setPage(2);
191
+ expect(listener).not.toHaveBeenCalled();
192
+ });
193
+ });
194
+
195
+ describe('auto-tracking integration', () => {
196
+ it('ViewModel getter invalidates when pagination changes', () => {
197
+ let applyCallCount = 0;
198
+
199
+ class TestVM extends ViewModel {
200
+ readonly pagination = new Pagination({ pageSize: 2 });
201
+ private _items = [1, 2, 3, 4, 5, 6];
202
+
203
+ get items() {
204
+ applyCallCount++;
205
+ return this.pagination.apply(this._items);
206
+ }
207
+ }
208
+
209
+ const vm = new TestVM();
210
+ vm.init();
211
+
212
+ expect(vm.items).toEqual([1, 2]);
213
+ expect(applyCallCount).toBe(1);
214
+
215
+ // Cached
216
+ vm.items;
217
+ expect(applyCallCount).toBe(1);
218
+
219
+ // Change page — getter should recompute
220
+ vm.pagination.setPage(2);
221
+ expect(vm.items).toEqual([3, 4]);
222
+ expect(applyCallCount).toBe(2);
223
+
224
+ vm.dispose();
225
+ });
226
+ });
227
+
228
+ describe('method binding', () => {
229
+ it('destructured methods work point-free', () => {
230
+ const pagination = new Pagination({ pageSize: 5 });
231
+ const { nextPage, prevPage, setPage, reset } = pagination;
232
+ nextPage();
233
+ expect(pagination.page).toBe(2);
234
+ nextPage();
235
+ expect(pagination.page).toBe(3);
236
+ prevPage();
237
+ expect(pagination.page).toBe(2);
238
+ setPage(5);
239
+ expect(pagination.page).toBe(5);
240
+ reset();
241
+ expect(pagination.page).toBe(1);
242
+ });
243
+ });
244
+ });