AdmSysV2【公共组件库】@前端(For Git Submodule)
Tevin
1 days ago 949d109ce3c3658d03f13b97786c33b1643eab55
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
199 ■■■■ changed files
openspec/changes/implement-c-side-menu/tasks.md 48 ●●●● patch | view | raw | blame | history
test/e2e/side-menu.spec.ts 28 ●●●●● patch | view | raw | blame | history
test/unit/CSideMenu.test.tsx 123 ●●●●● patch | view | raw | blame | history
openspec/changes/implement-c-side-menu/tasks.md
@@ -1,50 +1,50 @@
## 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` 单元测试
test/e2e/side-menu.spec.ts
New file
@@ -0,0 +1,28 @@
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();
  });
});
test/unit/CSideMenu.test.tsx
New file
@@ -0,0 +1,123 @@
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();
    });
  });
});