test: 添加 CSideMenu 组件测试
- 新增 test/e2e/side-menu.spec.ts 端到端测试
- 新增 test/unit/CSideMenu.test.tsx 单元测试
- 更新 implement-c-side-menu 任务清单进度
Co-Authored-By: ClaudeCode
1 files modified
2 files added
| | |
| | | ## 1. 项目初始化 |
| | | |
| | | - [ ] 1.1 创建 `src/framework/sideMenu/` 目录结构 |
| | | - [ ] 1.2 创建类型定义文件 `sideMenuTypes.ts`(MenuTree、MenuItem、CSideMenuProps) |
| | | - [ ] 1.3 创建组件文件:`CSideMenu.tsx`、`cSideMenu.scss` |
| | | - [ ] 1.4 创建 `src/index.ts` 统一导出入口 |
| | | - [x] 1.1 创建 `src/framework/sideMenu/` 目录结构 |
| | | - [x] 1.2 创建类型定义文件 `sideMenuTypes.ts`(MenuTree、MenuItem、CSideMenuProps) |
| | | - [x] 1.3 创建组件文件:`CSideMenu.tsx`、`cSideMenu.scss` |
| | | - [x] 1.4 创建 `src/index.ts` 统一导出入口 |
| | | |
| | | ## 2. example 基础结构 |
| | | |
| | | - [ ] 2.1 创建 `example/pages/side-menu/SideMenuPage.tsx` 组件示例页 |
| | | - [ ] 2.2 在 `example/App.tsx` 中添加组件列表入口 |
| | | - [x] 2.1 创建 `example/pages/side-menu/SideMenuPage.tsx` 组件示例页 |
| | | - [x] 2.2 在 `example/App.tsx` 中添加组件列表入口 |
| | | |
| | | ## 3. 核心组件实现 |
| | | |
| | | - [ ] 3.1 实现 `CSideMenu` 组件框架,定义 props 接口 |
| | | - [ ] 3.2 使用 antd Menu 的 `items` 属性实现三级菜单嵌套 |
| | | - [ ] 3.3 实现 `tree` 到 antd `items` 的转换函数 |
| | | - [ ] 3.4 处理 `type` 字段的图标映射(chart、setting 等,缺省为默认图标) |
| | | - [x] 3.1 实现 `CSideMenu` 组件框架,定义 props 接口 |
| | | - [x] 3.2 使用 antd Menu 的 `items` 属性实现三级菜单嵌套 |
| | | - [x] 3.3 实现 `tree` 到 antd `items` 的转换函数 |
| | | - [x] 3.4 处理 `type` 字段的图标映射(chart、setting 等,缺省为默认图标) |
| | | |
| | | ## 4. 手风琴行为 |
| | | |
| | | - [ ] 4.1 实现全局手风琴逻辑:在 `onOpenChange` 中用新 key 替换 `openKeys` |
| | | - [ ] 4.2 实现 `openKeys` 受控展开状态 |
| | | - [x] 4.1 实现全局手风琴逻辑:在 `onOpenChange` 中用新 key 替换 `openKeys` |
| | | - [x] 4.2 实现 `openKeys` 受控展开状态 |
| | | |
| | | ## 5. 响应式固定模式 |
| | | |
| | | - [ ] 5.1 添加 antd Layout.Sider,设置 `breakpoint="lg"` |
| | | - [ ] 5.2 实现移动端覆盖样式(CSS 媒体查询) |
| | | - [ ] 5.3 实现遮罩层及其点击关闭逻辑 |
| | | - [x] 5.1 添加 antd Layout.Sider,设置 `breakpoint="lg"` |
| | | - [x] 5.2 实现移动端覆盖样式(CSS 媒体查询) |
| | | - [x] 5.3 实现遮罩层及其点击关闭逻辑 |
| | | |
| | | ## 6. 交互与回调 |
| | | |
| | | - [ ] 6.1 实现叶子项点击 → `onClickMenuItem` 回调,传入完整菜单项对象 |
| | | - [ ] 6.2 移动端点击叶子时:先 `onSetMenuCollapse(true)`,300ms 延迟后再调用导航回调 |
| | | - [ ] 6.3 支持 `onSetMenuCollapse` 的 boolean 和无参两种调用模式 |
| | | - [x] 6.1 实现叶子项点击 → `onClickMenuItem` 回调,传入完整菜单项对象 |
| | | - [x] 6.2 移动端点击叶子时:先 `onSetMenuCollapse(true)`,300ms 延迟后再调用导航回调 |
| | | - [x] 6.3 支持 `onSetMenuCollapse` 的 boolean 和无参两种调用模式 |
| | | |
| | | ## 7. 选中与指示器 |
| | | |
| | | - [ ] 7.1 连接 `selectedKeys` 与 `curActivePaneKey` |
| | | - [ ] 7.2 SubMenu 下叶子被选中时应用子树选中样式 |
| | | - [ ] 7.3 在 `panesOnShelf` 键匹配时显示"已打开"指示器 |
| | | - [x] 7.1 连接 `selectedKeys` 与 `curActivePaneKey` |
| | | - [x] 7.2 SubMenu 下叶子被选中时应用子树选中样式 |
| | | - [x] 7.3 在 `panesOnShelf` 键匹配时显示"已打开"指示器 |
| | | |
| | | ## 8. 空状态与类型处理 |
| | | |
| | | - [ ] 8.1 空树时渲染标题和空区域,不报错 |
| | | - [ ] 8.2 支持 `id`/`key` 比较,包含数值类型键(string/number)的类型转换 |
| | | - [x] 8.1 空树时渲染标题和空区域,不报错 |
| | | - [x] 8.2 支持 `id`/`key` 比较,包含数值类型键(string/number)的类型转换 |
| | | |
| | | ## 9. 测试 |
| | | |
| | | - [ ] 9.1 创建 `test/unit/CSideMenu.test.tsx` 单元测试 |
| | | - [x] 9.1 创建 `test/unit/CSideMenu.test.tsx` 单元测试 |
| New file |
| | |
| | | import { test, expect } from '@playwright/test'; |
| | | |
| | | test.describe('CSideMenu 组件', () => { |
| | | test.beforeEach(async ({ page }) => { |
| | | await page.goto('http://localhost:5173/#/preview/pages/side-menu/SideMenuPage.tsx'); |
| | | }); |
| | | |
| | | test('页面加载正常', async ({ page }) => { |
| | | // 等待页面加载 |
| | | await page.waitForSelector('text=CSideMenu 组件示例', { timeout: 10000 }); |
| | | // 验证标题 |
| | | await expect(page.locator('text=CSideMenu 组件示例')).toBeVisible(); |
| | | }); |
| | | |
| | | test('左侧菜单显示正常', async ({ page }) => { |
| | | // 验证菜单标题 |
| | | await expect(page.locator('text=管理后台')).toBeVisible(); |
| | | // 验证菜单项 |
| | | await expect(page.locator('text=导航1')).toBeVisible(); |
| | | }); |
| | | |
| | | test('菜单点击功能正常', async ({ page }) => { |
| | | // 点击子菜单 |
| | | await page.click('text=子菜单1-1'); |
| | | // 验证页面1-1-1 显示 |
| | | await expect(page.locator('text=页面1-1-1')).toBeVisible(); |
| | | }); |
| | | }); |
| New file |
| | |
| | | import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| | | import { render, screen } from '@testing-library/react'; |
| | | import React from 'react'; |
| | | import { CSideMenu } from '../../src/framework/sideMenu/CSideMenu'; |
| | | |
| | | // Mock window.matchMedia |
| | | Object.defineProperty(window, 'matchMedia', { |
| | | writable: true, |
| | | value: vi.fn().mockImplementation((query: string) => ({ |
| | | matches: false, |
| | | media: query, |
| | | onchange: null, |
| | | addEventListener: vi.fn(), |
| | | removeEventListener: vi.fn(), |
| | | dispatchEvent: vi.fn(), |
| | | })), |
| | | }); |
| | | |
| | | // Mock antd Menu component |
| | | vi.mock('antd', async () => { |
| | | const actual = await vi.importActual('antd'); |
| | | return { |
| | | ...actual, |
| | | Menu: vi.fn(({ items, onClick, selectedKeys, openKeys, onOpenChange, children }) => { |
| | | // Render items as a nested structure for testing |
| | | const renderItems = (items: any[]): React.ReactNode => |
| | | items?.map((item) => ( |
| | | <div key={item.key} data-testid={`menu-item-${item.key}`}> |
| | | {item.label} |
| | | {item.children && <div className="submenu">{renderItems(item.children)}</div>} |
| | | </div> |
| | | )); |
| | | return <div data-testid="antd-menu">{items ? renderItems(items) : children}</div>; |
| | | }), |
| | | Layout: { |
| | | Sider: vi.fn(({ children, breakpoint, onBreakpointChange }) => children), |
| | | }, |
| | | }; |
| | | }); |
| | | |
| | | // Mock icons |
| | | vi.mock('@ant-design/icons', () => ({ |
| | | PieChartOutlined: () => <span data-testid="icon-chart">ChartIcon</span>, |
| | | SettingOutlined: () => <span data-testid="icon-setting">SettingIcon</span>, |
| | | FolderOutlined: () => <span data-testid="icon-folder">FolderIcon</span>, |
| | | FileOutlined: () => <span data-testid="icon-file">FileIcon</span>, |
| | | RightOutlined: () => <span data-testid="icon-right">RightIcon</span>, |
| | | })); |
| | | |
| | | describe('CSideMenu 组件', () => { |
| | | const mockOnClickMenuItem = vi.fn(); |
| | | const mockOnSetMenuCollapse = vi.fn(); |
| | | |
| | | const basicTree = { |
| | | key: '1', |
| | | label: '导航1', |
| | | children: [ |
| | | { |
| | | key: '1-1', |
| | | label: '子菜单1-1', |
| | | children: [ |
| | | { key: '1-1-1', label: '页面1-1-1', path: '/page1-1-1', pageName: 'Page111' }, |
| | | { key: '1-1-2', label: '页面1-1-2', path: '/page1-1-2', pageName: 'Page112' }, |
| | | ], |
| | | }, |
| | | { |
| | | key: '1-2', |
| | | label: '页面1-2', |
| | | path: '/page1-2', |
| | | pageName: 'Page12', |
| | | }, |
| | | ], |
| | | }; |
| | | |
| | | beforeEach(() => { |
| | | vi.clearAllMocks(); |
| | | }); |
| | | |
| | | describe('基础渲染', () => { |
| | | it('1.1 渲染空树时不报错', () => { |
| | | render( |
| | | <CSideMenu |
| | | title="测试标题" |
| | | tree={undefined as any} |
| | | collapsed={false} |
| | | onClickMenuItem={mockOnClickMenuItem} |
| | | onSetMenuCollapse={mockOnSetMenuCollapse} |
| | | /> |
| | | ); |
| | | expect(screen.getByText('测试标题')).toBeDefined(); |
| | | }); |
| | | |
| | | it('1.2 渲染标题', () => { |
| | | render( |
| | | <CSideMenu |
| | | title="我的侧边栏" |
| | | tree={basicTree} |
| | | collapsed={false} |
| | | onClickMenuItem={mockOnClickMenuItem} |
| | | onSetMenuCollapse={mockOnSetMenuCollapse} |
| | | /> |
| | | ); |
| | | expect(screen.getByText('我的侧边栏')).toBeDefined(); |
| | | }); |
| | | }); |
| | | |
| | | describe('三级菜单渲染', () => { |
| | | it('3.1 渲染三级菜单结构', () => { |
| | | render( |
| | | <CSideMenu |
| | | title="测试" |
| | | tree={basicTree} |
| | | collapsed={false} |
| | | onClickMenuItem={mockOnClickMenuItem} |
| | | onSetMenuCollapse={mockOnSetMenuCollapse} |
| | | /> |
| | | ); |
| | | expect(screen.getByText('导航1')).toBeDefined(); |
| | | expect(screen.getByText('子菜单1-1')).toBeDefined(); |
| | | expect(screen.getByText('页面1-1-1')).toBeDefined(); |
| | | }); |
| | | }); |
| | | }); |