mvc-kit 2.0.0 → 2.1.0
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 +19 -0
- package/agent-config/bin/setup.mjs +217 -0
- package/agent-config/claude-code/.claude-plugin/plugin.json +5 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +97 -0
- package/agent-config/claude-code/skills/guide/SKILL.md +85 -0
- package/agent-config/claude-code/skills/guide/anti-patterns.md +321 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +310 -0
- package/agent-config/claude-code/skills/guide/patterns.md +336 -0
- package/agent-config/claude-code/skills/review/SKILL.md +53 -0
- package/agent-config/claude-code/skills/review/checklist.md +89 -0
- package/agent-config/claude-code/skills/scaffold/SKILL.md +52 -0
- package/agent-config/claude-code/skills/scaffold/templates/channel.md +88 -0
- package/agent-config/claude-code/skills/scaffold/templates/collection.md +49 -0
- package/agent-config/claude-code/skills/scaffold/templates/controller.md +56 -0
- package/agent-config/claude-code/skills/scaffold/templates/eventbus.md +54 -0
- package/agent-config/claude-code/skills/scaffold/templates/model.md +102 -0
- package/agent-config/claude-code/skills/scaffold/templates/page-component.md +58 -0
- package/agent-config/claude-code/skills/scaffold/templates/service.md +101 -0
- package/agent-config/claude-code/skills/scaffold/templates/viewmodel.md +128 -0
- package/agent-config/copilot/copilot-instructions.md +242 -0
- package/agent-config/cursor/cursorrules +242 -0
- package/package.json +6 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Controller Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## {{Name}}Controller.ts
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Controller } from 'mvc-kit';
|
|
7
|
+
|
|
8
|
+
export class {{Name}}Controller extends Controller {
|
|
9
|
+
constructor(
|
|
10
|
+
// TODO: Inject dependencies
|
|
11
|
+
// private viewModelA: SomeViewModel,
|
|
12
|
+
// private viewModelB: AnotherViewModel,
|
|
13
|
+
) {
|
|
14
|
+
super();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
protected onInit() {
|
|
18
|
+
// Wire up cross-ViewModel subscriptions
|
|
19
|
+
// this.subscribeTo(this.viewModelA, () => this.onViewModelAChanged());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// TODO: Add orchestration methods
|
|
23
|
+
// async submit() {
|
|
24
|
+
// const items = this.viewModelA.state.items;
|
|
25
|
+
// await this.viewModelB.process(items);
|
|
26
|
+
// }
|
|
27
|
+
|
|
28
|
+
// private onViewModelAChanged() {
|
|
29
|
+
// // React to changes in viewModelA
|
|
30
|
+
// }
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { useLocal } from 'mvc-kit/react';
|
|
38
|
+
import { {{Name}}Controller } from '../controllers/{{Name}}Controller';
|
|
39
|
+
|
|
40
|
+
function {{Name}}Page() {
|
|
41
|
+
const controller = useLocal(() => new {{Name}}Controller(/* deps */));
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div>
|
|
45
|
+
<button onClick={() => controller.submit()}>Submit</button>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Notes
|
|
52
|
+
|
|
53
|
+
- **Rare** — most orchestration fits in a single ViewModel
|
|
54
|
+
- Use only when coordinating **multiple ViewModels** in a single workflow
|
|
55
|
+
- Has `disposeSignal`, `subscribeTo`, `addCleanup` — but no state, no getters, no async tracking
|
|
56
|
+
- Component-scoped via `useLocal` with factory function
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# EventBus Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## {{Name}}EventBus.ts
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { EventBus } from 'mvc-kit';
|
|
7
|
+
|
|
8
|
+
export interface {{Name}}Events {
|
|
9
|
+
// TODO: Define your event map
|
|
10
|
+
// 'item:created': { id: string };
|
|
11
|
+
// 'item:deleted': { id: string };
|
|
12
|
+
// 'notification': { message: string; severity: 'success' | 'error' | 'info' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class {{Name}}EventBus extends EventBus<{{Name}}Events> {}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { singleton } from 'mvc-kit';
|
|
22
|
+
import { {{Name}}EventBus } from '../events/{{Name}}EventBus';
|
|
23
|
+
|
|
24
|
+
// In a ViewModel:
|
|
25
|
+
private bus = singleton({{Name}}EventBus);
|
|
26
|
+
|
|
27
|
+
// Emit
|
|
28
|
+
this.bus.emit('item:created', { id: '123' });
|
|
29
|
+
|
|
30
|
+
// Subscribe (with auto-cleanup)
|
|
31
|
+
this.addCleanup(
|
|
32
|
+
this.bus.on('item:deleted', ({ id }) => {
|
|
33
|
+
this.set({ items: this.state.items.filter(i => i.id !== id) });
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
// In a React component:
|
|
40
|
+
import { useEvent } from 'mvc-kit/react';
|
|
41
|
+
import { singleton } from 'mvc-kit';
|
|
42
|
+
import { {{Name}}EventBus } from '../events/{{Name}}EventBus';
|
|
43
|
+
|
|
44
|
+
const bus = singleton({{Name}}EventBus);
|
|
45
|
+
useEvent(bus, 'notification', ({ message }) => {
|
|
46
|
+
toast.show(message);
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Notes
|
|
51
|
+
|
|
52
|
+
- Use EventBus for **cross-cutting** events between unrelated parts of the app
|
|
53
|
+
- For ViewModel-to-component events (toasts, navigation), prefer the **second generic parameter** on ViewModel instead
|
|
54
|
+
- Keep the event map small — past 10-15 events, concerns may be entangled
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Model Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## {{Name}}Model.ts
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Model } from 'mvc-kit';
|
|
7
|
+
import type { ValidationErrors } from 'mvc-kit';
|
|
8
|
+
|
|
9
|
+
export interface {{Name}}FormState {
|
|
10
|
+
name: string;
|
|
11
|
+
// TODO: Add your fields
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class {{Name}}Model extends Model<{{Name}}FormState> {
|
|
15
|
+
setName(name: string) { this.set({ name }); }
|
|
16
|
+
// TODO: Add setters for each field
|
|
17
|
+
|
|
18
|
+
protected validate(state: {{Name}}FormState): ValidationErrors<{{Name}}FormState> {
|
|
19
|
+
const errors: Partial<Record<keyof {{Name}}FormState, string>> = {};
|
|
20
|
+
if (!state.name.trim()) errors.name = 'Name is required';
|
|
21
|
+
// TODO: Add validation rules
|
|
22
|
+
return errors;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## {{Name}}Model.test.ts
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { describe, test, expect } from 'vitest';
|
|
31
|
+
import { {{Name}}Model } from './{{Name}}Model';
|
|
32
|
+
|
|
33
|
+
describe('{{Name}}Model', () => {
|
|
34
|
+
function create(overrides: Partial<{ name: string }> = {}) {
|
|
35
|
+
return new {{Name}}Model({
|
|
36
|
+
name: '',
|
|
37
|
+
...overrides,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('validates required name', () => {
|
|
42
|
+
const model = create();
|
|
43
|
+
expect(model.valid).toBe(false);
|
|
44
|
+
expect(model.errors.name).toBe('Name is required');
|
|
45
|
+
model.dispose();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('valid when all fields filled', () => {
|
|
49
|
+
const model = create({ name: 'Test' });
|
|
50
|
+
expect(model.valid).toBe(true);
|
|
51
|
+
expect(model.errors.name).toBeUndefined();
|
|
52
|
+
model.dispose();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('tracks dirty state', () => {
|
|
56
|
+
const model = create({ name: 'Original' });
|
|
57
|
+
expect(model.dirty).toBe(false);
|
|
58
|
+
|
|
59
|
+
model.setName('Changed');
|
|
60
|
+
expect(model.dirty).toBe(true);
|
|
61
|
+
|
|
62
|
+
model.rollback();
|
|
63
|
+
expect(model.state.name).toBe('Original');
|
|
64
|
+
expect(model.dirty).toBe(false);
|
|
65
|
+
model.dispose();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('commit resets dirty baseline', () => {
|
|
69
|
+
const model = create({ name: 'Original' });
|
|
70
|
+
model.setName('Updated');
|
|
71
|
+
expect(model.dirty).toBe(true);
|
|
72
|
+
|
|
73
|
+
model.commit();
|
|
74
|
+
expect(model.dirty).toBe(false);
|
|
75
|
+
model.dispose();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## React Usage
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { useModel, useField } from 'mvc-kit/react';
|
|
84
|
+
import { {{Name}}Model } from '../models/{{Name}}Model';
|
|
85
|
+
|
|
86
|
+
function {{Name}}Form() {
|
|
87
|
+
const { state, errors, valid, dirty, model } = useModel(
|
|
88
|
+
() => new {{Name}}Model({ name: '' }),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<form onSubmit={e => { e.preventDefault(); /* handle submit */ }}>
|
|
93
|
+
<input
|
|
94
|
+
value={state.name}
|
|
95
|
+
onChange={e => model.setName(e.target.value)}
|
|
96
|
+
/>
|
|
97
|
+
{errors.name && <span className="error">{errors.name}</span>}
|
|
98
|
+
<button disabled={!valid || !dirty}>Save</button>
|
|
99
|
+
</form>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Page Component Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## {{Name}}Page.tsx
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { useLocal } from 'mvc-kit/react';
|
|
7
|
+
import { {{Name}}ViewModel } from '../viewmodels/{{Name}}ViewModel';
|
|
8
|
+
|
|
9
|
+
export function {{Name}}Page() {
|
|
10
|
+
const [state, vm] = useLocal({{Name}}ViewModel, {
|
|
11
|
+
items: [],
|
|
12
|
+
search: '',
|
|
13
|
+
// TODO: Add initial state matching {{Name}}ViewModel's State interface
|
|
14
|
+
});
|
|
15
|
+
const { loading, error } = vm.async.load;
|
|
16
|
+
|
|
17
|
+
// Optional: subscribe to ViewModel events
|
|
18
|
+
// useEvent(vm, 'saved', ({ id }) => toast.success(`Saved ${id}`));
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div>
|
|
22
|
+
<h1>{{Name}}</h1>
|
|
23
|
+
|
|
24
|
+
{/* Filters */}
|
|
25
|
+
<input
|
|
26
|
+
placeholder="Search..."
|
|
27
|
+
value={state.search}
|
|
28
|
+
onChange={e => vm.setSearch(e.target.value)}
|
|
29
|
+
/>
|
|
30
|
+
|
|
31
|
+
{/* Loading/Error states from async tracking */}
|
|
32
|
+
{loading && <p>Loading...</p>}
|
|
33
|
+
{error && <p className="error">{error}</p>}
|
|
34
|
+
|
|
35
|
+
{/* Data — use vm.x for computed values */}
|
|
36
|
+
{!loading && (
|
|
37
|
+
<div>
|
|
38
|
+
{/* TODO: Replace with your data display */}
|
|
39
|
+
<p>Showing {vm.filtered.length} of {vm.total}</p>
|
|
40
|
+
<ul>
|
|
41
|
+
{vm.filtered.map((item: any) => (
|
|
42
|
+
<li key={item.id}>{JSON.stringify(item)}</li>
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Rules
|
|
53
|
+
|
|
54
|
+
- **One ViewModel per component** via `useLocal`
|
|
55
|
+
- **No `useEffect`** for data loading — `onInit()` handles it
|
|
56
|
+
- **No `useState`/`useMemo`/`useCallback`** — the ViewModel is the hook
|
|
57
|
+
- Read `state.x` for raw values, `vm.x` for computed, `vm.async.x` for loading/error
|
|
58
|
+
- Connected components own a ViewModel; extract presentational children that receive props
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Service Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## {{Name}}Service.ts
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { Service, HttpError } from 'mvc-kit';
|
|
7
|
+
|
|
8
|
+
// TODO: Import or define your data types
|
|
9
|
+
// import type { {{Name}}Item } from '../types/{{name}}';
|
|
10
|
+
|
|
11
|
+
export class {{Name}}Service extends Service {
|
|
12
|
+
private baseUrl = '/api/{{name}}s'; // TODO: Set your API path
|
|
13
|
+
|
|
14
|
+
async getAll(signal?: AbortSignal): Promise<any[]> {
|
|
15
|
+
const res = await fetch(this.baseUrl, { signal });
|
|
16
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
17
|
+
return res.json();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async getById(id: string, signal?: AbortSignal): Promise<any> {
|
|
21
|
+
const res = await fetch(`${this.baseUrl}/${id}`, { signal });
|
|
22
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
23
|
+
return res.json();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async create(data: any, signal?: AbortSignal): Promise<any> {
|
|
27
|
+
const res = await fetch(this.baseUrl, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body: JSON.stringify(data),
|
|
31
|
+
signal,
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
34
|
+
return res.json();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async update(id: string, data: any, signal?: AbortSignal): Promise<any> {
|
|
38
|
+
const res = await fetch(`${this.baseUrl}/${id}`, {
|
|
39
|
+
method: 'PUT',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify(data),
|
|
42
|
+
signal,
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
45
|
+
return res.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async delete(id: string, signal?: AbortSignal): Promise<void> {
|
|
49
|
+
const res = await fetch(`${this.baseUrl}/${id}`, {
|
|
50
|
+
method: 'DELETE',
|
|
51
|
+
signal,
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) throw new HttpError(res.status, res.statusText);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## {{Name}}Service.test.ts
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
|
62
|
+
import { teardownAll } from 'mvc-kit';
|
|
63
|
+
import { {{Name}}Service } from './{{Name}}Service';
|
|
64
|
+
|
|
65
|
+
beforeEach(() => teardownAll());
|
|
66
|
+
|
|
67
|
+
describe('{{Name}}Service', () => {
|
|
68
|
+
test('getAll fetches items', async () => {
|
|
69
|
+
const mockData = [{ id: '1' }];
|
|
70
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
71
|
+
new Response(JSON.stringify(mockData), { status: 200 }),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const service = new {{Name}}Service();
|
|
75
|
+
const result = await service.getAll();
|
|
76
|
+
expect(result).toEqual(mockData);
|
|
77
|
+
|
|
78
|
+
vi.restoreAllMocks();
|
|
79
|
+
service.dispose();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('getAll throws HttpError on failure', async () => {
|
|
83
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
84
|
+
new Response(null, { status: 500, statusText: 'Internal Server Error' }),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const service = new {{Name}}Service();
|
|
88
|
+
await expect(service.getAll()).rejects.toThrow('Internal Server Error');
|
|
89
|
+
|
|
90
|
+
vi.restoreAllMocks();
|
|
91
|
+
service.dispose();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Rules
|
|
97
|
+
|
|
98
|
+
- **Stateless** — no caching (that's a Collection's job)
|
|
99
|
+
- **Accept `AbortSignal`** — lets ViewModels cancel via `disposeSignal`
|
|
100
|
+
- **Throw `HttpError`** — carries HTTP status for `classifyError()` to produce canonical codes
|
|
101
|
+
- **No knowledge of ViewModels or Collections** — Services are at the bottom of the dependency graph
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# ViewModel Template: {{Name}}
|
|
2
|
+
|
|
3
|
+
## {{Name}}ViewModel.ts
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { ViewModel, singleton } from 'mvc-kit';
|
|
7
|
+
// import { {{Name}}Service } from '../services/{{Name}}Service';
|
|
8
|
+
// import { {{Name}}Collection } from '../collections/{{Name}}Collection';
|
|
9
|
+
|
|
10
|
+
interface {{Name}}State {
|
|
11
|
+
items: any[]; // TODO: Replace with your item type
|
|
12
|
+
search: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Optional: define events for toasts, navigation, etc.
|
|
16
|
+
// interface {{Name}}Events {
|
|
17
|
+
// saved: { id: string };
|
|
18
|
+
// }
|
|
19
|
+
|
|
20
|
+
export class {{Name}}ViewModel extends ViewModel<{{Name}}State> {
|
|
21
|
+
// --- Private fields ---
|
|
22
|
+
// private service = singleton({{Name}}Service);
|
|
23
|
+
// private collection = singleton({{Name}}Collection);
|
|
24
|
+
|
|
25
|
+
// --- Computed getters ---
|
|
26
|
+
get filtered(): any[] {
|
|
27
|
+
const { items, search } = this.state;
|
|
28
|
+
if (!search) return items;
|
|
29
|
+
const q = search.toLowerCase();
|
|
30
|
+
return items.filter(item =>
|
|
31
|
+
JSON.stringify(item).toLowerCase().includes(q),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get total(): number {
|
|
36
|
+
return this.state.items.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get hasResults(): boolean {
|
|
40
|
+
return this.filtered.length > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Lifecycle ---
|
|
44
|
+
protected onInit() {
|
|
45
|
+
// Subscribe to collection and mirror data into state
|
|
46
|
+
// this.subscribeTo(this.collection, () => {
|
|
47
|
+
// this.set({ items: this.collection.items });
|
|
48
|
+
// });
|
|
49
|
+
|
|
50
|
+
// Smart init: use cached data or fetch fresh
|
|
51
|
+
// if (this.collection.length > 0) {
|
|
52
|
+
// this.set({ items: this.collection.items });
|
|
53
|
+
// } else {
|
|
54
|
+
// this.load();
|
|
55
|
+
// }
|
|
56
|
+
|
|
57
|
+
this.load();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Actions ---
|
|
61
|
+
async load() {
|
|
62
|
+
// const data = await this.service.getAll(this.disposeSignal);
|
|
63
|
+
// this.collection.reset(data);
|
|
64
|
+
// TODO: implement data loading
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Setters ---
|
|
68
|
+
setSearch(search: string) { this.set({ search }); }
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## {{Name}}ViewModel.test.ts
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { describe, test, expect, beforeEach } from 'vitest';
|
|
76
|
+
import { teardownAll } from 'mvc-kit';
|
|
77
|
+
import { {{Name}}ViewModel } from './{{Name}}ViewModel';
|
|
78
|
+
|
|
79
|
+
beforeEach(() => teardownAll());
|
|
80
|
+
|
|
81
|
+
describe('{{Name}}ViewModel', () => {
|
|
82
|
+
function create(overrides: Partial<{ items: any[]; search: string }> = {}) {
|
|
83
|
+
return new {{Name}}ViewModel({
|
|
84
|
+
items: [],
|
|
85
|
+
search: '',
|
|
86
|
+
...overrides,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('initializes with default state', () => {
|
|
91
|
+
const vm = create();
|
|
92
|
+
expect(vm.state.items).toEqual([]);
|
|
93
|
+
expect(vm.state.search).toBe('');
|
|
94
|
+
vm.dispose();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('setSearch updates search state', () => {
|
|
98
|
+
const vm = create();
|
|
99
|
+
vm.setSearch('test');
|
|
100
|
+
expect(vm.state.search).toBe('test');
|
|
101
|
+
vm.dispose();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('filtered getter applies search', () => {
|
|
105
|
+
const vm = create({
|
|
106
|
+
items: [
|
|
107
|
+
{ id: '1', name: 'Alpha' },
|
|
108
|
+
{ id: '2', name: 'Beta' },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(vm.filtered).toHaveLength(2);
|
|
113
|
+
|
|
114
|
+
vm.setSearch('alpha');
|
|
115
|
+
expect(vm.filtered).toHaveLength(1);
|
|
116
|
+
|
|
117
|
+
vm.dispose();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('total returns item count', () => {
|
|
121
|
+
const vm = create({
|
|
122
|
+
items: [{ id: '1' }, { id: '2' }, { id: '3' }],
|
|
123
|
+
});
|
|
124
|
+
expect(vm.total).toBe(3);
|
|
125
|
+
vm.dispose();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
```
|