Explorar o código

✨ feat(admin): 增强路线管理功能与测试支持

- 在路线列表页添加"关联活动"列,显示路线关联的活动名称
- 为表单元素添加data-testid属性,提升测试稳定性
- 优化测试代码,使用data-testid替代label选择器

⚡️ perf(deps): 更新happy-dom至20.0.2版本

- 提升测试环境中的DOM模拟性能
- 修复测试环境中Pointer Events API缺失问题

✅ test(admin): 改进路线管理页面测试

- 优化车型筛选测试逻辑,添加必要的API模拟
- 使用data-testid增强测试选择器稳定性
- 完善表单字段验证测试用例
yourname hai 4 meses
pai
achega
43c9b2b9b2

+ 1 - 1
package.json

@@ -139,7 +139,7 @@
     "eslint-plugin-react": "^7.37.5",
     "eslint-plugin-react-hooks": "^5.2.0",
     "globals": "^16.4.0",
-    "happy-dom": "^18.0.1",
+    "happy-dom": "^20.0.2",
     "tailwindcss": "^4.1.11",
     "tsx": "^4.20.3",
     "typescript": "~5.8.3",

+ 11 - 11
pnpm-lock.yaml

@@ -284,7 +284,7 @@ importers:
         version: 3.11.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))
       '@vitest/coverage-v8':
         specifier: ^3.2.4
-        version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))
+        version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@20.0.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))
       concurrently:
         specifier: ^9.2.0
         version: 9.2.0
@@ -304,8 +304,8 @@ importers:
         specifier: ^16.4.0
         version: 16.4.0
       happy-dom:
-        specifier: ^18.0.1
-        version: 18.0.1
+        specifier: ^20.0.2
+        version: 20.0.2
       tailwindcss:
         specifier: ^4.1.11
         version: 4.1.11
@@ -326,7 +326,7 @@ importers:
         version: 0.0.2(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))
       vitest:
         specifier: ^3.2.4
-        version: 3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)
+        version: 3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@20.0.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)
 
 packages:
 
@@ -2568,8 +2568,8 @@ packages:
   graphemer@1.4.0:
     resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
 
-  happy-dom@18.0.1:
-    resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==}
+  happy-dom@20.0.2:
+    resolution: {integrity: sha512-pYOyu624+6HDbY+qkjILpQGnpvZOusItCk+rvF5/V+6NkcgTKnbOldpIy22tBnxoaLtlM9nXgoqAcW29/B7CIw==}
     engines: {node: '>=20.0.0'}
 
   has-bigints@1.1.0:
@@ -5450,7 +5450,7 @@ snapshots:
     transitivePeerDependencies:
       - '@swc/helpers'
 
-  '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))':
+  '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@20.0.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0))':
     dependencies:
       '@ampproject/remapping': 2.3.0
       '@bcoe/v8-coverage': 1.0.2
@@ -5465,7 +5465,7 @@ snapshots:
       std-env: 3.10.0
       test-exclude: 7.0.1
       tinyrainbow: 2.0.0
-      vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)
+      vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@20.0.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0)
     transitivePeerDependencies:
       - supports-color
 
@@ -6370,7 +6370,7 @@ snapshots:
 
   graphemer@1.4.0: {}
 
-  happy-dom@18.0.1:
+  happy-dom@20.0.2:
     dependencies:
       '@types/node': 20.19.21
       '@types/whatwg-mimetype': 3.0.2
@@ -7776,7 +7776,7 @@ snapshots:
       tsx: 4.20.3
       yaml: 2.8.0
 
-  vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0):
+  vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(happy-dom@20.0.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)(yaml@2.8.0):
     dependencies:
       '@types/chai': 5.2.2
       '@vitest/expect': 3.2.4
@@ -7804,7 +7804,7 @@ snapshots:
     optionalDependencies:
       '@types/debug': 4.1.12
       '@types/node': 24.1.0
-      happy-dom: 18.0.1
+      happy-dom: 20.0.2
     transitivePeerDependencies:
       - jiti
       - less

+ 3 - 0
src/client/admin/components/ActivitySelect.tsx

@@ -27,6 +27,7 @@ interface ActivitySelectProps {
   placeholder?: string
   className?: string
   disabled?: boolean
+  'data-testid'?: string
 }
 
 export function ActivitySelect({
@@ -35,6 +36,7 @@ export function ActivitySelect({
   placeholder = "请选择活动...",
   className,
   disabled = false,
+  "data-testid": dataTestId,
 }: ActivitySelectProps) {
   const [searchKeyword, setSearchKeyword] = React.useState("")
 
@@ -86,6 +88,7 @@ export function ActivitySelect({
       emptyMessage="未找到匹配的活动"
       className={className}
       disabled={disabled || isLoading}
+      data-testid={dataTestId}
     />
   )
 }

+ 10 - 0
src/client/admin/components/RouteForm.tsx

@@ -88,6 +88,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                 <FormControl>
                   <Input
                     placeholder="请输入路线名称"
+                    data-testid="route-name-input"
                     {...field}
                   />
                 </FormControl>
@@ -179,6 +180,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                     <Input
                       placeholder="请输入出发地"
                       className="pl-8"
+                      data-testid="start-point-input"
                       {...field}
                     />
                   </div>
@@ -204,6 +206,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                     <Input
                       placeholder="请输入目的地"
                       className="pl-8"
+                      data-testid="end-point-input"
                       {...field}
                     />
                   </div>
@@ -231,6 +234,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                     <Input
                       placeholder="请输入上车点"
                       className="pl-8"
+                      data-testid="pickup-point-input"
                       {...field}
                     />
                   </div>
@@ -256,6 +260,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                     <Input
                       placeholder="请输入下车点"
                       className="pl-8"
+                      data-testid="dropoff-point-input"
                       {...field}
                     />
                   </div>
@@ -280,6 +285,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                 <FormControl>
                   <Input
                     type="datetime-local"
+                    data-testid="departure-time-input"
                     {...field}
                   />
                 </FormControl>
@@ -307,6 +313,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                       min="0"
                       placeholder="0.00"
                       className="pl-8"
+                      data-testid="price-input"
                       {...field}
                       onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
                     />
@@ -338,6 +345,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                       max="1000"
                       placeholder="1"
                       className="pl-8"
+                      data-testid="seat-count-input"
                       {...field}
                       onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
                     />
@@ -367,6 +375,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                       max="1000"
                       placeholder="0"
                       className="pl-8"
+                      data-testid="available-seats-input"
                       {...field}
                       onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
                     />
@@ -393,6 +402,7 @@ export const RouteForm: React.FC<RouteFormProps> = ({
                   value={field.value}
                   onValueChange={field.onChange}
                   placeholder="请选择关联活动"
+                  data-testid="activity-select"
                 />
               </FormControl>
               <FormDescription>

+ 11 - 1
src/client/admin/pages/Routes.tsx

@@ -302,6 +302,7 @@ export const RoutesPage: React.FC = () => {
                   <TableHead>价格</TableHead>
                   <TableHead>可用座位</TableHead>
                   <TableHead>出发时间</TableHead>
+                  <TableHead>关联活动</TableHead>
                   <TableHead>状态</TableHead>
                   <TableHead className="text-right">操作</TableHead>
                 </TableRow>
@@ -309,7 +310,7 @@ export const RoutesPage: React.FC = () => {
               <TableBody>
                 {isLoading ? (
                   <TableRow>
-                    <TableCell colSpan={9} className="text-center py-4">
+                    <TableCell colSpan={10} className="text-center py-4">
                       加载中...
                     </TableCell>
                   </TableRow>
@@ -346,6 +347,15 @@ export const RoutesPage: React.FC = () => {
                       <TableCell>
                         {new Date(route.departureTime).toLocaleString('zh-CN')}
                       </TableCell>
+                      <TableCell>
+                        {route.activity ? (
+                          <span className="text-sm text-muted-foreground">
+                            {route.activity.name}
+                          </span>
+                        ) : (
+                          <span className="text-sm text-gray-400">未关联</span>
+                        )}
+                      </TableCell>
                       <TableCell>
                         <span className={`px-2 py-1 rounded-full text-xs ${
                           route.isDisabled === 0

+ 3 - 0
src/client/components/ui/combobox.tsx

@@ -34,6 +34,7 @@ interface ComboboxProps {
   emptyMessage?: string
   className?: string
   disabled?: boolean
+  'data-testid'?: string
 }
 
 // 将 Command 逻辑封装为独立组件,确保 options 更新时重新渲染
@@ -101,6 +102,7 @@ export function Combobox({
   emptyMessage = "未找到匹配项",
   className,
   disabled = false,
+  "data-testid": dataTestId,
 }: ComboboxProps) {
   const [open, setOpen] = React.useState(false)
 
@@ -117,6 +119,7 @@ export function Combobox({
           aria-expanded={open}
           className={cn("w-full justify-between", className)}
           disabled={disabled}
+          data-testid={dataTestId}
         >
           {value
             ? options.find((option) => option.value === value)?.label

+ 25 - 14
tests/integration/client/admin/routes.test.tsx

@@ -331,18 +331,17 @@ describe('RoutesPage 集成测试', () => {
     // 验证模态框显示
     expect(screen.getByRole('heading', { name: '创建路线' })).toBeInTheDocument();
 
-    // 验证表单字段存在
-    expect(screen.getByLabelText(/路线名称/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/出发地/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/目的地/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/上车点/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/下车点/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/出发时间/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/车型/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/价格/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/座位数/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/可用座位数/i)).toBeInTheDocument();
-    expect(screen.getByLabelText(/关联活动/i)).toBeInTheDocument();
+    // 验证表单字段存在 - 使用data-testid
+    expect(screen.getByTestId('route-name-input')).toBeInTheDocument();
+    expect(screen.getByTestId('start-point-input')).toBeInTheDocument();
+    expect(screen.getByTestId('end-point-input')).toBeInTheDocument();
+    expect(screen.getByTestId('pickup-point-input')).toBeInTheDocument();
+    expect(screen.getByTestId('dropoff-point-input')).toBeInTheDocument();
+    expect(screen.getByTestId('departure-time-input')).toBeInTheDocument();
+    expect(screen.getByTestId('price-input')).toBeInTheDocument();
+    expect(screen.getByTestId('seat-count-input')).toBeInTheDocument();
+    expect(screen.getByTestId('available-seats-input')).toBeInTheDocument();
+    expect(screen.getByTestId('activity-select')).toBeInTheDocument();
   });
 
   it('应该处理启用/禁用路线操作', async () => {
@@ -403,6 +402,15 @@ describe('RoutesPage 集成测试', () => {
 
   it('应该处理车型筛选', async () => {
     const user = userEvent.setup();
+
+    // 在测试前模拟缺失的 Pointer Events API
+    if (!Element.prototype.hasPointerCapture) {
+      Element.prototype.hasPointerCapture = vi.fn(() => false);
+    }
+    if (!Element.prototype.releasePointerCapture) {
+      Element.prototype.releasePointerCapture = vi.fn();
+    }
+
     render(
       <TestWrapper>
         <RoutesPage />
@@ -413,10 +421,13 @@ describe('RoutesPage 集成测试', () => {
       expect(screen.getByText('北京到上海路线')).toBeInTheDocument();
     });
 
-    // 验证车型筛选器存在,但不直接点击避免事件错误
-    const vehicleTypeFilter = screen.getByRole('combobox');
+    // 验证车型筛选器存在 - 使用data-testid更精确
+    const vehicleTypeFilter = screen.getByTestId('route-vehicle-type-filter');
     expect(vehicleTypeFilter).toBeInTheDocument();
 
+    // 点击Select来展开选项
+    await user.click(vehicleTypeFilter);
+
     // 验证筛选选项存在
     expect(screen.getByText('大巴')).toBeInTheDocument();
     expect(screen.getByText('中巴')).toBeInTheDocument();