---
title: 测试用例
order: 4
category: pro
---

# PisellProcedure 测试用例文档

## 1. 测试概述

### 1.1 测试目标

- 验证组件的基础渲染功能
- 验证步骤控制逻辑
- 验证响应式布局
- 验证事件回调机制
- 验证确认弹窗功能
- 验证移动端适配
- 验证高度计算的稳定性（使用 requestAnimationFrame 和连续稳定性检查）
- 验证响应式类名的延迟应用（避免宽度抖动）
- 验证弹窗模式（modal）与页面模式（page）的差异行为

### 1.2 测试范围

- 单元测试：组件功能测试
- Hook 测试：自定义 Hook 功能测试（useSidebarHeight、useResponsiveClass）
- 快照测试：UI 结构测试
- 集成测试：子组件协作测试
- 性能测试：稳定性检查机制测试
- 模式测试：弹窗模式与页面模式行为差异测试
- Ref 方法测试：通过 ref 暴露的方法测试
- 垂直布局测试：纵向排列模式测试

## 2. 基础功能测试

### 2.1 组件渲染测试

#### 测试用例 1: 默认渲染

**描述**：组件应该能够正常渲染，并显示默认配置

**测试步骤**：

1. 渲染组件，不传递任何 props
2. 检查组件是否成功渲染
3. 检查是否显示头部、主体、底部区域

**预期结果**：

- 组件成功渲染
- 显示头部区域
- 显示主体区域（内容区 + 信息区）
- 显示底部区域

**测试代码**：

```
test('should render with default props', () => {
  const { container } = render(<PisellProcedure />);
  expect(container.querySelector('.pisell-procedure')).toBeInTheDocument();
  expect(container.querySelector('.pisell-procedure-header')).toBeInTheDocument();
  expect(container.querySelector('.pisell-procedure-body')).toBeInTheDocument();
  expect(container.querySelector('.pisell-procedure-footer')).toBeInTheDocument();
});
```

#### 测试用例 2: 自定义类名和样式

**描述**：应该能够接受自定义类名和样式

**测试步骤**：

1. 传递 className 和 style props
2. 检查元素是否应用了自定义类名和样式

**预期结果**：

- 容器元素包含自定义类名
- 容器元素应用了自定义样式

**测试代码**：

```
test('should apply custom className and style', () => {
  const { container } = render(
    <PisellProcedure
      className="custom-class"
      style={{ background: 'red' }}
    />
  );
  const element = container.querySelector('.pisell-procedure');
  expect(element).toHaveClass('custom-class');
  expect(element).toHaveStyle({ background: 'red' });
});
```

#### 测试用例 2.1: 数据属性

**描述**：应该正确应用低代码引擎的数据属性

**测试代码**：

```
test('should apply data attributes', () => {
  const { container } = render(
    <PisellProcedure __id="test-id" __designMode="design" />
  );
  const element = container.querySelector('.pisell-procedure');
  expect(element).toHaveAttribute('data-component', 'pisell-procedure');
  expect(element).toHaveAttribute('data-id', 'test-id');
});
```

#### 测试用例 3: 显示/隐藏区域

**描述**：应该能够通过 props 控制各区域的显示/隐藏

**测试步骤**：

1. 设置 showHeader={false}
2. 检查头部区域是否隐藏
3. 设置 showSidebar={false}
4. 检查信息区是否隐藏
5. 设置 showFooter={false}
6. 检查底部区域是否隐藏

**测试代码**：

```
test('should hide/show regions based on props', () => {
  const { rerender, container } = render(
    <PisellProcedure showHeader={false} />
  );
  expect(container.querySelector('.pisell-procedure-header')).not.toBeInTheDocument();

  rerender(<PisellProcedure showSidebar={false} />);
  expect(container.querySelector('.pisell-procedure-sidebar')).not.toBeInTheDocument();

  rerender(<PisellProcedure showFooter={false} />);
  expect(container.querySelector('.pisell-procedure-footer')).not.toBeInTheDocument();
});
```

## 3. 步骤控制测试

### 3.1 步骤初始化

#### 测试用例 4: 默认步骤

**描述**：组件应该默认显示第一步（索引 0）

**测试代码**：

```
test('should start at step 0 by default', () => {
  const { container } = render(
    <PisellProcedure
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }],
        },
      }}
    />
  );
  const steps = container.querySelector('.pisell-steps');
  expect(steps).toHaveAttribute('data-current', '0');
});
```

#### 测试用例 5: 自定义初始步骤

**描述**：应该能够通过 defaultCurrent 设置初始步骤

**测试代码**：

```
test('should start at custom step', () => {
  const { container } = render(
    <PisellProcedure
      defaultCurrent={1}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }],
        },
      }}
    />
  );
  const steps = container.querySelector('.pisell-steps');
  expect(steps).toHaveAttribute('data-current', '1');
});
```

### 3.2 步骤变化

#### 测试用例 6: 下一步操作

**描述**：点击带有 `data-action="next"` 的按钮应该触发下一步

**测试代码**：

```
test('should go to next step when clicking button with data-action="next"', () => {
  const onStepsChange = vi.fn();
  const { container } = render(
    <PisellProcedure
      defaultCurrent={0}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }],
        },
      }}
      footerProps={{
        actions: <Button data-action="next">下一步</Button>,
      }}
      onStepsChange={onStepsChange}
    />
  );

  const button = container.querySelector('[data-action="next"]');
  fireEvent.click(button!);

  expect(onStepsChange).toHaveBeenCalledWith({
    success: true,
    currentStep: 1,
    currentItem: expect.any(Object),
  });
});
```

#### 测试用例 6.1: 无 data-action 属性的按钮

**描述**：没有 `data-action` 属性的按钮不应该触发步骤变化

**测试代码**：

```
test('should not trigger step change when button has no data-action', () => {
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      defaultCurrent={0}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }],
        },
      }}
      footerProps={{
        actions: <Button>保存</Button>,
      }}
      onStepsChange={onStepsChange}
    />
  );

  const button = screen.getByText('保存');
  fireEvent.click(button);

  // 没有 data-action 属性的按钮不应该触发步骤变化
  expect(onStepsChange).not.toHaveBeenCalled();
});
```

#### 测试用例 7: 上一步操作

**描述**：点击有 `data-action="prev"` 的按钮应该触发上一步

**测试代码**：

```
test('should go to previous step when clicking prev button', () => {
  const onStepsChange = vi.fn();
  const { container } = render(
    <PisellProcedure
      defaultCurrent={1}
      footerProps={{
        actions: (
          <>
            <Button data-action="prev">上一步</Button>
            <Button data-action="next">下一步</Button>
          </>
        ),
      }}
      onStepsChange={onStepsChange}
    />
  );

  const prevButton = container.querySelector('[data-action="prev"]');
  fireEvent.click(prevButton!);

  expect(onStepsChange).toHaveBeenCalledWith({
    success: true,
    currentStep: 0,
    currentItem: expect.any(Object),
  });
});
```

#### 测试用例 8: 步骤边界检查

**描述**：在第一步不能继续返回，在最后一步不能继续前进

**测试代码**：

```
test('should not go beyond first step', () => {
  const onStepsChange = vi.fn();
  const { container } = render(
    <PisellProcedure
      defaultCurrent={0}
      footerProps={{
        actions: <Button data-action="prev">上一步</Button>,
      }}
      onStepsChange={onStepsChange}
    />
  );

  const button = container.querySelector('[data-action="prev"]');
  fireEvent.click(button!);

  expect(onStepsChange).not.toHaveBeenCalled();
});

test('should not go beyond last step', () => {
  const onStepsChange = vi.fn();
  const { container } = render(
    <PisellProcedure
      defaultCurrent={1}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }],
        },
      }}
      footerProps={{
        actions: <Button data-action="next">下一步</Button>,
      }}
      onStepsChange={onStepsChange}
    />
  );

  const button = container.querySelector('.pisell-procedure-footer button');
  fireEvent.click(button!);

  expect(onStepsChange).not.toHaveBeenCalled();
});
```

#### 测试用例 8.1: 跳转到指定步骤

**描述**：应该支持跳转到指定步骤

**测试代码**：

```
test('should go to specific step when goToStep is called', () => {
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      defaultCurrent={0}
      headerProps={{
        stepsProps: {
          items: [
            { title: '步骤1' },
            { title: '步骤2' },
            { title: '步骤3' },
          ],
        },
      }}
      onStepsChange={onStepsChange}
    />
  );

  // 通过点击步骤条来触发 goToStep（如果步骤条支持点击）
  // 这里主要测试步骤管理逻辑
  expect(onStepsChange).not.toHaveBeenCalled();
});
```

## 4. 事件回调测试

### 4.1 步骤事件

#### 测试用例 9: onPrev 事件拦截

**描述**：onPrev 返回 false 应该阻止步骤更新

**测试代码**：

```
test('should block prev step when onPrev returns false', () => {
  const onPrev = vi.fn().mockReturnValue(false);
  const onStepsChange = vi.fn();

  const { container } = render(
    <PisellProcedure
      defaultCurrent={1}
      footerProps={{
        actions: <Button data-action="prev">上一步</Button>,
      }}
      onPrev={onPrev}
      onStepsChange={onStepsChange}
    />
  );

  const button = container.querySelector('[data-action="prev"]');
  fireEvent.click(button!);

  expect(onPrev).toHaveBeenCalledWith(1);
  expect(onStepsChange).not.toHaveBeenCalled();
});
```

#### 测试用例 10: onNext 事件拦截

**描述**：onNext 返回 false 应该阻止步骤更新

**测试代码**：

```
test('should block next step when onNext returns false', () => {
  const onNext = vi.fn().mockReturnValue(false);
  const onStepsChange = vi.fn();

  const { container } = render(
    <PisellProcedure
      defaultCurrent={0}
      footerProps={{
        actions: <Button data-action="next">下一步</Button>,
      }}
      onNext={onNext}
      onStepsChange={onStepsChange}
    />
  );

  const button = container.querySelector('.pisell-procedure-footer button');
  fireEvent.click(button!);

  expect(onNext).toHaveBeenCalledWith(0);
  expect(onStepsChange).not.toHaveBeenCalled();
});
```

### 4.2 关闭事件

#### 测试用例 11: 关闭按钮点击

**描述**：点击关闭按钮应该触发 onClose 回调

**测试代码**：

```
test('should call onClose when clicking close button', () => {
  const onClose = vi.fn();
  const { container } = render(
    <PisellProcedure
      headerProps={{
        right: <Button className="close-btn">关闭</Button>,
      }}
      onClose={onClose}
    />
  );

  const closeButton = container.querySelector('.close-btn');
  fireEvent.click(closeButton!);

  expect(onClose).toHaveBeenCalled();
});
```

### 4.3 首步返回事件

#### 测试用例 12: 首步返回回调

**描述**：第一步显示返回按钮，点击应该触发 onFirstStepBack

**测试代码**：

```
test('should call onFirstStepBack when clicking back on first step', () => {
  const onFirstStepBack = vi.fn();
  const { container } = render(
    <PisellProcedure
      defaultCurrent={0}
      headerProps={{
        left: <Button className="back-btn">返回</Button>,
        showBackOnFirstStep: true,
      }}
      onFirstStepBack={onFirstStepBack}
    />
  );

  const backButton = container.querySelector('.back-btn');
  fireEvent.click(backButton!);

  expect(onFirstStepBack).toHaveBeenCalled();
});
```

#### 测试用例 13: 首步返回默认跳转

**描述**：第一步不传 onFirstStepBack 应该跳转首页

**测试代码**：

```
test('should redirect to home when no onFirstStepBack provided', () => {
  delete window.location;
  window.location = { href: '' } as any;

  const { container } = render(
    <PisellProcedure
      defaultCurrent={0}
      headerProps={{
        left: <Button className="back-btn">返回</Button>,
        showBackOnFirstStep: true,
      }}
    />
  );

  const backButton = container.querySelector('.back-btn');
  fireEvent.click(backButton!);

  expect(window.location.href).toBe('/');
});
```

## 5. 确认弹窗测试

### 5.1 返回确认弹窗

#### 测试用例 14: 显示返回确认弹窗

**描述**：启用返回确认弹窗，点击返回应该显示弹窗

**测试代码**：

```
test('should show back confirm modal', async () => {
  const { container } = render(
    <PisellProcedure
      defaultCurrent={1}
      headerProps={{
        left: <Button className="back-btn">返回</Button>,
        backConfirmModal: {
          enabled: true,
          title: '确认返回',
          describe: '确定要返回吗？',
        },
      }}
    />
  );

  const backButton = container.querySelector('.back-btn');
  fireEvent.click(backButton!);

  await waitFor(() => {
    expect(screen.getByText('确认返回')).toBeInTheDocument();
    expect(screen.getByText('确定要返回吗？')).toBeInTheDocument();
  });
});
```

#### 测试用例 15: 确认返回操作

**描述**：点击弹窗确认按钮应该触发返回

**测试代码**：

```
test('should go back when confirming in modal', async () => {
  const onStepsChange = vi.fn();
  const onBackConfirm = vi.fn();

  render(
    <PisellProcedure
      defaultCurrent={1}
      headerProps={{
        left: <Button className="back-btn">返回</Button>,
        backConfirmModal: {
          enabled: true,
        },
      }}
      onStepsChange={onStepsChange}
      onBackConfirm={onBackConfirm}
    />
  );

  // 点击返回按钮
  const backButton = screen.getByText('返回');
  fireEvent.click(backButton);

  // 等待弹窗出现并点击确认
  await waitFor(() => {
    const confirmButton = screen.getByText('清除');
    fireEvent.click(confirmButton);
  });

  expect(onBackConfirm).toHaveBeenCalledWith('confirm');
  expect(onStepsChange).toHaveBeenCalledWith({
    success: true,
    currentStep: 0,
    currentItem: expect.any(Object),
  });
});
```

#### 测试用例 16: 取消返回操作

**描述**：点击弹窗取消按钮应该只关闭弹窗，不返回

**测试代码**：

```
test('should close modal without going back when canceling', async () => {
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      defaultCurrent={1}
      headerProps={{
        left: <Button className="back-btn">返回</Button>,
        backConfirmModal: {
          enabled: true,
        },
      }}
      onStepsChange={onStepsChange}
    />
  );

  // 点击返回按钮
  const backButton = screen.getByText('返回');
  fireEvent.click(backButton);

  // 等待弹窗出现并点击保留
  await waitFor(() => {
    const cancelButton = screen.getByText('保留');
    fireEvent.click(cancelButton);
  });

  expect(onStepsChange).not.toHaveBeenCalled();
});
```

### 5.2 关闭确认弹窗

#### 测试用例 17: 显示关闭确认弹窗

**描述**：启用关闭确认弹窗，点击关闭应该显示弹窗

**测试代码**：

```
test('should show close confirm modal', async () => {
  render(
    <PisellProcedure
      headerProps={{
        right: <Button className="close-btn">关闭</Button>,
        closeConfirmModal: {
          enabled: true,
          title: '确认关闭',
          describe: '确定要关闭吗？',
        },
      }}
    />
  );

  const closeButton = screen.getByText('关闭');
  fireEvent.click(closeButton);

  await waitFor(() => {
    expect(screen.getByText('确认关闭')).toBeInTheDocument();
    expect(screen.getByText('确定要关闭吗？')).toBeInTheDocument();
  });
});
```

## 6. 底部操作区测试

### 6.1 按钮事件注入

#### 测试用例 18: data-action="next" 触发下一步

**描述**：带有 `data-action="next"` 属性的按钮应该触发下一步

**测试代码**：

```
test('should trigger next step for button with data-action="next"', () => {
  const onStepsChange = vi.fn();

  const { container } = render(
    <PisellProcedure
      defaultCurrent={0}
      footerProps={{
        actions: (
          <>
            <Button>保存</Button>
            <Button data-action="next">下一步</Button>
          </>
        ),
      }}
      onStepsChange={onStepsChange}
    />
  );

  const nextButton = container.querySelector('[data-action="next"]');
  fireEvent.click(nextButton!);

  expect(onStepsChange).toHaveBeenCalledWith({
    success: true,
    currentStep: 1,
    currentItem: expect.any(Object),
  });
});
```

#### 测试用例 19: data-action 属性使用

**描述**：通过 `data-action` 属性控制按钮行为

**测试代码**：

```
test('should use data-action attribute', () => {
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      defaultCurrent={1}
      footerProps={{
        actions: (
          <>
            <Button data-action="prev">上一步</Button>
            <Button>保存草稿</Button>
            <Button data-action="next">下一步</Button>
          </>
        ),
      }}
      onStepsChange={onStepsChange}
    />
  );

  // 点击上一步
  const prevButton = screen.getByText('上一步');
  fireEvent.click(prevButton);

  expect(onStepsChange).toHaveBeenLastCalledWith({
    success: true,
    currentStep: 0,
    currentItem: expect.any(Object),
  });

  // 点击保存草稿（没有 data-action，不应该触发步骤变化）
  const saveButton = screen.getByText('保存草稿');
  fireEvent.click(saveButton);
  // 保存按钮不应该触发步骤变化
  expect(onStepsChange).toHaveBeenCalledTimes(1);

  // 点击下一步
  const nextButton = screen.getByText('下一步');
  fireEvent.click(nextButton);

  expect(onStepsChange).toHaveBeenLastCalledWith({
    success: true,
    currentStep: 2,
    currentItem: expect.any(Object),
  });
});
```

### 6.2 溢出收纳

#### 测试用例 20: 按钮溢出收纳

**描述**：当宽度不足时，应该显示 More 按钮

**测试代码**：

```
test('should show More button when overflow', () => {
  // 模拟小宽度容器
  const { container } = render(
    <div style={{ width: '300px' }}>
      <PisellProcedure
        footerProps={{
          actions: (
            <>
              <Button>按钮1</Button>
              <Button>按钮2</Button>
              <Button>按钮3</Button>
              <Button>按钮4</Button>
              <Button>按钮5</Button>
            </>
          ),
        }}
      />
    </div>
  );

  // 检查是否有 More 按钮
  await waitFor(() => {
    expect(screen.getByText('More')).toBeInTheDocument();
  });
});
```

## 7. 弹窗模式测试

### 7.1 模式切换

#### 测试用例 21: 默认使用页面模式

**描述**：组件默认应该使用页面模式（mode='page'）

**测试代码**：

```
test('should use page mode by default', () => {
  const { container } = render(<PisellProcedure />);
  expect(container.querySelector('.pisell-procedure')).toBeInTheDocument();
});
```

#### 测试用例 22: 弹窗模式下不强制移动端底部悬浮

**描述**：弹窗模式下，即使在移动端（< 744px），也不应该强制使用 float-all 位置模式

**测试步骤**：

1. 模拟移动端（设置 window.innerWidth < 744px）
2. 渲染组件，设置 mode='modal' 和 footerProps.position='float-sidebar'
3. 检查底部是否使用配置的位置模式而非 float-all

**预期结果**：

- 弹窗模式下使用配置的位置模式（float-sidebar）
- 页面模式下移动端强制使用 float-all

**测试代码**：

```
test('should not force float-all position on mobile in modal mode', () => {
  // 模拟移动端（宽度 < 744px）
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 600,
  });

  const { container } = render(
    <PisellProcedure
      mode="modal"
      footerProps={{
        position: 'float-sidebar',
      }}
    />
  );

  // 弹窗模式下，即使是移动端也应该使用配置的位置模式
  const element = container.querySelector('.pisell-procedure');
  expect(element).toHaveClass('pisell-procedure--footer-float-sidebar');
});
```

#### 测试用例 23: 弹窗模式下不隐藏信息区

**描述**：弹窗模式下，即使在移动端，信息区也不应该被隐藏

**测试步骤**：

1. 模拟移动端（设置 window.innerWidth < 744px）
2. 渲染组件，设置 mode='modal' 和 sidebarSlot
3. 检查信息区是否正常显示

**预期结果**：

- 弹窗模式下信息区始终显示
- 页面模式下移动端隐藏信息区

**测试代码**：

```
test('should not hide sidebar in modal mode on mobile', () => {
  // 模拟移动端（宽度 < 744px）
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 600,
  });

  const { container } = render(
    <PisellProcedure
      mode="modal"
      bodyProps={{
        sidebarSlot: <div data-testid="sidebar">信息区内容</div>,
      }}
    />
  );

  // 弹窗模式下，信息区应该显示（不会被媒体查询隐藏）
  expect(container.querySelector('.pisell-procedure-sidebar')).toBeInTheDocument();
});
```

#### 测试用例 24: 弹窗模式下不显示移动端总结按钮

**描述**：弹窗模式下，移动端总结按钮不应该显示

**测试步骤**：

1. 模拟移动端（设置 window.innerWidth < 744px）
2. 渲染组件，设置 mode='modal' 和 summaryButton
3. 检查总结按钮是否被隐藏

**预期结果**：

- 弹窗模式下总结按钮不显示
- 页面模式下移动端显示总结按钮

**测试代码**：

```
test('should not show summary button in modal mode', () => {
  // 模拟移动端（宽度 < 744px）
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 600,
  });

  const { container } = render(
    <PisellProcedure
      mode="modal"
      bodyProps={{
        sidebarSlot: <div>信息区内容</div>,
      }}
      footerProps={{
        summaryButton: <Button>摘要</Button>,
      }}
    />
  );

  // 弹窗模式下，移动端总结按钮不应该显示
  const summaryArea = container.querySelector('.pisell-procedure-footer__summary');
  expect(summaryArea).not.toBeInTheDocument();
});
```

#### 测试用例 25: 弹窗模式下执行响应式类名计算

**描述**：弹窗模式下，应该执行 useResponsiveClass 来计算响应式类名（因为弹窗没有媒体查询支持）

**测试步骤**：

1. 创建容器元素，设置宽度在响应式断点范围内
2. 渲染组件，设置 mode='modal' 和 sidebarContainerSelector
3. 检查响应式类名是否正确应用

**预期结果**：

- 弹窗模式下根据容器宽度应用响应式类名
- 页面模式下不执行响应式类名计算（依赖媒体查询）

**测试代码**：

```
test('should apply responsive class in modal mode', async () => {
  // 创建容器元素
  const container = document.createElement('div');
  container.className = 'test-modal-container';
  container.style.width = '1000px'; // 在 960px-1133px 范围内
  document.body.appendChild(container);

  const { container: renderContainer } = render(
    <PisellProcedure
      mode="modal"
      bodyProps={{
        sidebarContainerSelector: '.test-modal-container',
        sidebarSlot: <div>信息区内容</div>,
      }}
    />
  );

  // 等待响应式类名应用
  await waitFor(() => {
    const sidebar = renderContainer.querySelector('.pisell-procedure-sidebar');
    // 弹窗模式下应该应用响应式类名
    expect(sidebar).toBeInTheDocument();
  }, { timeout: 2000 });

  // 清理
  document.body.removeChild(container);
});
```

## 8. 响应式测试

### 8.1 布局模式

#### 测试用例 26: 固定宽度模式

**描述**：默认应该使用固定宽度模式

**测试代码**：

```
test('should use fixed layout mode by default', () => {
  const { container } = render(<PisellProcedure />);
  const body = container.querySelector('.pisell-procedure-body');
  expect(body).toHaveClass('pisell-procedure-body--fixed');
});
```

#### 测试用例 27: 比例模式

**描述**：设置 layoutMode='ratio' 应该使用比例布局

**测试代码**：

```
test('should use ratio layout mode', () => {
  const { container } = render(
    <PisellProcedure
      bodyProps={{
        layoutMode: 'ratio',
        layoutRatio: '7:3',
      }}
    />
  );

  const body = container.querySelector('.pisell-procedure-body');
  expect(body).toHaveClass('pisell-procedure-body--ratio');
});
```

### 8.2 移动端适配

#### 测试用例 28: 移动端强制使用 float-all 位置模式（页面模式）

**描述**：页面模式下，< 744px 时应该强制使用 float-all 位置模式

**测试代码**：

```
test('should force float-all position on mobile', () => {
  // 模拟移动端（宽度 < 744px）
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 600,
  });

  const { container } = render(
    <PisellProcedure
      footerProps={{
        position: 'float-sidebar',
      }}
    />
  );

  const element = container.querySelector('.pisell-procedure');
  expect(element).toHaveClass('pisell-procedure--footer-float-all');
});
```

#### 测试用例 29: 桌面端使用自定义位置模式

**描述**：>= 744px 时应该使用自定义的位置模式

**测试代码**：

```
test('should use custom footer position on desktop', () => {
  // 模拟桌面端（宽度 >= 744px）
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 1024,
  });

  const { container } = render(
    <PisellProcedure
      footerProps={{
        position: 'fixed-bottom',
      }}
    />
  );

  const element = container.querySelector('.pisell-procedure');
  expect(element).toHaveClass('pisell-procedure--footer-fixed-bottom');
});
```

#### 测试用例 30: 移动端总结弹窗打开（页面模式）

**描述**：点击总结按钮应该打开弹窗显示信息区内容

**测试代码**：

```
test('should open summary modal when clicking summary button on mobile', async () => {
  // 模拟移动端
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 600,
  });

  render(
    <PisellProcedure
      bodyProps={{
        sidebarSlot: <div>信息区内容</div>,
      }}
      footerProps={{
        summaryButton: <Button>摘要</Button>,
        summaryModalTitle: '订单摘要',
      }}
    />
  );

  const summaryButton = screen.getByText('摘要');
  fireEvent.click(summaryButton);

  await waitFor(() => {
    expect(screen.getByText('订单摘要')).toBeInTheDocument();
    expect(screen.getByText('信息区内容')).toBeInTheDocument();
  });
});
```

#### 测试用例 31: 移动端总结弹窗关闭

**描述**：点击关闭按钮应该关闭弹窗

**测试代码**：

```
test('should close summary modal when clicking close button', async () => {
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 600,
  });

  render(
    <PisellProcedure
      bodyProps={{
        sidebarSlot: <div>信息区内容</div>,
      }}
      footerProps={{
        summaryButton: <Button>摘要</Button>,
      }}
    />
  );

  const summaryButton = screen.getByText('摘要');
  fireEvent.click(summaryButton);

  await waitFor(() => {
    expect(screen.getByText('信息区内容')).toBeInTheDocument();
  });

  // 查找关闭按钮并点击
  const closeButton = screen.getByRole('button', { name: /close|关闭/i });
  if (closeButton) {
    fireEvent.click(closeButton);
    await waitFor(() => {
      expect(screen.queryByText('信息区内容')).not.toBeInTheDocument();
    });
  }
});
```

## 9. 插槽测试

### 9.1 头部插槽

#### 测试用例 32: 头部左侧插槽

**描述**：应该正确渲染头部左侧插槽内容

**测试代码**：

```
test('should render header left slot', () => {
  render(
    <PisellProcedure
      headerProps={{
        left: <div className="custom-left">自定义左侧</div>,
      }}
    />
  );

  expect(screen.getByText('自定义左侧')).toBeInTheDocument();
  expect(document.querySelector('.custom-left')).toBeInTheDocument();
});
```

#### 测试用例 33: 头部右侧插槽

**描述**：应该正确渲染头部右侧插槽内容

**测试代码**：

```
test('should render header right slot', () => {
  render(
    <PisellProcedure
      headerProps={{
        right: <div className="custom-right">自定义右侧</div>,
      }}
    />
  );

  expect(screen.getByText('自定义右侧')).toBeInTheDocument();
});
```

### 9.2 主体插槽

#### 测试用例 34: 内容区插槽

**描述**：应该正确渲染内容区插槽内容

**测试代码**：

```
test('should render content slot', () => {
  render(
    <PisellProcedure
      bodyProps={{
        contentSlot: <div className="custom-content">自定义内容</div>,
      }}
    />
  );

  expect(screen.getByText('自定义内容')).toBeInTheDocument();
});
```

#### 测试用例 35: 信息区插槽

**描述**：应该正确渲染信息区插槽内容

**测试代码**：

```
test('should render sidebar slot', () => {
  render(
    <PisellProcedure
      bodyProps={{
        sidebarSlot: <div className="custom-sidebar">自定义信息</div>,
      }}
    />
  );

  expect(screen.getByText('自定义信息')).toBeInTheDocument();
});
```

#### 测试用例 36: 信息区容器选择器

**描述**：应该支持通过容器选择器计算信息区高度

**测试步骤**：

1. 创建一个带有特定类名的容器元素
2. 传递 `sidebarContainerSelector` 属性
3. 检查信息区高度是否基于容器元素计算
4. 验证高度计算的稳定性（使用 requestAnimationFrame 和连续稳定性检查）

**预期结果**：

- 信息区高度基于容器元素计算
- 如果没有提供容器选择器，使用 window.innerHeight
- 高度计算使用稳定性检查机制，避免初始渲染时的抖动
- 响应式类名延迟应用，避免宽度抖动

**测试代码**：

```
test('should calculate sidebar height based on container selector', async () => {
  // 创建容器元素
  const container = document.createElement('div');
  container.className = 'test-modal-container';
  container.style.height = '600px';
  container.style.width = '800px';
  document.body.appendChild(container);

  const { container: renderContainer } = render(
    <PisellProcedure
      bodyProps={{
        sidebarContainerSelector: '.test-modal-container',
        sidebarSlot: <div data-testid="sidebar">信息区内容</div>,
      }}
    />
  );

  // 等待高度计算完成（使用稳定性检查机制，可能需要多次 requestAnimationFrame）
  await waitFor(() => {
    const sidebar = renderContainer.querySelector('.pisell-procedure-sidebar');
    expect(sidebar).toBeInTheDocument();
    expect(screen.getByTestId('sidebar')).toBeInTheDocument();
    // 高度应该基于容器高度计算，而不是 window.innerHeight
    // 使用稳定性检查，连续3次检查都稳定后才应用高度
  }, { timeout: 2000 });

  // 清理
  document.body.removeChild(container);
});

test('should use window.innerHeight when container selector not provided', () => {
  const { container } = render(
    <PisellProcedure
      bodyProps={{
        sidebarSlot: <div data-testid="sidebar">信息区内容</div>,
      }}
    />
  );

  const sidebar = container.querySelector('.pisell-procedure-sidebar');
  expect(sidebar).toBeInTheDocument();
  expect(screen.getByTestId('sidebar')).toBeInTheDocument();
  // 高度应该基于 window.innerHeight 计算
  // 使用稳定性检查机制，确保高度计算的准确性
});
```

#### 测试用例 37: 响应式类名延迟应用

**描述**：响应式类名应该延迟应用，避免宽度抖动

**测试步骤**：

1. 渲染组件，设置容器宽度
2. 检查响应式类名是否延迟应用（使用 requestAnimationFrame 和稳定性检查）
3. 验证类名只在宽度稳定后才应用

**预期结果**：

- 响应式类名使用稳定性检查机制
- 连续 3 次检查宽度都稳定（变化 < 5px）后才应用类名
- 避免宽度突然变化导致的抖动

**测试代码**：

```
test('should apply responsive class after width stabilizes', async () => {
  // 创建容器元素
  const container = document.createElement('div');
  container.className = 'test-modal-container';
  container.style.width = '1000px'; // 在 960px-1133px 范围内，应该应用 responsive-md 类
  document.body.appendChild(container);

  const { container: renderContainer } = render(
    <PisellProcedure
      bodyProps={{
        sidebarContainerSelector: '.test-modal-container',
        sidebarSlot: <div>信息区内容</div>,
      }}
    />
  );

  // 等待响应式类名应用（使用稳定性检查机制）
  await waitFor(() => {
    const sidebar = renderContainer.querySelector('.pisell-procedure-sidebar');
    // 响应式类名应该延迟应用，避免宽度抖动
    // 连续3次检查宽度都稳定后才应用类名
  }, { timeout: 2000 });

  // 清理
  document.body.removeChild(container);
});
```

### 9.3 底部插槽

#### 测试用例 38: 底部左侧插槽

**描述**：应该正确渲染底部左侧插槽内容

**测试代码**：

```
test('should render footer left slot', () => {
  render(
    <PisellProcedure
      footerProps={{
        leftSlot: <div className="custom-left">总价: ¥100</div>,
      }}
    />
  );

  expect(screen.getByText('总价: ¥100')).toBeInTheDocument();
});
```

#### 测试用例 39: 底部操作按钮

**描述**：应该正确渲染底部操作按钮

**测试代码**：

```
test('should render footer actions', () => {
  render(
    <PisellProcedure
      footerProps={{
        actions: (
          <>
            <Button>按钮1</Button>
            <Button>按钮2</Button>
          </>
        ),
      }}
    />
  );

  expect(screen.getByText('按钮1')).toBeInTheDocument();
  expect(screen.getByText('按钮2')).toBeInTheDocument();
});
```

## 10. 底部位置模式测试

### 10.1 不同位置模式

#### 测试用例 40: 固定底部模式

**描述**：position='fixed-bottom' 应该添加对应类名

**测试代码**：

```
test('should apply correct class for fixed-bottom position', () => {
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 1024,
  });

  const { container } = render(
    <PisellProcedure
      footerProps={{
        position: 'fixed-bottom',
      }}
    />
  );

  const element = container.querySelector('.pisell-procedure');
  expect(element).toHaveClass('pisell-procedure--footer-fixed-bottom');
});
```

#### 测试用例 41: 悬浮模式

**描述**：position='float-sidebar' 应该添加对应类名

**测试代码**：

```
test('should apply correct class for float-sidebar position', () => {
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 1024,
  });

  const { container } = render(
    <PisellProcedure
      footerProps={{
        position: 'float-sidebar',
      }}
    />
  );

  const element = container.querySelector('.pisell-procedure');
  expect(element).toHaveClass('pisell-procedure--footer-float-sidebar');
});
```

#### 测试用例 42: 全局底部显示

**描述**：position='float-all' 时应该显示全局底部

**测试代码**：

```
test('should show global footer when position is float-all', () => {
  const { container } = render(
    <PisellProcedure
      footerProps={{
        position: 'float-all',
      }}
    />
  );

  // 全局底部应该在主体外部
  const footer = container.querySelector('.pisell-procedure-footer');
  expect(footer).toBeInTheDocument();
});
```

## 11. 边界条件测试

### 11.1 空数据处理

#### 测试用例 43: 无步骤数据

**描述**：不传递步骤数据应该正常渲染

**测试代码**：

```
test('should render without steps data', () => {
  const { container } = render(<PisellProcedure />);
  expect(container.querySelector('.pisell-procedure')).toBeInTheDocument();
});
```

#### 测试用例 44: 空插槽

**描述**：不传递插槽内容应该正常渲染

**测试代码**：

```
test('should render with empty slots', () => {
  const { container } = render(
    <PisellProcedure
      headerProps={{}}
      bodyProps={{}}
      footerProps={{}}
    />
  );
  expect(container.querySelector('.pisell-procedure')).toBeInTheDocument();
});
```

### 11.2 异常输入处理

#### 测试用例 45: 负数步骤索引

**描述**：设置负数的 defaultCurrent 应该正常渲染

**测试代码**：

```
test('should handle negative defaultCurrent', () => {
  const { container } = render(
    <PisellProcedure
      defaultCurrent={-1}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }],
        },
      }}
    />
  );
  expect(container.querySelector('.pisell-procedure')).toBeInTheDocument();
});
```

#### 测试用例 46: 超出范围的步骤索引

**描述**：设置超出范围的 defaultCurrent 应该正常渲染

**测试代码**：

```
test('should handle large defaultCurrent', () => {
  const { container } = render(
    <PisellProcedure
      defaultCurrent={100}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }],
        },
      }}
    />
  );
  expect(container.querySelector('.pisell-procedure')).toBeInTheDocument();
});
```

## 12. 集成测试

### 12.1 完整流程测试

#### 测试用例 47: 完整步骤流程

**描述**：测试完整的多步骤流程

**测试代码**：

```
test('should complete full step flow', async () => {
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      defaultCurrent={0}
      headerProps={{
        stepsProps: {
          items: [
            { title: '步骤1' },
            { title: '步骤2' },
            { title: '步骤3' },
          ],
        },
      }}
      footerProps={{
        actions: (
          <>
            <Button data-action="prev">上一步</Button>
            <Button data-action="next">下一步</Button>
          </>
        ),
      }}
      onStepsChange={onStepsChange}
    />
  );

  // 点击下一步
  fireEvent.click(screen.getByText('下一步'));
  expect(onStepsChange).toHaveBeenLastCalledWith({
    success: true,
    currentStep: 1,
    currentItem: expect.any(Object),
  });

  // 再次点击下一步
  fireEvent.click(screen.getByText('下一步'));
  expect(onStepsChange).toHaveBeenLastCalledWith({
    success: true,
    currentStep: 2,
    currentItem: expect.any(Object),
  });

  // 点击上一步
  fireEvent.click(screen.getByText('上一步'));
  expect(onStepsChange).toHaveBeenLastCalledWith({
    success: true,
    currentStep: 1,
    currentItem: expect.any(Object),
  });
});
```

## 13. 布局顺序测试

### 13.1 主体区域内容顺序

#### 测试用例 48: 默认内容顺序（内容区在前）

**描述**：默认情况下，内容区应该在信息区之前（横向时内容区在左，纵向时内容区在上）

**测试步骤**：

1. 渲染组件，设置内容区和信息区
2. 检查 body 元素是否包含 `pisell-procedure-body--content-first` 类名
3. 检查 DOM 顺序：内容区应该在信息区之前

**预期结果**：

- body 元素包含 `pisell-procedure-body--content-first` 类名
- DOM 中内容区在信息区之前

**测试代码**：

```
test('should render content-first order by default', () => {
  const { container } = render(
    <PisellProcedure
      bodyProps={{
        contentSlot: <div data-testid="content">内容区</div>,
        sidebarSlot: <div data-testid="sidebar">信息区</div>,
      }}
    />
  );

  const body = container.querySelector('.pisell-procedure-body');
  expect(body).toHaveClass('pisell-procedure-body--content-first');

  // 检查 DOM 顺序：内容区应该在信息区之前
  const layout = container.querySelector('.pisell-lowcode-layout');
  const children = Array.from(layout?.children || []);
  const contentIndex = children.findIndex(child => child.querySelector('[data-testid="content"]'));
  const sidebarIndex = children.findIndex(child => child.querySelector('[data-testid="sidebar"]'));
  expect(contentIndex).toBeLessThan(sidebarIndex);
});
```

#### 测试用例 49: 信息区在前顺序

**描述**：当设置 `contentOrder='sidebar-first'` 时，信息区应该在内容区之前（横向时信息区在左，纵向时信息区在上）

**测试步骤**：

1. 渲染组件，设置 `contentOrder='sidebar-first'`
2. 检查 body 元素是否包含 `pisell-procedure-body--sidebar-first` 类名
3. 检查 DOM 顺序：信息区应该在内容区之前

**预期结果**：

- body 元素包含 `pisell-procedure-body--sidebar-first` 类名
- DOM 中信息区在内容区之前

**测试代码**：

```
test('should render sidebar-first order when configured', () => {
  const { container } = render(
    <PisellProcedure
      bodyProps={{
        contentOrder: 'sidebar-first',
        contentSlot: <div data-testid="content">内容区</div>,
        sidebarSlot: <div data-testid="sidebar">信息区</div>,
      }}
    />
  );

  const body = container.querySelector('.pisell-procedure-body');
  expect(body).toHaveClass('pisell-procedure-body--sidebar-first');

  // 检查 DOM 顺序：信息区应该在内容区之前
  const layout = container.querySelector('.pisell-lowcode-layout');
  const children = Array.from(layout?.children || []);
  const contentIndex = children.findIndex(child => child.querySelector('[data-testid="content"]'));
  const sidebarIndex = children.findIndex(child => child.querySelector('[data-testid="sidebar"]'));
  expect(sidebarIndex).toBeLessThan(contentIndex);
});
```

### 13.2 头部插槽顺序

#### 测试用例 50: 默认插槽顺序

**描述**：默认情况下，left 插槽在左侧，right 插槽在右侧

**测试步骤**：

1. 渲染组件，设置 left 和 right 插槽
2. 检查左右插槽是否正确显示
3. 检查 DOM 顺序：左侧应该在右侧之前

**预期结果**：

- 左右插槽都正确显示
- DOM 中左侧区域在右侧区域之前

**测试代码**：

```
test('should render header slots in default order', () => {
  const { container } = render(
    <PisellProcedure
      headerProps={{
        left: <div data-testid="left-slot">左侧</div>,
        right: <div data-testid="right-slot">右侧</div>,
      }}
    />
  );

  const header = container.querySelector('.pisell-procedure-header');
  const leftSlot = header?.querySelector('[data-testid="left-slot"]');
  const rightSlot = header?.querySelector('[data-testid="right-slot"]');

  expect(leftSlot).toBeInTheDocument();
  expect(rightSlot).toBeInTheDocument();

  // 检查 DOM 顺序：左侧应该在右侧之前
  const leftElement = header?.querySelector('.pisell-procedure-header__left');
  const rightElement = header?.querySelector('.pisell-procedure-header__right');
  expect(leftElement?.compareDocumentPosition(rightElement!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
```

#### 测试用例 51: 反转插槽顺序

**描述**：当设置 `slotOrder='reverse'` 时，left 插槽显示在右侧，right 插槽显示在左侧，且点击事件会自动适配

**测试步骤**：

1. 渲染组件，设置 `slotOrder='reverse'`
2. 检查插槽的实际显示位置是否反转
3. 检查点击事件：左侧应该触发关闭，右侧应该触发返回

**预期结果**：

- left prop 的内容显示在右侧
- right prop 的内容显示在左侧
- 点击左侧触发关闭事件
- 点击右侧触发返回事件

**测试代码**：

```
test('should render header slots in reverse order when configured', () => {
  const onClose = vi.fn();
  const onPrev = vi.fn();

  const { container } = render(
    <PisellProcedure
      defaultCurrent={1}
      headerProps={{
        slotOrder: 'reverse',
        left: <div data-testid="left-slot">左侧（实际显示在右）</div>,
        right: <div data-testid="right-slot">右侧（实际显示在左）</div>,
        stepsProps: {
          items: [
            { title: '步骤1' },
            { title: '步骤2' },
            { title: '步骤3' },
          ],
        },
      }}
      onClose={onClose}
      onPrev={onPrev}
    />
  );

  const header = container.querySelector('.pisell-procedure-header');
  const leftArea = header?.querySelector('.pisell-procedure-header__left');
  const rightArea = header?.querySelector('.pisell-procedure-header__right');

  // 检查实际显示位置：reverse 时，left prop 的内容应该在右侧显示
  expect(leftArea?.querySelector('[data-testid="left-slot"]')).not.toBeInTheDocument();
  expect(leftArea?.querySelector('[data-testid="right-slot"]')).toBeInTheDocument();
  expect(rightArea?.querySelector('[data-testid="left-slot"]')).toBeInTheDocument();
  expect(rightArea?.querySelector('[data-testid="right-slot"]')).not.toBeInTheDocument();

  // 检查点击事件：reverse 时，左侧应该触发关闭，右侧应该触发返回
  fireEvent.click(leftArea!);
  expect(onClose).toHaveBeenCalled();

  fireEvent.click(rightArea!);
  expect(onPrev).toHaveBeenCalled();
});
```

#### 测试用例 52: 反转顺序时关闭按钮在左侧

**描述**：当设置 `slotOrder='reverse'` 且只有 right 插槽（关闭按钮）时，关闭按钮应该显示在左侧

**测试步骤**：

1. 渲染组件，设置 `slotOrder='reverse'` 和 right 插槽
2. 检查关闭按钮是否显示在左侧
3. 点击左侧，检查是否触发关闭事件

**预期结果**：

- 关闭按钮显示在左侧
- 点击左侧触发关闭事件

**测试代码**：

```
test('should handle reverse slot order with close button on left', () => {
  const onClose = vi.fn();

  const { container } = render(
    <PisellProcedure
      defaultCurrent={0}
      headerProps={{
        slotOrder: 'reverse',
        right: <Button data-testid="close-btn">关闭</Button>,
        stepsProps: {
          items: [
            { title: '步骤1' },
            { title: '步骤2' },
          ],
        },
      }}
      onClose={onClose}
    />
  );

  const header = container.querySelector('.pisell-procedure-header');
  const leftArea = header?.querySelector('.pisell-procedure-header__left');

  // reverse 时，关闭按钮应该在左侧显示
  expect(leftArea?.querySelector('[data-testid="close-btn"]')).toBeInTheDocument();

  // 点击左侧应该触发关闭
  fireEvent.click(leftArea!);
  expect(onClose).toHaveBeenCalled();
});
```

## 14. 快照测试

### 14.1 基础快照

#### 测试用例 53: 默认配置快照

**描述**：记录默认配置的渲染结果

**测试代码**：

```
test('should match snapshot with default props', () => {
  const { container } = render(<PisellProcedure />);
  expect(container.firstChild).toMatchSnapshot();
});
```

#### 测试用例 54: 完整配置快照

**描述**：记录完整配置的渲染结果

**测试代码**：

```
test('should match snapshot with full props', () => {
  const { container } = render(
    <PisellProcedure
      defaultCurrent={0}
      headerProps={{
        left: <Button>返回</Button>,
        right: <Button>关闭</Button>,
        slotOrder: 'default',
        stepsProps: {
          items: [
            { title: '步骤1', description: '第一步' },
            { title: '步骤2', description: '第二步' },
          ],
        },
      }}
      bodyProps={{
        contentOrder: 'content-first',
        contentSlot: <div>内容</div>,
        sidebarSlot: <div>信息</div>,
      }}
      footerProps={{
        leftSlot: <div>总价: ¥100</div>,
        actions: (
          <>
            <Button data-action="prev">上一步</Button>
            <Button data-action="next">下一步</Button>
          </>
        ),
      }}
    />
  );
  expect(container.firstChild).toMatchSnapshot();
});
```

#### 测试用例 55: 反转布局顺序快照

**描述**：记录反转布局顺序的渲染结果

**测试代码**：

```
test('should match snapshot with reverse layout order', () => {
  const { container } = render(
    <PisellProcedure
      defaultCurrent={0}
      headerProps={{
        slotOrder: 'reverse',
        left: <Button>返回</Button>,
        right: <Button>关闭</Button>,
        stepsProps: {
          items: [
            { title: '步骤1', description: '第一步' },
            { title: '步骤2', description: '第二步' },
          ],
        },
      }}
      bodyProps={{
        contentOrder: 'sidebar-first',
        contentSlot: <div>内容</div>,
        sidebarSlot: <div>信息</div>,
      }}
    />
  );
  expect(container.firstChild).toMatchSnapshot();
});
```

#### 测试用例 70: 弹窗模式快照

**描述**：记录弹窗模式的渲染结果

**测试代码**：

```
test('should match snapshot with modal mode', () => {
  const { container } = render(
    <PisellProcedure
      mode="modal"
      defaultCurrent={0}
      headerProps={{
        stepsProps: {
          items: [
            { title: '步骤1' },
            { title: '步骤2' },
          ],
        },
      }}
      bodyProps={{
        contentSlot: <div>内容</div>,
        sidebarSlot: <div>信息</div>,
      }}
    />
  );
  expect(container.firstChild).toMatchSnapshot();
});
```

#### 测试用例 71: 垂直布局快照

**描述**：记录垂直布局的渲染结果

**测试代码**：

```
test('should match snapshot with vertical layout', () => {
  const { container } = render(
    <PisellProcedure
      bodyProps={{
        direction: 'vertical',
        contentSlot: <div>内容</div>,
        sidebarSlot: <div>信息</div>,
      }}
    />
  );
  expect(container.firstChild).toMatchSnapshot();
});
```

## 15. Ref 方法测试

### 15.1 Ref 暴露方法

#### 测试用例 56: Ref 方法暴露

**描述**：组件应该通过 ref 暴露指定的方法

**测试步骤**：

1. 创建 ref 并传递给组件
2. 检查 ref.current 是否包含所有暴露的方法

**预期结果**：

- ref.current 不为 null
- 包含 `goPrev`、`goNext`、`goToStep`、`getCurrentStep`、`openSummaryModal`、`closeSummaryModal` 方法

**测试代码**：

```
test('should expose ref methods', () => {
  const ref = createRef<PisellProcedureRef>();

  render(
    <PisellProcedure
      ref={ref}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }, { title: '步骤3' }],
        },
      }}
    />
  );

  expect(ref.current).not.toBeNull();
  expect(typeof ref.current?.goPrev).toBe('function');
  expect(typeof ref.current?.goNext).toBe('function');
  expect(typeof ref.current?.goToStep).toBe('function');
  expect(typeof ref.current?.getCurrentStep).toBe('function');
  expect(typeof ref.current?.openSummaryModal).toBe('function');
  expect(typeof ref.current?.closeSummaryModal).toBe('function');
});
```

#### 测试用例 57: 通过 Ref 获取当前步骤

**描述**：应该能够通过 ref 获取当前步骤索引

**测试代码**：

```
test('should get current step via ref', () => {
  const ref = createRef<PisellProcedureRef>();

  render(
    <PisellProcedure
      ref={ref}
      defaultCurrent={1}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }, { title: '步骤3' }],
        },
      }}
    />
  );

  expect(ref.current?.getCurrentStep()).toBe(1);
});
```

#### 测试用例 58: 通过 Ref 跳转下一步

**描述**：应该能够通过 ref 的 goNext 方法跳转到下一步

**测试代码**：

```
test('should go to next step via ref', () => {
  const ref = createRef<PisellProcedureRef>();
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      ref={ref}
      defaultCurrent={0}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }, { title: '步骤3' }],
        },
      }}
      onStepsChange={onStepsChange}
    />
  );

  act(() => {
    ref.current?.goNext();
  });

  expect(onStepsChange).toHaveBeenCalledWith({
    success: true,
    currentStep: 1,
    currentItem: expect.any(Object),
  });
  expect(ref.current?.getCurrentStep()).toBe(1);
});
```

#### 测试用例 59: 通过 Ref 跳转上一步

**描述**：应该能够通过 ref 的 goPrev 方法跳转到上一步

**测试代码**：

```
test('should go to previous step via ref', () => {
  const ref = createRef<PisellProcedureRef>();
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      ref={ref}
      defaultCurrent={2}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }, { title: '步骤3' }],
        },
      }}
      onStepsChange={onStepsChange}
    />
  );

  act(() => {
    ref.current?.goPrev();
  });

  expect(onStepsChange).toHaveBeenCalledWith({
    success: true,
    currentStep: 1,
    currentItem: expect.any(Object),
  });
  expect(ref.current?.getCurrentStep()).toBe(1);
});
```

#### 测试用例 60: 通过 Ref 跳转到指定步骤

**描述**：应该能够通过 ref 的 goToStep 方法跳转到指定步骤

**测试代码**：

```
test('should go to specific step via ref', () => {
  const ref = createRef<PisellProcedureRef>();
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      ref={ref}
      defaultCurrent={0}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }, { title: '步骤3' }],
        },
      }}
      onStepsChange={onStepsChange}
    />
  );

  act(() => {
    ref.current?.goToStep(2);
  });

  expect(onStepsChange).toHaveBeenCalledWith({
    success: true,
    currentStep: 2,
    currentItem: expect.any(Object),
  });
  expect(ref.current?.getCurrentStep()).toBe(2);
});
```

#### 测试用例 61: Ref 方法边界检查 - 不能超出第一步

**描述**：在第一步时调用 goPrev 不应该触发步骤变化

**测试代码**：

```
test('should not go beyond first step via ref', () => {
  const ref = createRef<PisellProcedureRef>();
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      ref={ref}
      defaultCurrent={0}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }],
        },
      }}
      onStepsChange={onStepsChange}
    />
  );

  act(() => {
    ref.current?.goPrev();
  });

  expect(onStepsChange).not.toHaveBeenCalled();
  expect(ref.current?.getCurrentStep()).toBe(0);
});
```

#### 测试用例 62: Ref 方法边界检查 - 不能超出最后一步

**描述**：在最后一步时调用 goNext 不应该触发步骤变化

**测试代码**：

```
test('should not go beyond last step via ref', () => {
  const ref = createRef<PisellProcedureRef>();
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      ref={ref}
      defaultCurrent={1}
      headerProps={{
        stepsProps: {
          items: [{ title: '步骤1' }, { title: '步骤2' }],
        },
      }}
      onStepsChange={onStepsChange}
    />
  );

  act(() => {
    ref.current?.goNext();
  });

  expect(onStepsChange).not.toHaveBeenCalled();
  expect(ref.current?.getCurrentStep()).toBe(1);
});
```

#### 测试用例 63: 通过 Ref 打开/关闭总结弹窗

**描述**：应该能够通过 ref 的 openSummaryModal 和 closeSummaryModal 方法控制弹窗

**测试代码**：

```
test('should open and close summary modal via ref', async () => {
  const ref = createRef<PisellProcedureRef>();

  // 模拟移动端
  Object.defineProperty(window, 'innerWidth', {
    writable: true,
    configurable: true,
    value: 600,
  });

  render(
    <PisellProcedure
      ref={ref}
      bodyProps={{
        sidebarSlot: <div data-testid="sidebar-content">信息区内容</div>,
      }}
      footerProps={{
        summaryButton: <Button>摘要</Button>,
        summaryModalTitle: '订单摘要',
      }}
    />
  );

  // 通过 ref 打开弹窗
  act(() => {
    ref.current?.openSummaryModal();
  });

  await waitFor(() => {
    expect(screen.getByText('订单摘要')).toBeInTheDocument();
  });

  // 通过 ref 关闭弹窗
  act(() => {
    ref.current?.closeSummaryModal();
  });

  await waitFor(() => {
    expect(screen.queryByText('订单摘要')).not.toBeInTheDocument();
  });
});
```

#### 测试用例 64: 连续调用 Ref 方法

**描述**：应该能够连续调用多个 ref 方法

**测试代码**：

```
test('should chain multiple step changes via ref', () => {
  const ref = createRef<PisellProcedureRef>();
  const onStepsChange = vi.fn();

  render(
    <PisellProcedure
      ref={ref}
      defaultCurrent={0}
      headerProps={{
        stepsProps: {
          items: [
            { title: '步骤1' },
            { title: '步骤2' },
            { title: '步骤3' },
            { title: '步骤4' },
          ],
        },
      }}
      onStepsChange={onStepsChange}
    />
  );

  // 连续操作
  act(() => {
    ref.current?.goNext(); // 0 -> 1
    ref.current?.goNext(); // 1 -> 2
    ref.current?.goPrev(); // 2 -> 1
    ref.current?.goToStep(3); // 1 -> 3
  });

  expect(ref.current?.getCurrentStep()).toBe(3);
  expect(onStepsChange).toHaveBeenCalledTimes(4);
});
```

## 16. Modal Mode CSS Class 测试

### 16.1 弹窗模式类名

#### 测试用例 65: 弹窗模式添加 modal 类名

**描述**：当 mode='modal' 时，应该添加 `pisell-procedure--modal` 类名

**测试代码**：

```
test('should add modal class when mode is modal', () => {
  const { container } = render(<PisellProcedure mode="modal" />);
  const element = container.querySelector('.pisell-procedure');
  expect(element).toHaveClass('pisell-procedure--modal');
});
```

#### 测试用例 66: 页面模式不添加 modal 类名

**描述**：当 mode='page' 时，不应该添加 `pisell-procedure--modal` 类名

**测试代码**：

```
test('should not add modal class when mode is page', () => {
  const { container } = render(<PisellProcedure mode="page" />);
  const element = container.querySelector('.pisell-procedure');
  expect(element).not.toHaveClass('pisell-procedure--modal');
});
```

#### 测试用例 67: 默认不添加 modal 类名

**描述**：不传 mode 时，默认不添加 `pisell-procedure--modal` 类名

**测试代码**：

```
test('should not add modal class by default', () => {
  const { container } = render(<PisellProcedure />);
  const element = container.querySelector('.pisell-procedure');
  expect(element).not.toHaveClass('pisell-procedure--modal');
});
```

## 17. 垂直布局测试

### 17.1 纵向排列模式

#### 测试用例 68: 垂直布局渲染

**描述**：当 direction='vertical' 时，应该添加对应的类名

**测试代码**：

```
test('should render vertical layout correctly', () => {
  const { container } = render(
    <PisellProcedure
      bodyProps={{
        direction: 'vertical',
        contentSlot: <div data-testid="content">内容区</div>,
        sidebarSlot: <div data-testid="sidebar">信息区</div>,
      }}
    />
  );

  const body = container.querySelector('.pisell-procedure-body');
  expect(body).toHaveClass('pisell-procedure-body--vertical');
});
```

#### 测试用例 69: 垂直布局下的内容顺序

**描述**：垂直布局下应该能够正确应用内容顺序

**测试代码**：

```
test('should handle content order in vertical layout', () => {
  const { container } = render(
    <PisellProcedure
      bodyProps={{
        direction: 'vertical',
        contentOrder: 'sidebar-first',
        contentSlot: <div data-testid="content">内容区</div>,
        sidebarSlot: <div data-testid="sidebar">信息区</div>,
      }}
    />
  );

  const body = container.querySelector('.pisell-procedure-body');
  expect(body).toHaveClass('pisell-procedure-body--sidebar-first');
});
```

## 18. 测试覆盖率目标

- 语句覆盖率：≥ 80%
- 分支覆盖率：≥ 75%
- 函数覆盖率：≥ 80%
- 行覆盖率：≥ 80%

## 19. 测试环境配置

```json
{
  "testEnvironment": "jsdom",
  "setupFilesAfterEnv": ["<rootDir>/jest.setup.js"],
  "moduleNameMapper": {
    "\\.(css|less)$": "identity-obj-proxy"
  }
}
```

## 20. 测试注意事项

1. 所有异步操作使用 `waitFor` 等待
2. 使用 `fireEvent` 模拟用户交互
3. 使用 `vi.fn()` 创建 mock 函数（Vitest）
4. 测试后清理副作用（如 window.location、window.innerWidth）
5. 使用 `data-testid` 便于定位元素（可选）
6. 移动端测试需要模拟 `window.innerWidth` 的变化
7. 使用 `Object.defineProperty` 来模拟 window 属性变化
8. Mock `useMobile` hook 以便控制移动端检测逻辑
9. **高度计算测试**：由于使用了 `requestAnimationFrame` 和连续稳定性检查（连续 3 次检查），测试时需要适当增加 `waitFor` 的 `timeout` 时间（建议 2000ms）
10. **响应式类名测试**：响应式类名延迟应用，需要等待稳定性检查完成，测试时也需要增加 `timeout` 时间
11. **稳定性检查机制**：
    - 高度计算：连续 3 次检查高度都稳定（变化 < 10px）后才应用
    - 响应式类名：连续 3 次检查宽度都稳定（变化 < 5px）后才应用
    - 使用 `requestAnimationFrame` 确保在浏览器重绘后检查
12. **容器选择器测试**：测试 `sidebarContainerSelector` 时，需要创建真实的 DOM 元素并设置尺寸，确保 `useElementSize` hook 能够正确获取尺寸
13. **弹窗模式测试**：测试 `mode='modal'` 时，需要验证以下行为差异：
    - 不应用移动端模式（即使在小屏幕下）
    - 不隐藏信息区
    - 不强制底部悬浮（float-all）
    - 不显示移动端总结按钮
    - 执行响应式类名计算（因为弹窗没有媒体查询支持）
14. **Ref 方法测试**：
    - 使用 `createRef<PisellProcedureRef>()` 创建 ref
    - 使用 `act()` 包裹 ref 方法调用，确保状态更新
    - 测试边界条件（第一步、最后一步）
    - 测试连续调用多个方法的场景
15. **垂直布局测试**：测试 `direction='vertical'` 时，验证：
    - 正确添加 `pisell-procedure-body--vertical` 类名
    - 内容顺序属性仍然有效
    - 信息区高度不进行计算（跳过 useSidebarHeight）

## 21. 代码结构说明

### 21.1 自定义 Hooks

组件使用了以下自定义 Hooks 来优化性能和稳定性：

1. **`useSidebarHeight`**：

   - 计算并管理 Sidebar 的高度
   - 使用 `requestAnimationFrame` 和连续稳定性检查
   - 确保在尺寸稳定后才应用高度，避免初始渲染时的抖动

2. **`useResponsiveClass`**：

   - 根据容器宽度计算响应式类名
   - 延迟应用类名，避免宽度抖动
   - 使用稳定性检查机制，连续 3 次检查宽度都稳定后才应用

3. **`useElementSize`**：
   - 计算指定元素的宽度和高度
   - 使用 `ResizeObserver` 和防抖机制优化性能
   - 支持阈值检查，减少不必要的更新

### 21.2 稳定性检查机制

为了确保初始渲染时的稳定性，组件实现了以下机制：

1. **高度计算稳定性**：

   - 等待所有尺寸稳定（header、footer、container）
   - 使用 `requestAnimationFrame` 在浏览器重绘后检查
   - 连续 3 次检查高度都稳定（变化 < 10px）后才应用

2. **响应式类名稳定性**：

   - 使用 `requestAnimationFrame` 在浏览器重绘后检查
   - 连续 3 次检查宽度都稳定（变化 < 5px）后才应用类名
   - 避免宽度突然变化导致的抖动

3. **性能优化**：
   - 使用 `useRef` 存储最新值，避免闭包问题
   - 使用阈值检查，避免微小变化导致的频繁更新
   - 使用防抖机制，减少不必要的计算

### 21.3 Ref 方法暴露

组件通过 `useImperativeHandle` 暴露以下方法供外部调用：

1. **`goPrev()`**：

   - 跳转到上一步
   - 返回 `StepChangeResult` 或 `null`（如果无法跳转）
   - 遵循 `onPrev` 回调的拦截逻辑

2. **`goNext()`**：

   - 跳转到下一步
   - 返回 `StepChangeResult` 或 `null`（如果无法跳转）
   - 遵循 `onNext` 回调的拦截逻辑

3. **`goToStep(step: number)`**：

   - 跳转到指定步骤
   - 返回 `StepChangeResult` 或 `null`（如果无法跳转）
   - 会进行边界检查

4. **`getCurrentStep()`**：

   - 获取当前步骤索引
   - 返回 `number`

5. **`openSummaryModal()`**：

   - 打开移动端总结弹窗
   - 无返回值

6. **`closeSummaryModal()`**：
   - 关闭移动端总结弹窗
   - 无返回值

### 21.4 弹窗模式 CSS 类名

为了支持弹窗模式下的样式隔离，组件会根据 `mode` 属性添加相应的 CSS 类名：

1. **`pisell-procedure--modal`**：

   - 当 `mode='modal'` 时添加
   - 用于在 CSS 中排除媒体查询的影响
   - 配合 `:not(.pisell-procedure--modal)` 选择器使用

2. **媒体查询隔离**：
   - 所有响应式媒体查询都添加了 `:not(.pisell-procedure--modal)` 条件
   - 弹窗模式下不会应用移动端适配样式
   - 弹窗模式下不会隐藏信息区
   - 弹窗模式下不会强制底部悬浮
