Explorar el Código

📝 docs(architecture): 添加API模拟规范到测试策略文档

- 添加完整的API模拟规范章节,支持跨UI包集成测试
- 统一模拟@d8d/shared-ui-components包中的rpcClient函数
- 提供跨UI包集成测试示例(收货地址+区域管理)
- 更新测试策略文档版本至2.9

🤖 Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname hace 1 mes
padre
commit
06cdd72e75

+ 343 - 0
docs/architecture/testing-strategy.md

@@ -3,6 +3,7 @@
 ## 版本信息
 | 版本 | 日期 | 描述 | 作者 |
 |------|------|------|------|
+| 2.9 | 2025-12-15 | 添加API模拟规范和前端组件测试策略 | James |
 | 2.8 | 2025-11-11 | 更新包测试结构,添加模块化包测试策略 | Winston |
 | 2.7 | 2025-11-09 | 更新为monorepo测试架构,清理重复测试文件 | James |
 | 2.6 | 2025-10-15 | 完成遗留测试文件迁移到统一的tests目录结构 | Winston |
@@ -206,6 +207,348 @@ const inactiveUser = createTestUser({ active: false });
 2. **数据库清理** (每个测试后)
 3. **测试数据隔离** (使用唯一标识符)
 
+## API模拟规范
+
+### 概述
+API模拟规范为管理后台UI包提供测试中的API模拟策略。虽然当前项目实践中每个UI包都有自己的客户端管理器,但为了简化测试复杂度、特别是跨UI包集成测试场景,规范要求统一模拟共享UI组件包中的`rpcClient`函数。
+
+### 问题背景
+当前实现中,每个UI包测试文件模拟自己的客户端管理器(如`AdvertisementClientManager`、`UserClientManager`)。这种模式在单一UI包测试时可行,但在**跨UI包集成测试**时存在严重问题:
+
+**示例场景**:收货地址UI包中使用区域管理UI包的区域选择组件,两个组件分别使用各自的客户端管理器。
+- 收货地址组件 → 使用`DeliveryAddressClientManager`
+- 区域选择组件 → 使用`AreaClientManager`
+- 测试时需要同时模拟两个客户端管理器,配置复杂且容易冲突
+
+**统一模拟优势**:通过模拟`@d8d/shared-ui-components`包中的`rpcClient`函数,可以:
+1. **统一控制**:所有API调用都经过同一个模拟点
+2. **简化配置**:无需关心具体客户端管理器,只需配置API响应
+3. **跨包支持**:天然支持多个UI包组件的集成测试
+4. **维护性**:API响应配置集中管理,易于更新
+
+### 现有模式分析(仅供参考)
+项目中的管理后台UI包当前遵循以下架构模式:
+1. **客户端管理器模式**:每个UI包都有一个客户端管理器类(如`AdvertisementClientManager`、`UserClientManager`)
+2. **rpcClient使用**:客户端管理器使用`@d8d/shared-ui-components`包中的`rpcClient`函数创建Hono RPC客户端
+3. **API结构**:生成的客户端使用Hono风格的方法调用(如`index.$get`、`index.$post`、`:id.$put`等)
+
+**注意**:新的测试规范要求直接模拟`rpcClient`函数,而不是模拟各个客户端管理器。
+
+### rpcClient函数分析
+`rpcClient`函数位于`@d8d/shared-ui-components`包的`src/utils/hc.ts`文件中,其核心功能是创建Hono RPC客户端:
+
+```typescript
+// packages/shared-ui-components/src/utils/hc.ts
+export const rpcClient = <T extends Hono<any, any, any>>(aptBaseUrl: string): ReturnType<typeof hc<T>> => {
+  return hc<T>(aptBaseUrl, {
+    fetch: axiosFetch
+  })
+}
+```
+
+该函数接收API基础URL参数,返回一个配置了axios适配器的Hono客户端实例。
+
+### 模拟策略
+
+#### 1. 统一模拟rpcClient函数
+在测试中,使用Vitest的`vi.mock`直接模拟`@d8d/shared-ui-components`包中的`rpcClient`函数,统一拦截所有API调用:
+
+```typescript
+// 测试文件顶部 - 统一模拟rpcClient函数
+import { vi } from 'vitest'
+import type { Hono } from 'hono'
+
+// 创建模拟的rpcClient函数
+const mockRpcClient = vi.fn((aptBaseUrl: string) => {
+  // 创建模拟的Hono客户端结构
+  const mockClient = {
+    // 支持动态路径访问
+    [Symbol.toPrimitive]: () => mockClient,
+
+    // 通用API端点模拟
+    index: {
+      $get: vi.fn(),
+      $post: vi.fn(),
+      $put: vi.fn(),
+      $delete: vi.fn(),
+    },
+    ':id': {
+      $get: vi.fn(),
+      $put: vi.fn(),
+      $delete: vi.fn(),
+    },
+
+    // 支持嵌套路径访问
+    $path: (path: string) => {
+      // 根据路径返回对应的模拟端点
+      const pathSegments = path.split('/').filter(Boolean)
+      let current = mockClient
+      for (const segment of pathSegments) {
+        if (!current[segment]) {
+          current[segment] = {
+            $get: vi.fn(),
+            $post: vi.fn(),
+            $put: vi.fn(),
+            $delete: vi.fn(),
+          }
+        }
+        current = current[segment]
+      }
+      return current
+    }
+  }
+
+  return mockClient
+})
+
+// 模拟共享UI组件包中的rpcClient函数
+vi.mock('@d8d/shared-ui-components/utils/hc', () => ({
+  rpcClient: mockRpcClient
+}))
+```
+
+#### 2. 创建模拟响应辅助函数
+创建通用的模拟响应辅助函数,用于生成一致的API响应格式:
+
+```typescript
+// 在测试文件中定义或从共享工具导入
+const createMockResponse = (status: number, data?: any) => ({
+  status,
+  ok: status >= 200 && status < 300,
+  body: null,
+  bodyUsed: false,
+  statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
+  headers: new Headers(),
+  url: '',
+  redirected: false,
+  type: 'basic' as ResponseType,
+  json: async () => data || {},
+  text: async () => '',
+  blob: async () => new Blob(),
+  arrayBuffer: async () => new ArrayBuffer(0),
+  formData: async () => new FormData(),
+  clone: function() { return this; }
+});
+
+// 创建简化版响应工厂(针对常见业务数据结构)
+const createMockApiResponse = <T>(data: T, success = true) => ({
+  success,
+  data,
+  timestamp: new Date().toISOString()
+})
+
+const createMockErrorResponse = (message: string, code = 'ERROR') => ({
+  success: false,
+  error: { code, message },
+  timestamp: new Date().toISOString()
+})
+```
+
+#### 3. 在测试用例中配置模拟响应
+在测试用例的`beforeEach`或具体测试中配置模拟响应,支持跨UI包集成:
+
+```typescript
+// 跨UI包集成测试示例:收货地址UI包(包含区域选择组件)
+describe('收货地址管理(跨UI包集成)', () => {
+  let mockClient: any;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    // 获取模拟的rpcClient实例
+    mockClient = mockRpcClient('/');
+
+    // 配置收货地址API响应(收货地址UI包)
+    mockClient.index.$get.mockResolvedValue(createMockResponse(200, {
+      data: [
+        {
+          id: 1,
+          name: '测试地址',
+          phone: '13800138000',
+          provinceId: 1,
+          cityId: 2,
+          districtId: 3,
+          detail: '测试街道'
+        }
+      ],
+      pagination: { total: 1, page: 1, pageSize: 10 }
+    }));
+
+    mockClient.index.$post.mockResolvedValue(createMockResponse(201, {
+      id: 2,
+      name: '新地址'
+    }));
+
+    mockClient[':id']['$put'].mockResolvedValue(createMockResponse(200));
+    mockClient[':id']['$delete'].mockResolvedValue(createMockResponse(204));
+
+    // 配置区域API响应(区域管理UI包 - 跨包支持)
+    mockClient.$path('api/areas').$get.mockResolvedValue(createMockResponse(200, {
+      data: [
+        { id: 1, name: '北京市', code: '110000', level: 1 },
+        { id: 2, name: '朝阳区', code: '110105', level: 2, parentId: 1 },
+        { id: 3, name: '海淀区', code: '110108', level: 2, parentId: 1 }
+      ]
+    }));
+
+    mockClient.$path('api/areas/provinces').$get.mockResolvedValue(createMockResponse(200, {
+      data: [
+        { id: 1, name: '北京市', code: '110000' },
+        { id: 2, name: '上海市', code: '310000' }
+      ]
+    }));
+
+    mockClient.$path('api/areas/:id/cities').$get.mockResolvedValue(createMockResponse(200, {
+      data: [
+        { id: 2, name: '朝阳区', code: '110105', parentId: 1 },
+        { id: 3, name: '海淀区', code: '110108', parentId: 1 }
+      ]
+    }));
+  });
+
+  it('应该显示收货地址列表并支持区域选择', async () => {
+    // 测试代码:验证收货地址UI和区域选择组件都能正常工作
+    // 所有API调用都通过统一的mockRpcClient模拟
+  });
+
+  it('应该处理API错误场景', async () => {
+    // 模拟API错误
+    mockClient.index.$get.mockRejectedValue(new Error('网络错误'));
+
+    // 测试错误处理
+  });
+});
+```
+
+### 管理后台UI包测试策略
+
+#### 1. 模拟范围
+- **统一模拟点**: 集中模拟`@d8d/shared-ui-components/utils/hc`中的`rpcClient`函数
+- **HTTP方法**: 支持Hono风格的`$get`、`$post`、`$put`、`$delete`方法
+- **API端点**: 支持标准端点(`index`)、参数化端点(`:id`)和嵌套路径(`$path()`)
+- **响应格式**: 模拟完整的Response对象,包含`status`、`ok`、`json()`等方法
+- **跨包支持**: 天然支持多个UI包组件的API模拟,无需分别模拟客户端管理器
+
+#### 2. 测试设置
+1. **统一模拟**: 在每个测试文件顶部使用`vi.mock`统一模拟`rpcClient`函数
+2. **测试隔离**: 每个测试用例使用独立的模拟实例,在`beforeEach`中重置
+3. **响应配置**: 根据测试场景配置不同的模拟响应(成功、失败、错误等)
+4. **错误测试**: 模拟各种错误场景(网络错误、验证错误、权限错误、服务器错误等)
+5. **跨包集成**: 支持配置多个UI包的API响应,适用于组件集成测试
+
+#### 3. 最佳实践
+- **统一模拟**: 所有API调用都通过模拟`rpcClient`函数统一拦截
+- **类型安全**: 使用TypeScript确保模拟响应与API类型兼容
+- **可维护性**: 保持模拟响应与实际API响应结构一致,便于后续更新
+- **文档化**: 在测试注释中说明模拟的API行为和预期结果
+- **响应工厂**: 创建可重用的模拟响应工厂函数,确保响应格式一致性
+- **跨包考虑**: 为集成的UI包组件配置相应的API响应
+
+### 验证和调试
+
+#### 1. 模拟验证
+```typescript
+// 验证API调用次数和参数 - 使用统一模拟的rpcClient
+describe('API调用验证(统一模拟)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('应该验证API调用次数和参数', async () => {
+    // 获取模拟的客户端实例
+    const mockClient = mockRpcClient('/');
+
+    // 配置模拟响应
+    mockClient.index.$get.mockResolvedValue(createMockResponse(200, { data: [] }));
+    mockClient.index.$post.mockResolvedValue(createMockResponse(201, { id: 1 }));
+    mockClient[':id']['$put'].mockResolvedValue(createMockResponse(200));
+    mockClient.$path('api/areas').$get.mockResolvedValue(createMockResponse(200, { data: [] }));
+
+    // 执行测试代码(触发API调用)...
+
+    // 验证API调用次数
+    expect(mockClient.index.$get).toHaveBeenCalledTimes(1);
+    expect(mockClient.$path('api/areas').$get).toHaveBeenCalledTimes(1);
+
+    // 验证API调用参数
+    expect(mockClient.index.$post).toHaveBeenCalledWith({
+      json: {
+        title: '新广告',
+        code: 'new-ad',
+        typeId: 1
+      }
+    });
+
+    // 验证带参数的API调用
+    expect(mockClient[':id']['$put']).toHaveBeenCalledWith({
+      param: { id: 1 },
+      json: {
+        title: '更新后的广告'
+      }
+    });
+
+    // 验证嵌套路径API调用
+    expect(mockClient.$path('api/areas').$get).toHaveBeenCalledWith({
+      query: { level: 1 }
+    });
+  });
+
+  it('应该验证错误场景', async () => {
+    const mockClient = mockRpcClient('/');
+
+    // 配置错误响应
+    mockClient.index.$get.mockRejectedValue(new Error('网络错误'));
+
+    // 执行测试代码...
+
+    // 验证错误调用
+    expect(mockClient.index.$get).toHaveBeenCalledTimes(1);
+  });
+});
+```
+
+#### 2. 调试技巧
+- **console.debug**: 在测试中使用`console.debug`输出模拟调用信息,便于调试
+  ```typescript
+  // 在测试中输出调试信息
+  console.debug('Mock client calls:', {
+    getCalls: mockClient.index.$get.mock.calls,
+    postCalls: mockClient.index.$post.mock.calls
+  });
+  ```
+
+- **调用检查**: 使用`vi.mocked()`检查模拟函数的调用参数和次数
+  ```typescript
+  // 检查mockRpcClient的调用
+  const mockCalls = vi.mocked(mockRpcClient).mock.calls;
+  console.debug('rpcClient调用参数:', mockCalls);
+
+  // 检查具体端点调用
+  const getCalls = vi.mocked(mockClient.index.$get).mock.calls;
+  ```
+
+- **响应验证**: 确保模拟响应的格式与实际API响应一致
+  ```typescript
+  // 验证响应格式
+  const response = await mockClient.index.$get();
+  expect(response.status).toBe(200);
+  expect(response.ok).toBe(true);
+  const data = await response.json();
+  expect(data).toHaveProperty('data');
+  expect(data).toHaveProperty('pagination');
+  ```
+
+- **错误模拟**: 测试各种错误场景,确保UI能正确处理
+  ```typescript
+  // 模拟不同类型的错误
+  mockClient.index.$get.mockRejectedValue(new Error('网络错误')); // 网络错误
+  mockClient.index.$get.mockResolvedValue(createMockResponse(500)); // 服务器错误
+  mockClient.index.$get.mockResolvedValue(createMockResponse(401)); // 认证错误
+  ```
+
+- **快照测试**: 使用Vitest的快照测试验证UI在不同API响应下的渲染结果
+- **跨包调试**: 在跨UI包集成测试中,验证所有相关API都正确配置了模拟响应
+
 ## 测试执行流程
 
 ### 本地开发测试

+ 48 - 1
docs/stories/006.012.goods-detail-spec-optimization.story.md

@@ -1,7 +1,7 @@
 # Story 006.012: 商品详情页规格选择流程优化
 
 ## Status
-Ready for Review
+In Progress (需要修复规格选择流程完整性问题)
 
 ## Story
 **As a** 商品购买用户,
@@ -47,11 +47,50 @@ Ready for Review
   - [x] 添加规格状态保持机制测试
   - [x] 测试向后兼容性(单规格商品流程不变)
   - [x] 运行现有测试套件,确保无回归问题
+- [ ] 任务7:修复规格选择流程完整性问题 (AC: 1, 2, 3, 6)
+  - [ ] 修改"加入购物车"和"立即购买"按钮的disabled逻辑,允许未选择规格时点击按钮(已部分完成)
+  - [ ] 修改handleAddToCart和handleBuyNow函数逻辑:每次点击按钮时,如果商品有多规格选项(hasSpecOptions为true),都弹出规格选择器
+  - [ ] 移除直接执行操作逻辑,将操作执行移到handleSpecConfirm函数中
+  - [ ] 确保规格选择器弹出时自动选中上次选择的规格和数量(currentSpec和currentQuantity props)
+  - [ ] 更新规格选择区域的文本提示,与流程保持一致
+  - [ ] 验证多规格商品点击按钮时总是弹出规格选择器,确认后直接执行操作的流程
+  - [ ] 测试单规格商品的按钮状态不受影响(不弹出规格选择器,直接执行操作)
+  - [ ] 更新相关测试以验证修复后的行为
+- [ ] 任务8:移除商品详情页规格信息显示 (AC: 5, 6)
+  - [ ] 分析商品详情页中根据selectedSpec更新显示的逻辑
+  - [ ] 修改价格显示,始终显示主商品价格,不根据选择的规格更新(已完成)
+  - [ ] 移除规格选择区域显示(包括已选择和未选择的规格信息)
+  - [ ] 移除操作按钮区域的规格信息提示
+  - [ ] 确保selectedSpec状态仍然保留,用于规格选择器自动选中上次选择
+  - [ ] 验证页面显示简洁,不干扰规格选择流程
+  - [ ] 测试单规格商品显示不受影响
+  - [ ] 更新相关测试以验证显示移除
 
 ## Dev Notes
 
 ### 先前故事洞察
 - **故事6(商品详情页规格选择集成)**:已成功集成GoodsSpecSelector组件到商品详情页,实现规格选择功能。当前页面包含独立的"选择规格"按钮(第443-460行),点击触发handleOpenSpecModal函数弹出规格选择器。规格选择状态通过selectedSpec和showSpecModal管理。"加入购物车"和"立即购买"按钮已支持规格选择,但需要用户先点击"选择规格"按钮选择规格,再点击操作按钮,流程为两步操作。本故事需要优化此流程,实现一键完成规格选择和购买操作。
+
+### 流程问题分析
+根据史诗006故事12的完整流程描述,正确的规格选择流程应该是:
+1. 用户点击"加入购物车"或"立即购买"按钮
+2. 系统判断:如果商品有多规格选项 → 弹出规格选择器
+3. 用户在规格选择器中选择规格和数量,点击确定
+4. 直接执行对应的购物车添加或购买操作
+5. 如果用户没有完成操作(如取消或返回),选择的规格状态保持在页面中
+6. **用户再次点击操作按钮 → 再次弹出规格选择器,自动选中上次选择的规格**
+7. 用户可以快速确认原有选择,或修改规格/数量后继续操作
+
+**当前实现的问题**:
+1. **按钮禁用逻辑冲突**:已修复,现在允许未选择规格时点击按钮
+2. **规格选择器弹出逻辑不完整**:当前实现中,一旦选择了规格(selectedSpec不为null),再次点击操作按钮时不会弹出规格选择器,而是直接执行操作,这违反了流程第6步的要求
+3. **流程目标不符**:故事目标是"一键完成规格选择和购物车/购买操作",但真正的含义是:点击按钮 → 弹出选择器 → 选择规格 → 执行操作。用户应该每次都有机会确认或修改规格选择,而不是选择一次后直接执行操作。
+
+**需要调整的流程**:
+- 每次点击"加入购物车"或"立即购买"按钮时,如果商品有多规格选项(hasSpecOptions为true),都应该弹出规格选择器
+- 规格选择器弹出时,自动选中上次选择的规格(如果已选择)和数量
+- 用户在规格选择器中点击确认后,直接执行对应的购物车添加或购买操作
+- 如果用户取消规格选择,不执行任何操作,但保持已选择的规格状态(便于下次快速确认)
 - **故事5(父子商品多规格选择组件开发)**:已实现GoodsSpecSelector组件,支持获取子商品列表作为规格选项,包含加载状态、错误处理和空状态显示。组件props包括parentGoodsId、visible、onClose、onConfirm、currentSpec、currentQuantity。本故事需要扩展此组件的onConfirm回调支持直接执行操作。
 - **故事9(父子商品名称关联查询优化)**:商品详情API不再返回spuName字段,使用parent对象获取父商品信息。规格选择应使用子商品的name字段作为规格名称。
 - [Source: docs/prd/epic-006-parent-child-goods-multi-spec-support.md#故事12]
@@ -157,6 +196,10 @@ Ready for Review
 ## Change Log
 | Date | Version | Description | Author |
 |------|---------|-------------|--------|
+| 2025-12-15 | 1.5 | 更新任务8为移除商品详情页规格信息显示,简化页面显示 | James (Developer) |
+| 2025-12-15 | 1.4 | 添加任务8优化商品详情页规格信息显示,简化页面显示逻辑 | James (Developer) |
+| 2025-12-15 | 1.3 | 根据史诗006故事12完整流程分析,更新任务7为修复规格选择流程完整性问题,添加流程问题分析 | James (Developer) |
+| 2025-12-15 | 1.2 | 添加任务7修复按钮禁用逻辑冲突,状态改为进行中 | James (Developer) |
 | 2025-12-15 | 1.1 | 故事状态更新为已批准 | James (Developer) |
 | 2025-12-15 | 1.0 | 初始故事创建 | Bob (Scrum Master) |
 
@@ -178,6 +221,10 @@ Claude Sonnet (claude-sonnet)
 7. 已更新商品详情页集成测试,验证新规格选择流程
 8. 注意:部分现有测试需要更新以适应新流程(8个测试因引用已移除的"选择规格"按钮而失败)
 9. 核心功能已实现并通过手动验证,建议在代码审查后更新剩余测试
+10. 发现按钮禁用逻辑冲突问题:当有多规格选项且未选择规格时,"加入购物车"和"立即购买"按钮被禁用,无法触发自动弹窗逻辑,与故事目标不符。已添加任务7进行修复
+11. 发现规格选择流程不完整问题:根据史诗006故事12完整流程分析,当前实现中一旦选择了规格,再次点击操作按钮时不会弹出规格选择器,而是直接执行操作,这违反了"用户再次点击操作按钮 → 再次弹出规格选择器,自动选中上次选择的规格"的流程要求。已更新任务7为修复规格选择流程完整性问题,需要修改handleAddToCart和handleBuyNow函数逻辑,确保每次点击按钮时都弹出规格选择器(对于多规格商品)
+12. 发现商品详情页规格信息显示问题:当前实现中,选择了规格后,商品详情页会根据selectedSpec更新价格和规格信息显示。但根据流程设计,用户选择规格后直接执行操作,商品详情页应一直显示主商品信息,避免不必要的页面更新和复杂度。已添加任务8进行显示优化
+13. 决定完全移除规格选择区域和操作按钮区域的规格信息显示,因为规格选择器已提供完整信息,页面显示应保持简洁,避免冗余信息干扰用户。已更新任务8为"移除商品详情页规格信息显示"
 
 ### File List
 1. `mini/src/pages/goods-detail/index.tsx` - 主要修改:添加pendingAction状态,重构handleAddToCart和handleBuyNow函数添加自动弹窗逻辑,移除独立"选择规格"按钮,优化规格信息显示和价格显示