@teamix-evo/skills 0.2.0 → 0.4.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.
Files changed (56) hide show
  1. package/README.md +18 -8
  2. package/_template/SKILL.md.hbs +14 -1
  3. package/manifest.json +79 -6
  4. package/package.json +7 -3
  5. package/src/teamix-evo-code-opentrek/SKILL.md +92 -0
  6. package/src/teamix-evo-code-opentrek/api-layering.md +225 -0
  7. package/src/teamix-evo-code-opentrek/checklist.md +173 -0
  8. package/src/teamix-evo-code-opentrek/error-and-loading.md +269 -0
  9. package/src/teamix-evo-code-opentrek/file-structure.md +273 -0
  10. package/src/teamix-evo-code-opentrek/forms-and-validation.md +220 -0
  11. package/src/teamix-evo-code-opentrek/reuse-first.md +130 -0
  12. package/src/teamix-evo-code-opentrek/routing-and-codesplit.md +298 -0
  13. package/src/teamix-evo-code-opentrek/testing.md +313 -0
  14. package/src/teamix-evo-code-uni-manager/SKILL.md +95 -0
  15. package/src/teamix-evo-code-uni-manager/api-layering.md +370 -0
  16. package/src/teamix-evo-code-uni-manager/checklist.md +193 -0
  17. package/src/teamix-evo-code-uni-manager/error-and-loading.md +389 -0
  18. package/src/teamix-evo-code-uni-manager/file-structure.md +339 -0
  19. package/src/teamix-evo-code-uni-manager/forms-and-validation.md +459 -0
  20. package/src/teamix-evo-code-uni-manager/reuse-first.md +188 -0
  21. package/src/teamix-evo-code-uni-manager/routing-and-codesplit.md +450 -0
  22. package/src/teamix-evo-code-uni-manager/testing.md +396 -0
  23. package/src/teamix-evo-design-opentrek/SKILL.md +71 -0
  24. package/src/teamix-evo-design-opentrek/boundaries.md +513 -0
  25. package/src/teamix-evo-design-opentrek/brand.md +154 -0
  26. package/src/teamix-evo-design-opentrek/checklist.md +83 -0
  27. package/src/teamix-evo-design-opentrek/components.md +245 -0
  28. package/src/teamix-evo-design-opentrek/flows.md +51 -0
  29. package/src/teamix-evo-design-opentrek/foundations.md +271 -0
  30. package/src/teamix-evo-design-opentrek/generation-flow.md +185 -0
  31. package/src/teamix-evo-design-opentrek/patterns/dashboard.md +31 -0
  32. package/src/teamix-evo-design-opentrek/patterns/detail-page.md +202 -0
  33. package/src/teamix-evo-design-opentrek/patterns/form-page.md +289 -0
  34. package/src/teamix-evo-design-opentrek/patterns/list-page.md +334 -0
  35. package/src/teamix-evo-design-opentrek/patterns/page-types.md +154 -0
  36. package/src/teamix-evo-design-opentrek/philosophy.md +96 -0
  37. package/src/teamix-evo-design-opentrek/rules/README.md +39 -0
  38. package/src/teamix-evo-design-opentrek/rules/boundaries.rules.json +391 -0
  39. package/src/teamix-evo-design-uni-manager/SKILL.md +73 -0
  40. package/src/teamix-evo-design-uni-manager/boundaries.md +564 -0
  41. package/src/teamix-evo-design-uni-manager/brand.md +202 -0
  42. package/src/teamix-evo-design-uni-manager/checklist.md +115 -0
  43. package/src/teamix-evo-design-uni-manager/components.md +254 -0
  44. package/src/teamix-evo-design-uni-manager/flows.md +63 -0
  45. package/src/teamix-evo-design-uni-manager/foundations.md +258 -0
  46. package/src/teamix-evo-design-uni-manager/generation-flow.md +194 -0
  47. package/src/teamix-evo-design-uni-manager/patterns/dashboard.md +95 -0
  48. package/src/teamix-evo-design-uni-manager/patterns/detail-page.md +224 -0
  49. package/src/teamix-evo-design-uni-manager/patterns/form-page.md +329 -0
  50. package/src/teamix-evo-design-uni-manager/patterns/list-page.md +356 -0
  51. package/src/teamix-evo-design-uni-manager/patterns/page-types.md +165 -0
  52. package/src/teamix-evo-design-uni-manager/philosophy.md +106 -0
  53. package/src/teamix-evo-design-uni-manager/rules/README.md +49 -0
  54. package/src/teamix-evo-design-uni-manager/rules/boundaries.rules.json +418 -0
  55. package/src/teamix-evo-manage/SKILL.md +281 -0
  56. package/skills/teamix-evo-manage/SKILL.md +0 -138
@@ -0,0 +1,298 @@
1
+ # 路由与代码分包规范
2
+
3
+ > **核心约定**:页面级 `React.lazy` 默认分包,鉴权 / 角色守卫集中在 `src/routes/`,404 / 401 / 403 必有兜底。
4
+
5
+ ---
6
+
7
+ ## 路由库选型
8
+
9
+ teamix-evo console preset 默认 `react-router-dom@6`(Data Router 模式,业界主流)。
10
+
11
+ | 候选 | 何时选 |
12
+ | --- | --- |
13
+ | `react-router-dom@6` Data Router | **默认**。中后台 / SPA / 多页应用 |
14
+ | `@tanstack/react-router` | 类型安全要求极高、复杂 search params | (替换需在 ADR 记录)
15
+ | 文件路由(Next.js / Remix) | 不在本 preset 范围 |
16
+
17
+ **一个项目只用一种**。已经用了 react-router 就不要混入 tanstack-router。
18
+
19
+ ---
20
+
21
+ ## §1 · 路由文件组织
22
+
23
+ ```
24
+ src/routes/
25
+ ├── index.tsx # createBrowserRouter 声明(单一来源)
26
+ ├── guards.tsx # AuthGuard、RoleGuard
27
+ └── lazy.tsx # 统一的 lazy() 工厂,可选
28
+ ```
29
+
30
+ **约定**:
31
+
32
+ - 路由声明**只在** `src/routes/index.tsx`,不要散在多个 layout / page 里
33
+ - 页面文件**默认 default export**,便于 `React.lazy`
34
+ - 嵌套路由用 `children`,layout 用 `<Outlet />`
35
+
36
+ ### 标准声明
37
+
38
+ ```tsx
39
+ // src/routes/index.tsx
40
+ import { createBrowserRouter } from 'react-router-dom';
41
+ import { lazy } from 'react';
42
+ import { ConsoleLayout } from '@/layouts/ConsoleLayout';
43
+ import { AuthGuard } from './guards';
44
+ import { PageErrorFallback } from '@/components/PageErrorFallback';
45
+ import { PageSkeleton } from '@/components/PageSkeleton';
46
+
47
+ const DashboardPage = lazy(() => import('@/pages/dashboard'));
48
+ const OrderListPage = lazy(() => import('@/pages/orders'));
49
+ const OrderDetailPage = lazy(() => import('@/pages/orders/[id]'));
50
+ const LoginPage = lazy(() => import('@/pages/login'));
51
+ const NotFoundPage = lazy(() => import('@/pages/_not-found'));
52
+
53
+ export const router = createBrowserRouter([
54
+ {
55
+ path: '/login',
56
+ element: <LoginPage />,
57
+ },
58
+ {
59
+ element: (
60
+ <AuthGuard>
61
+ <ConsoleLayout />
62
+ </AuthGuard>
63
+ ),
64
+ errorElement: <PageErrorFallback />,
65
+ children: [
66
+ { path: '/', element: <DashboardPage /> },
67
+ { path: '/orders', element: <OrderListPage /> },
68
+ { path: '/orders/:id', element: <OrderDetailPage /> },
69
+ ],
70
+ },
71
+ { path: '*', element: <NotFoundPage /> },
72
+ ]);
73
+ ```
74
+
75
+ ---
76
+
77
+ ## §2 · 代码分包(默认开启)
78
+
79
+ ### 规则
80
+
81
+ - **每个页面文件**用 `React.lazy()` 引入 → vite 自动 chunk
82
+ - 重型第三方库(图表、富文本、PDF)用动态 import 包一层组件,延迟到使用时加载
83
+ - 同一 layout 下的多个 page 自动共享 layout chunk —— 不需要手动配
84
+
85
+ ### 不分包的例外
86
+
87
+ - 登录页 / 错误页 / 404 → **不分包**(否则首屏要等 chunk 加载)
88
+ - 主 layout / Header / Sidebar → **不分包**(立即用到)
89
+ - < 5 KB 的小页面 → 分不分都行,默认分
90
+
91
+ ### 动态 import 重型库
92
+
93
+ ```tsx
94
+ // ❌ 顶部 import:整个图表库进首屏 bundle
95
+ import { LineChart } from 'recharts';
96
+
97
+ // ✅ 用到时再加载
98
+ const LineChart = lazy(() =>
99
+ import('recharts').then((m) => ({ default: m.LineChart })),
100
+ );
101
+
102
+ <Suspense fallback={<ChartSkeleton />}>
103
+ <LineChart data={data} />
104
+ </Suspense>;
105
+ ```
106
+
107
+ ---
108
+
109
+ ## §3 · Suspense 配合
110
+
111
+ 每个 lazy 页面**必须**有 Suspense 兜底,否则首次进入会抛错。
112
+
113
+ 最佳实践:在 layout / route 配置里挂一次,所有 children 共享:
114
+
115
+ ```tsx
116
+ // ConsoleLayout.tsx
117
+ import { Outlet } from 'react-router-dom';
118
+ import { Suspense } from 'react';
119
+
120
+ export function ConsoleLayout() {
121
+ return (
122
+ <div className="flex">
123
+ <Sidebar />
124
+ <main className="flex-1">
125
+ <Suspense fallback={<PageSkeleton />}>
126
+ <Outlet />
127
+ </Suspense>
128
+ </main>
129
+ </div>
130
+ );
131
+ }
132
+ ```
133
+
134
+ ---
135
+
136
+ ## §4 · 鉴权 / 角色守卫
137
+
138
+ ### 模式:守卫组件包路由
139
+
140
+ ```tsx
141
+ // src/routes/guards.tsx
142
+ import { Navigate, useLocation } from 'react-router-dom';
143
+ import { useCurrentUser } from '@/hooks/useCurrentUser';
144
+
145
+ export function AuthGuard({ children }: { children: React.ReactNode }) {
146
+ const { data: user, isPending } = useCurrentUser();
147
+ const location = useLocation();
148
+
149
+ if (isPending) return <PageSkeleton />;
150
+ if (!user) {
151
+ // 记录原 URL,登录后跳回
152
+ return <Navigate to="/login" state={{ from: location.pathname }} replace />;
153
+ }
154
+ return <>{children}</>;
155
+ }
156
+
157
+ export function RoleGuard({
158
+ roles,
159
+ children,
160
+ }: {
161
+ roles: string[];
162
+ children: React.ReactNode;
163
+ }) {
164
+ const { data: user } = useCurrentUser();
165
+ if (!user || !roles.some((r) => user.roles.includes(r))) {
166
+ return <Navigate to="/_forbidden" replace />;
167
+ }
168
+ return <>{children}</>;
169
+ }
170
+ ```
171
+
172
+ ### 反模式
173
+
174
+ - ❌ 每个页面顶部都写 `if (!user) navigate('/login')` —— 抽到守卫
175
+ - ❌ 把角色判断写在 sidebar / button 的渲染逻辑里(只能藏入口,无法防直接访问 URL)—— 路由层判 + UI 层判,**两层都要**
176
+ - ❌ 守卫里直接 fetch 用户信息 —— 走 `useCurrentUser` hook,享受缓存
177
+
178
+ ---
179
+
180
+ ## §5 · 必须存在的兜底路由
181
+
182
+ 每个项目**必须**有这三个页面,放在 `src/pages/_xxx/`(下划线前缀,表示非业务路由):
183
+
184
+ | 路径 | 文件 | 触发场景 |
185
+ | --- | --- | --- |
186
+ | `*` (404) | `src/pages/_not-found/index.tsx` | 任何未匹配的 URL |
187
+ | `/_forbidden` (403) | `src/pages/_forbidden/index.tsx` | 角色无权访问 |
188
+ | `/_error` (500) | `src/pages/_error/index.tsx` | router `errorElement` 兜底 |
189
+
190
+ 未登录(401)由 `AuthGuard` 重定向到 `/login`,**不**单独建页面。
191
+
192
+ ---
193
+
194
+ ## §6 · search params 与状态同步
195
+
196
+ 列表页的筛选 / 分页 / 排序参数**必须**写进 URL,刷新 / 分享 / 浏览器后退都要还原:
197
+
198
+ ```tsx
199
+ import { useSearchParams } from 'react-router-dom';
200
+
201
+ function OrderListPage() {
202
+ const [searchParams, setSearchParams] = useSearchParams();
203
+ const status = searchParams.get('status') ?? 'all';
204
+ const page = Number(searchParams.get('page') ?? 1);
205
+
206
+ const { data } = useOrderList({ status, page });
207
+
208
+ return (
209
+ <Toolbar
210
+ status={status}
211
+ onStatusChange={(s) => setSearchParams({ status: s, page: '1' })}
212
+ />
213
+ );
214
+ }
215
+ ```
216
+
217
+ 复杂场景用 [`nuqs`](https://nuqs.47ng.com/) 做类型化包装,业界常见。
218
+
219
+ 反模式:
220
+
221
+ - ❌ 筛选状态只放 `useState` —— 刷新就丢
222
+ - ❌ 分页放 store —— 多 tab 各自分页会互相污染
223
+
224
+ ---
225
+
226
+ ## §7 · 导航与跳转
227
+
228
+ ```tsx
229
+ import { useNavigate, Link } from 'react-router-dom';
230
+
231
+ // 声明式(链接)
232
+ <Link to={`/orders/${order.id}`}>查看</Link>
233
+
234
+ // 命令式(mutation success)
235
+ const navigate = useNavigate();
236
+ mutation.mutate(values, {
237
+ onSuccess: (order) => navigate(`/orders/${order.id}`),
238
+ });
239
+ ```
240
+
241
+ 约束:
242
+
243
+ - 静态跳 → `<Link>`(可右键打开新 tab)
244
+ - 动态跳(提交后) → `useNavigate()`
245
+ - **不要** `window.location.href = ...`(整页刷新,丢状态)
246
+ - 外链 → 普通 `<a href target="_blank" rel="noopener noreferrer">`
247
+
248
+ ---
249
+
250
+ ## 反模式速查
251
+
252
+ | 反模式 | 为什么禁 | 应该 |
253
+ | --- | --- | --- |
254
+ | 全部页面顶部 `import` 不 lazy | 首屏 bundle 爆炸 | `React.lazy` 每页 |
255
+ | Lazy 但没 Suspense 兜底 | 进入页面报错 | Layout 挂一次 Suspense |
256
+ | 鉴权写在每个 page 里 | 散乱、易漏 | 守卫组件 + 路由层 |
257
+ | 路由声明分散在多处 | 难维护、易循环 | 唯一在 `src/routes/index.tsx` |
258
+ | 列表筛选状态不进 URL | 刷新 / 分享 / 后退失效 | `useSearchParams` |
259
+ | `window.location.href` 跳转 | 整页刷新 | `useNavigate` / `<Link>` |
260
+ | 路由 path 写魔法字符串 | 改路径漏改 | 抽 `src/routes/paths.ts` 集中 |
261
+
262
+ ---
263
+
264
+ ## 进阶:Loader / Action(react-router v6.4+)
265
+
266
+ 如果项目愿意采用 Data Router 的 `loader` / `action` 模式,可把数据预取下沉到路由层:
267
+
268
+ ```tsx
269
+ {
270
+ path: '/orders/:id',
271
+ element: <OrderDetailPage />,
272
+ loader: async ({ params }) => {
273
+ return queryClient.ensureQueryData({
274
+ queryKey: ['order', params.id],
275
+ queryFn: () => getOrder(params.id!),
276
+ });
277
+ },
278
+ }
279
+ ```
280
+
281
+ 收益:页面切换时数据并行加载(不是渲染后才发请求),首屏更快。
282
+
283
+ **慎用**:loader 写多了和 hook 形成两套数据流,需团队约定清楚。建议先用 hook + Suspense,熟练后再考虑 loader。
284
+
285
+ ---
286
+
287
+ ## AI 必须输出的路由日志
288
+
289
+ ```
290
+ ## 路由 / 分包改动
291
+
292
+ - src/routes/index.tsx: 注册 /orders、/orders/:id(均 React.lazy)
293
+ - src/pages/orders/index.tsx: default export(便于 lazy)
294
+ - 守卫: ✅ 包在已有 AuthGuard 下
295
+ - Suspense: ✅ ConsoleLayout 已挂一次,新增页面共用
296
+ - search params: ✅ status / page 用 useSearchParams
297
+ - 兜底: ✅ * → NotFoundPage 已存在
298
+ ```
@@ -0,0 +1,313 @@
1
+ # 测试规范
2
+
3
+ > **核心约定**:`vitest` + `@testing-library/react` + `msw`,文件就近 `*.test.ts(x)`。**不追求覆盖率数字**,追求"关键路径必有测试 + 纯函数全测"。
4
+
5
+ ---
6
+
7
+ ## 选型(业界主流)
8
+
9
+ | 类型 | 工具 | 理由 |
10
+ | --- | --- | --- |
11
+ | 测试 runner | `vitest` | 与 Vite 原生集成,ESM 友好,API 与 Jest 兼容 |
12
+ | 组件测试 | `@testing-library/react` + `@testing-library/user-event` | 测行为不测实现,业界事实标准(Kent C. Dodds) |
13
+ | API mock | `msw`(Mock Service Worker) | 拦截真实 fetch / axios,测试与生产共用 service 层 |
14
+ | 断言 | `vitest` 内置 `expect`(兼容 Jest) | 无需 chai |
15
+ | E2E(可选) | `playwright` | 关键流程冒烟,**不在本 skill 范围** |
16
+
17
+ **不要混用**:已经选 vitest 就不要再加 jest;已经用 msw 就不要再自己 mock axios。
18
+
19
+ ---
20
+
21
+ ## §1 · 文件位置
22
+
23
+ 测试文件**就近**放在被测文件同目录,`<name>.test.ts(x)`:
24
+
25
+ ```
26
+ src/
27
+ ├── utils/
28
+ │ ├── format.ts
29
+ │ └── format.test.ts # ← 紧邻
30
+ ├── services/
31
+ │ ├── order.ts
32
+ │ └── order.test.ts
33
+ ├── hooks/
34
+ │ ├── useOrderList.ts
35
+ │ └── useOrderList.test.ts
36
+ └── components/
37
+ ├── OrderStatusBadge.tsx
38
+ └── OrderStatusBadge.test.tsx
39
+ ```
40
+
41
+ **不要**集中放在 `tests/` / `__tests__/` 顶层 —— 业界共识已偏向就近,便于维护与移动。
42
+
43
+ 测试辅助资源(fixtures、mock handler)放 `src/test/`:
44
+
45
+ ```
46
+ src/test/
47
+ ├── setup.ts # vitest setup(jest-dom、msw server)
48
+ ├── handlers.ts # msw handler 集合
49
+ ├── render.tsx # 自定义 render(包 Provider)
50
+ └── fixtures/
51
+ └── order.ts # 测试数据工厂
52
+ ```
53
+
54
+ ---
55
+
56
+ ## §2 · 应该写什么测试(优先级)
57
+
58
+ 按 ROI 排序:
59
+
60
+ | 优先级 | 类型 | 例子 | 必测? |
61
+ | --- | --- | --- | --- |
62
+ | P0 | 纯函数(`src/utils/`、`src/services/`) | `formatMoney`、`isValidOrder` | ✅ **必测** |
63
+ | P0 | zod schema | `CreateOrderInputSchema.parse(...)` | ✅ **必测** |
64
+ | P0 | 关键业务路径(下单 / 支付 / 鉴权) | E2E-style 组件测 | ✅ **必测** |
65
+ | P1 | Hook(数据 hook + 自定义 UI hook) | `useOrderList`、`useDebounce` | 推荐 |
66
+ | P1 | 复用业务组件(`src/components/`) | `OrderStatusBadge` 各状态渲染 | 推荐 |
67
+ | P2 | 页面组件 | 用 RTL 渲染 + msw mock | 可选 |
68
+ | ❌ | ui 包源码 | 已在 `@teamix-evo/ui` 包内测过 | **不要重复测** |
69
+ | ❌ | 三方库 | `react-router`、`react-query` | **不要测** |
70
+
71
+ **红线**:不要为了凑覆盖率数字测 `Button` 能不能渲染、`React.useState` 能不能用。
72
+
73
+ ---
74
+
75
+ ## §3 · 标准测试模式
76
+
77
+ ### 纯函数
78
+
79
+ ```ts
80
+ // src/utils/format.test.ts
81
+ import { describe, it, expect } from 'vitest';
82
+ import { formatMoney } from './format';
83
+
84
+ describe('formatMoney', () => {
85
+ it('保留两位小数', () => {
86
+ expect(formatMoney(1234.5)).toBe('1,234.50');
87
+ });
88
+ it('0 显示 0.00', () => {
89
+ expect(formatMoney(0)).toBe('0.00');
90
+ });
91
+ it('负数前置 -', () => {
92
+ expect(formatMoney(-1.5)).toBe('-1.50');
93
+ });
94
+ });
95
+ ```
96
+
97
+ 约定:
98
+
99
+ - 一个 `describe` 一个函数
100
+ - 一个 `it` 一个行为(不堆 5 个 expect 测无关行为)
101
+ - 测试名是**行为描述**,不是函数名("保留两位小数" ✓ / "test formatMoney" ❌)
102
+
103
+ ### Service 函数(用 msw)
104
+
105
+ ```ts
106
+ // src/test/handlers.ts
107
+ import { http, HttpResponse } from 'msw';
108
+
109
+ export const handlers = [
110
+ http.get('/api/orders', () =>
111
+ HttpResponse.json({ list: [{ id: 'o1', status: 'pending' }] }),
112
+ ),
113
+ http.post('/api/orders', async ({ request }) => {
114
+ const body = await request.json();
115
+ return HttpResponse.json({ id: 'o-new', ...body });
116
+ }),
117
+ ];
118
+ ```
119
+
120
+ ```ts
121
+ // src/test/setup.ts
122
+ import { setupServer } from 'msw/node';
123
+ import { afterAll, afterEach, beforeAll } from 'vitest';
124
+ import '@testing-library/jest-dom/vitest';
125
+ import { handlers } from './handlers';
126
+
127
+ const server = setupServer(...handlers);
128
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
129
+ afterEach(() => server.resetHandlers());
130
+ afterAll(() => server.close());
131
+ ```
132
+
133
+ ```ts
134
+ // src/services/order.test.ts
135
+ import { describe, it, expect } from 'vitest';
136
+ import { listOrders } from './order';
137
+
138
+ describe('listOrders', () => {
139
+ it('返回订单列表', async () => {
140
+ const orders = await listOrders({ status: 'all' });
141
+ expect(orders).toHaveLength(1);
142
+ expect(orders[0].id).toBe('o1');
143
+ });
144
+ });
145
+ ```
146
+
147
+ ### Hook
148
+
149
+ ```tsx
150
+ // src/hooks/useOrderList.test.tsx
151
+ import { renderHook, waitFor } from '@testing-library/react';
152
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
153
+ import { useOrderList } from './useOrderList';
154
+
155
+ function wrapper({ children }: { children: React.ReactNode }) {
156
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
157
+ return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
158
+ }
159
+
160
+ it('加载订单列表', async () => {
161
+ const { result } = renderHook(() => useOrderList({ status: 'all' }), { wrapper });
162
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
163
+ expect(result.current.data).toHaveLength(1);
164
+ });
165
+ ```
166
+
167
+ ### 组件(testing-library 范式:测行为不测实现)
168
+
169
+ ```tsx
170
+ // src/components/OrderStatusBadge.test.tsx
171
+ import { render, screen } from '@testing-library/react';
172
+ import { OrderStatusBadge } from './OrderStatusBadge';
173
+
174
+ it('pending 状态显示"待处理"', () => {
175
+ render(<OrderStatusBadge status="pending" />);
176
+ expect(screen.getByText('待处理')).toBeInTheDocument();
177
+ });
178
+
179
+ it('cancelled 状态有 destructive 视觉', () => {
180
+ render(<OrderStatusBadge status="cancelled" />);
181
+ expect(screen.getByText('已取消')).toHaveClass('bg-destructive');
182
+ });
183
+ ```
184
+
185
+ ### 用户交互(`user-event`)
186
+
187
+ ```tsx
188
+ import userEvent from '@testing-library/user-event';
189
+
190
+ it('点击提交按钮触发 onSubmit', async () => {
191
+ const onSubmit = vi.fn();
192
+ render(<CreateOrderForm onSubmit={onSubmit} />);
193
+
194
+ await userEvent.type(screen.getByLabelText('客户'), 'cust-1');
195
+ await userEvent.click(screen.getByRole('button', { name: '提交' }));
196
+
197
+ expect(onSubmit).toHaveBeenCalledWith({ customerId: 'cust-1', items: [] });
198
+ });
199
+ ```
200
+
201
+ **不要**用 `fireEvent` —— 它不模拟真实键盘 / 鼠标事件序列,`user-event` 才贴近真实用户。
202
+
203
+ ---
204
+
205
+ ## §4 · 自定义 render(包 Provider)
206
+
207
+ 每次 render 都重复套 QueryClient / Router / Theme 太繁琐,抽到 `src/test/render.tsx`:
208
+
209
+ ```tsx
210
+ // src/test/render.tsx
211
+ import { render, type RenderOptions } from '@testing-library/react';
212
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
213
+ import { MemoryRouter } from 'react-router-dom';
214
+
215
+ export function renderWithProviders(
216
+ ui: React.ReactElement,
217
+ { route = '/', ...options }: RenderOptions & { route?: string } = {},
218
+ ) {
219
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
220
+ return render(
221
+ <QueryClientProvider client={qc}>
222
+ <MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
223
+ </QueryClientProvider>,
224
+ options,
225
+ );
226
+ }
227
+ ```
228
+
229
+ 测试里:
230
+
231
+ ```tsx
232
+ import { renderWithProviders } from '@/test/render';
233
+
234
+ renderWithProviders(<OrderListPage />, { route: '/orders?status=pending' });
235
+ ```
236
+
237
+ ---
238
+
239
+ ## §5 · 反模式速查
240
+
241
+ | 反模式 | 为什么禁 | 应该 |
242
+ | --- | --- | --- |
243
+ | 测 `setState` 是否被调用 | 测了实现,重构即崩 | 测渲染结果 / 用户行为 |
244
+ | `getByTestId` 当默认查询 | data-testid 跟可访问性绑不上 | `getByRole` / `getByLabelText` 优先 |
245
+ | 一个 it 里 expect 10 次无关断言 | 失败定位难 | 拆多个 it |
246
+ | mock 整个 hook(`vi.mock('@/hooks/...')`) | 失去集成价值 | msw mock 后端,hook 真跑 |
247
+ | 自己 mock fetch / axios | 与生产代码路径不一致 | 用 msw |
248
+ | 测试里写 `setTimeout(..., 1000)` 等异步 | 慢且不稳定 | `await waitFor` / `findBy*` |
249
+ | 全局 mock console.error 静默 | 错过真实告警 | 让测试输出 console.error,但 setup 里 fail on it |
250
+ | 追求 80% 覆盖率刷数字 | 测了没价值的代码 | 按"优先级表"测,不看覆盖率盲冲 |
251
+
252
+ ---
253
+
254
+ ## §6 · 装机指引
255
+
256
+ ```bash
257
+ pnpm add -D vitest @vitest/ui @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom msw
258
+ ```
259
+
260
+ ```ts
261
+ // vite.config.ts
262
+ import { defineConfig } from 'vitest/config';
263
+
264
+ export default defineConfig({
265
+ test: {
266
+ environment: 'jsdom',
267
+ setupFiles: ['./src/test/setup.ts'],
268
+ globals: true, // 可选:免 import describe / it / expect
269
+ },
270
+ });
271
+ ```
272
+
273
+ ```json
274
+ // package.json scripts
275
+ {
276
+ "test": "vitest",
277
+ "test:run": "vitest run",
278
+ "test:ui": "vitest --ui"
279
+ }
280
+ ```
281
+
282
+ `msw` 还需要初始化 worker(用于 dev 环境)或 server(node):
283
+
284
+ ```bash
285
+ pnpm exec msw init public/ --save # 浏览器 worker
286
+ ```
287
+
288
+ ---
289
+
290
+ ## §7 · 何时跑测试
291
+
292
+ - **本地开发**:`pnpm test`(watch 模式),改文件即跑相邻 test
293
+ - **commit 前**:pre-commit hook 跑改动文件的 test(可选,慢就关)
294
+ - **CI**:`pnpm test:run`(必须,跑全量)
295
+ - **PR**:CI 红 → 不合并
296
+
297
+ ---
298
+
299
+ ## AI 必须输出的测试日志
300
+
301
+ 新增 / 修改业务代码时,AI 必须在响应里说明测试覆盖情况:
302
+
303
+ ```
304
+ ## 测试覆盖
305
+
306
+ - 新增 src/utils/calcDiscount.ts → 同目录 calcDiscount.test.ts(5 个 case)
307
+ - 新增 src/services/order.ts:createOrder → order.test.ts(成功 / 422 字段错 / 500)
308
+ - 新增 src/hooks/useCreateOrder.ts → useCreateOrder.test.tsx(success / error)
309
+ - 复用 src/components/OrderStatusBadge.tsx → 已有 test 覆盖,无需补
310
+ - 跳过测: src/pages/orders/new/index.tsx(纯组合,关键逻辑已在 hook / form 层测)
311
+ ```
312
+
313
+ 如果跳过测试,**必须**写明原因(纯组合 / ui 包已测 / 一次性脚本)。