Explorar o código

docs(epic-9): 创建 Story 9.1 - 照片上传功能完整测试

- 创建完整的 Story 9.1 文档,包含业务需求、验收标准和详细任务分解
- 分析 Epic 1-3 的前置成果(Select 工具、文件上传工具)
- 提供完整的测试用例模板(8 个测试场景)
- 包含 TypeScript + Playwright 陷阱警告
- 更新 Epic 9 状态为 in-progress
- 更新 Story 9-1 状态为 ready-for-dev

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 hai 1 semana
pai
achega
55e0bfac1b

+ 531 - 0
_bmad-output/implementation-artifacts/9-1-photo-upload-tests.md

@@ -0,0 +1,531 @@
+# Story 9.1: 照片上传功能完整测试
+
+Status: ready-for-dev
+
+<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
+
+## Story
+
+作为测试开发者,
+我想要编写照片上传功能的完整测试,
+以便验证照片上传的真实业务逻辑。
+
+## Acceptance Criteria
+
+**Given** 文件上传工具 (Epic 3, Story 3.1) 已完成
+**And** `uploadFileToField()` 函数已可用
+**When** 编写照片上传功能测试
+**Then** 包含以下测试场景:
+
+1. **单张照片上传**
+   - 上传身份证正面
+   - 上传身份证反面
+   - 上传残疾证照片
+   - 验证预览显示正确
+
+2. **多张照片上传**
+   - 同时上传身份证正反面
+   - 同时上传多张照片
+   - 验证所有照片都显示
+
+3. **照片格式支持**
+   - JPG 格式上传
+   - PNG 格式上传
+   - WEBP 格式上传
+
+4. **照片删除**
+   - 删除已上传的照片
+   - 验证删除后预览消失
+
+5. **照片大小限制**
+   - 验证超大文件的处理
+   - 验证不支持的格式
+
+## Tasks / Subtasks
+
+- [ ] **Task 1: 创建测试 fixtures 文件** (AC: #1, #3)
+  - [ ] Subtask 1.1: 在 `web/tests/fixtures/images/` 创建测试图片文件
+  - [ ] Subtask 1.2: 准备不同格式的测试图片 (JPG, PNG, WEBP)
+  - [ ] Subtask 1.3: 准备超大文件用于边界测试
+  - [ ] Subtask 1.4: 准备不支持的格式文件用于验证
+
+- [ ] **Task 2: 创建照片上传测试文件** (AC: #1, #2, #3, #4, #5)
+  - [ ] Subtask 2.1: 创建 `web/tests/e2e/specs/admin/disability-person-photo.spec.ts`
+  - [ ] Subtask 2.2: 编写单张照片上传测试
+  - [ ] Subtask 2.3: 编写多张照片上传测试
+  - [ ] Subtask 2.4: 编写照片格式支持测试
+  - [ ] Subtask 2.5: 编写照片删除测试
+  - [ ] Subtask 2.6: 编写照片大小限制测试
+
+- [ ] **Task 3: 更新 Page Object 使用新工具** (AC: #1, #2)
+  - [ ] Subtask 3.1: 更新 `DisabilityPersonManagementPage.uploadPhoto()` 使用 `uploadFileToField()`
+  - [ ] Subtask 3.2: 移除旧的 `evaluateHandle` + temp files 代码
+
+- [ ] **Task 4: 运行测试并验证通过** (AC: #1, #2, #3, #4, #5)
+  - [ ] Subtask 4.1: 使用 `pnpm test:e2e:chromium disability-person-photo` 运行测试
+  - [ ] Subtask 4.2: 修复发现的问题
+  - [ ] Subtask 4.3: 验证所有测试通过
+
+## Dev Notes
+
+### Epic 9 背景与目标
+
+**Epic 9: 残疾人管理完整 E2E 测试覆盖(含并行隔离)**
+
+为残疾人管理功能编写完整的、真正验证业务功能的 E2E 测试,并确保测试可以与未来的区域管理测试并行运行。
+
+**当前问题:**
+- 现有 `disability-person-complete.spec.ts` 只是组件验证,没有真正测试业务功能
+- 照片上传等业务逻辑缺乏完整的测试覆盖
+- 测试隔离性未验证,可能与区域管理测试冲突
+
+**Epic 9 的 Story 依赖关系:**
+- **Story 9.1(本故事)**:照片上传功能测试(优先级最高,因为 Epic 3 已完成文件上传工具)
+- Story 9.2:银行卡管理功能测试
+- Story 9.3:备注管理功能测试
+- Story 9.4:回访记录管理测试
+- Story 9.5:完整流程测试(CRUD)
+- Story 9.6:测试隔离与并行执行验证
+- Story 9.7:稳定性验证(10次连续运行)
+
+### 业务功能分析
+
+**照片上传功能概述:**
+
+残疾人管理表单包含以下照片类型:
+1. **身份证照片**:正面、反面(必需)
+2. **残疾证照片**:照片(必需)
+3. **个人照片**:可选
+
+**组件架构(从 Story 3.1 分析):**
+
+```
+PhotoUploadField (allin-packages/disability-person-management-ui)
+  └── FileSelector (packages/file-management-ui)
+      └── MinioUploader (packages/file-management-ui)
+          └── <input type="file" data-testid="photo-upload-{index}">
+```
+
+**关键发现(Story 3.1 ):**
+- `MinioUploader` 组件已添加 `testId` prop
+- `PhotoUploadField` 生成唯一的 `data-testid="photo-upload-{index}"`
+- 每个照片项都有独立的隐藏文件输入框
+- 可以直接使用 `uploadFileToField(page, '[data-testid="photo-upload-0"]', 'file.jpg')` 上传
+
+### 技术规范
+
+#### 可用工具函数
+
+从 `@d8d/e2e-test-utils` 导入(Epic 1-3 已完成):
+
+```typescript
+import { uploadFileToField } from '@d8d/e2e-test-utils';
+import { selectRadixOption } from '@d8d/e2e-test-utils';
+import { selectProvinceCity } from '@d8d/e2e-test-utils';
+
+// 文件上传工具 (Epic 3, Story 3.1)
+await uploadFileToField(
+  page,
+  selector,        // 例如: '[data-testid="photo-upload-0"]'
+  fileName,        // 相对于 fixtures 目录: 'id-card-front.jpg'
+  options?         // 可选配置
+);
+
+// Radix UI Select 工具 (Epic 1)
+await selectRadixOption(page, label, value);
+
+// 省市区级联选择工具 (Epic 2/3)
+await selectProvinceCity(page, province, city);
+```
+
+#### 测试文件结构
+
+```
+web/tests/e2e/
+├── fixtures/
+│   └── images/              # 测试图片目录(需创建)
+│       ├── id-card-front.jpg          # 身份证正面
+│       ├── id-card-back.jpg           # 身份证反面
+│       ├── disability-card.jpg        # 残疾证照片
+│       ├── photo.jpg                  # 个人照片
+│       ├── photo.png                  # PNG 格式测试
+│       ├── photo.webp                 # WEBP 格式测试
+│       ├── large-file.jpg             # 超大文件 (>5MB)
+│       └── invalid.txt                # 不支持的格式
+├── specs/
+│   └── admin/
+│       └── disability-person-photo.spec.ts  # 本测试文件(需创建)
+└── pages/
+    └── admin/
+        └── disability-person.page.ts  # Page Object(需更新)
+```
+
+#### Fixtures 文件要求
+
+**图片文件创建指南:**
+
+由于 CI 环境中可能没有真实图片,创建占位图片:
+
+```bash
+# 方法1: 使用 ImageMagick(如果可用)
+convert -size 100x100 xc:blue web/tests/fixtures/images/id-card-front.jpg
+
+# 方法2: 使用 Python(快速创建占位图)
+python3 << 'EOF'
+from PIL import Image
+import os
+
+fixtures_dir = 'web/tests/fixtures/images'
+os.makedirs(fixtures_dir, exist_ok=True)
+
+# 创建占位图片
+images = [
+    ('id-card-front.jpg', (200, 300), 'blue'),
+    ('id-card-back.jpg', (200, 300), 'green'),
+    ('disability-card.jpg', (200, 300), 'red'),
+    ('photo.jpg', (150, 200), 'yellow'),
+    ('photo.png', (150, 200), 'cyan'),
+    ('photo.webp', (150, 200), 'magenta'),
+]
+
+for filename, size, color in images:
+    img = Image.new('RGB', size, color)
+    img.save(os.path.join(fixtures_dir, filename))
+
+# 创建超大文件(用于边界测试)
+large_img = Image.new('RGB', (2000, 2000), 'purple')
+large_img.save(os.path.join(fixtures_dir, 'large-file.jpg'), quality=100)
+
+# 创建无效格式文件
+with open(os.path.join(fixtures_dir, 'invalid.txt'), 'w') as f:
+    f.write('This is not an image')
+
+print('✅ Fixtures created')
+EOF
+```
+
+**临时方案:复制现有图片**
+```bash
+# 如果项目中已有图片,可以复制
+mkdir -p web/tests/fixtures/images
+# 复制或创建测试图片...
+```
+
+### Page Object 更新
+
+**当前 `uploadPhoto()` 方法(需替换):**
+
+当前代码(`disability-person.page.ts:179-204`)使用 `evaluateHandle` + 临时文件,不稳定:
+
+```typescript
+// ❌ 旧方法(不稳定)
+async uploadPhoto(photoType: string, fileName: string) {
+  const photoSection = this.page.locator('text=' + photoType).first();
+  const uploadButton = photoSection.locator('xpath=ancestor::div...').first()
+    .getByRole('button', { name: /上传/ }).first();
+
+  const fileInput = await uploadButton.evaluateHandle((el: any) => {
+    const input = el.querySelector('input[type="file"]');
+    return input;
+  });
+
+  const file = {
+    name: fileName,
+    mimeType: 'image/jpeg',
+    buffer: Buffer.from('fake image content')  // ❌ 假数据
+  };
+
+  await fileInput.uploadFile(file as any);  // ❌ 不稳定
+}
+```
+
+**新方法(使用 `uploadFileToField`):**
+
+```typescript
+// ✅ 新方法(推荐)
+import { uploadFileToField } from '@d8d/e2e-test-utils';
+
+/**
+ * 上传照片
+ * @param photoIndex 照片索引(0=身份证正面, 1=身份证反面, 2=残疾证照片)
+ * @param fileName fixtures 目录中的文件名
+ */
+async uploadPhoto(photoIndex: number, fileName: string) {
+  const selector = `[data-testid="photo-upload-${photoIndex}"]`;
+  await uploadFileToField(this.page, selector, fileName);
+  console.debug(`  ✓ 上传照片 [${photoIndex}]: ${fileName}`);
+}
+```
+
+### 测试用例设计
+
+#### 测试文件模板
+
+```typescript
+// web/tests/e2e/specs/admin/disability-person-photo.spec.ts
+import { test, expect } from '@playwright/test';
+import { DisabilityPersonManagementPage } from '../../pages/admin/disability-person.page';
+
+test.describe('残疾人管理 - 照片上传功能', () => {
+  let pageObject: DisabilityPersonManagementPage;
+
+  test.beforeEach(async ({ page }) => {
+    pageObject = new DisabilityPersonManagementPage(page);
+    await pageObject.goto();
+    await pageObject.openCreateDialog();
+  });
+
+  test('应该成功上传身份证正面照片', async ({ page }) => {
+    // 使用 uploadFileToField 工具
+    await pageObject.uploadPhoto(0, 'id-card-front.jpg');
+
+    // 验证预览显示
+    const preview = page.locator('[data-testid="photo-upload-0"] img');
+    await expect(preview).toBeVisible();
+  });
+
+  test('应该成功上传身份证正反面', async ({ page }) => {
+    // 顺序上传
+    await pageObject.uploadPhoto(0, 'id-card-front.jpg');
+    await pageObject.uploadPhoto(1, 'id-card-back.jpg');
+
+    // 验证两个预览都显示
+    const previews = page.locator('[data-testid^="photo-upload-"] img');
+    await expect(previews).toHaveCount(2);
+  });
+
+  test('应该支持 JPG 格式', async ({ page }) => {
+    await pageObject.uploadPhoto(0, 'photo.jpg');
+    const preview = page.locator('[data-testid="photo-upload-0"] img');
+    await expect(preview).toBeVisible();
+  });
+
+  test('应该支持 PNG 格式', async ({ page }) => {
+    await pageObject.uploadPhoto(0, 'photo.png');
+    const preview = page.locator('[data-testid="photo-upload-0"] img');
+    await expect(preview).toBeVisible();
+  });
+
+  test('应该支持 WEBP 格式', async ({ page }) => {
+    await pageObject.uploadPhoto(0, 'photo.webp');
+    const preview = page.locator('[data-testid="photo-upload-0"] img');
+    await expect(preview).toBeVisible();
+  });
+
+  test('应该能够删除已上传的照片', async ({ page }) => {
+    // 先上传
+    await pageObject.uploadPhoto(0, 'id-card-front.jpg');
+
+    // 点击删除按钮(需要确认选择器)
+    const deleteButton = page.locator('[data-testid="photo-upload-0"]')
+      .getByRole('button', { name: /删除/ });
+    await deleteButton.click();
+
+    // 验证预览消失
+    const preview = page.locator('[data-testid="photo-upload-0"] img');
+    await expect(preview).not.toBeVisible();
+  });
+
+  test('超大文件应该有合理处理', async ({ page }) => {
+    // 上传超大文件
+    await pageObject.uploadPhoto(0, 'large-file.jpg');
+
+    // 验证:要么成功上传,要么显示友好的错误提示
+    const preview = page.locator('[data-testid="photo-upload-0"] img');
+    const hasPreview = await preview.count() > 0;
+
+    if (!hasPreview) {
+      // 验证错误提示
+      const errorToast = page.locator('[data-sonner-toast][data-type="error"]');
+      await expect(errorToast).toBeVisible();
+    }
+  });
+
+  test('不支持的格式应该显示错误', async ({ page }) => {
+    // 尝试上传 TXT 文件
+    await pageObject.uploadPhoto(0, 'invalid.txt');
+
+    // 验证错误提示
+    const errorToast = page.locator('[data-sonner-toast][data-type="error"]');
+    await expect(errorToast).toBeVisible();
+    const errorMessage = await errorToast.textContent();
+    expect(errorMessage).toContain('格式');
+  });
+});
+```
+
+### 项目结构说明
+
+**测试目录组织:**
+- **fixtures/**: 测试资源(图片、数据文件)
+  - 集中管理,避免散落在代码中
+  - 支持版本控制(小文件)
+- **specs/**: 测试用例文件
+  - 按功能模块组织
+  - `.spec.ts` 后缀(Playwright 约定)
+- **pages/**: Page Object 封装
+  - 页面元素和操作方法
+  - 复用性强
+
+**无冲突检测:**
+- 新增测试文件,不影响现有代码
+- Page Object 更新是增强,非破坏性修改
+- Fixtures 目录新建,无冲突
+
+### 测试隔离策略
+
+**数据隔离(为 Story 9.6 准备):**
+
+```typescript
+test.describe.configure({ mode: 'serial' });  // 串行执行(如果需要)
+
+test.afterEach(async ({ page }) => {
+  // 清理测试数据
+  // TODO: 实现数据清理逻辑
+  // 选项1: 使用测试账号 + 软删除
+  // 选项2: 数据库事务回滚
+  // 选项3: API 删除创建的数据
+});
+```
+
+**唯一 ID 生成:**
+```typescript
+const timestamp = Date.now();
+const uniqueId = `test_${timestamp}`;
+```
+
+### 参考文档
+
+**架构文档:**
+- [Source: _bmad-output/planning-artifacts/architecture.md] - 测试策略、错误处理、TypeScript+Playwright 陷阱
+
+**E2E 测试标准:**
+- [Source: docs/standards/e2e-radix-testing.md] - 文件上传测试标准
+
+**Epic 9 完整需求:**
+- [Source: _bmad-output/planning-artifacts/epics.md#Epic-9-Story-9.1] - 业务需求和验收标准
+
+**前置 Epic 完成:**
+- [Source: _bmad-output/implementation-artifacts/3-1-file-upload-tool.md] - 文件上传工具实现
+- [Source: _bmad-output/implementation-artifacts/epic-2-retrospective.md] - DOM 结构假设必须验证
+
+**现有代码参考:**
+- [Source: packages/e2e-test-utils/src/file-upload.ts] - `uploadFileToField()` 实现
+- [Source: packages/file-management-ui/src/components/MinioUploader.tsx] - 组件的 testId 支持
+- [Source: allin-packages/disability-person-management-ui/src/components/PhotoUploadField.tsx] - testId 生成逻辑
+- [Source: web/tests/e2e/pages/admin/disability-person.page.ts] - Page Object(需更新)
+
+### Project Structure Notes
+
+**Monorepo 结构对齐:**
+- 测试位于 `web/tests/e2e/` 目录
+- 使用 pnpm workspace 协议引用 `@d8d/e2e-test-utils`
+- 与现有 Page Object 模式保持一致
+
+**文件组织:**
+- 新建测试 specs 文件:`web/tests/e2e/specs/admin/disability-person-photo.spec.ts`
+- 新建 fixtures 目录:`web/tests/fixtures/images/`
+- 更新现有 Page Object:`web/tests/e2e/pages/admin/disability-person.page.ts`
+
+**检测到的冲突或差异:**
+- 现有 `uploadPhoto()` 方法使用 `evaluateHandle` + 临时文件(不稳定)
+- 本故事将其替换为 `uploadFileToField()` 工具(更稳定)
+- **理由:** Epic 3 已完成文件上传工具,应统一使用标准工具
+
+**遵循的项目标准:**
+- 文件命名:`.spec.ts` 后缀(Playwright 测试)
+- 测试目录:`specs/` 分离,`pages/` Page Object,`fixtures/` 测试资源
+- 使用 `@d8d/e2e-test-utils` 工具函数
+- 遵循 `docs/standards/e2e-radix-testing.md` 标准
+
+### TypeScript + Playwright 陷阱(关键)
+
+基于架构文档的陷阱章节(architecture.md 第 533-657 行):
+
+**陷阱 1: DOM 结构假设必须验证** ⚠️
+- 本故事中:`MinioUploader` 的 DOM 结构已在 Story 3.1 中验证
+- 使用 `data-testid="photo-upload-{index}"` 选择器(最稳定)
+- 不依赖动态查找(如 evaluateHandle)
+
+**陷阱 2: 精确文本匹配**
+- 使用 `page.locator('[data-testid="photo-upload-0"]')` 而非文本匹配
+- 避免使用 `:has-text()` 进行部分匹配
+
+**陷阱 3: 网络空闲等待**
+- 上传后使用合理的等待策略
+- 考虑使用 `waitForLoadState('networkidle')` 或 `waitForSelector()` 等待预览显示
+
+**陷阱 4: 避免使用 page.evaluate()**
+- 旧代码使用 `evaluateHandle` 查找文件输入框(不稳定)
+- 新方法直接使用选择器 + `setInputFiles()`(更稳定)
+
+### References
+
+**源文档引用:**
+- [Source: _bmad-output/planning-artifacts/epics.md#Epic-9-Story-9.1] - 完整业务需求
+- [Source: _bmad-output/planning-artifacts/architecture.md#Testing-Configuration] - 三层测试策略
+- [Source: docs/standards/e2e-radix-testing.md#文件上传测试] - 文件上传测试标准
+
+**前置 Story 参考:**
+- [Source: _bmad-output/implementation-artifacts/3-1-file-upload-tool.md] - 文件上传工具实现
+- [Source: _bmad-output/implementation-artifacts/3-6-upload-stability-test] - 文件上传稳定性验证
+
+**相关组件源码:**
+- [Source: packages/e2e-test-utils/src/index.ts] - 可用工具导出
+- [Source: packages/e2e-test-utils/src/file-upload.ts] - uploadFileToField 实现
+- [Source: packages/e2e-test-utils/src/types.ts] - FileUploadOptions 类型定义
+- [Source: packages/file-management-ui/src/components/MinioUploader.tsx] - 上传组件
+- [Source: allin-packages/disability-person-management-ui/src/components/PhotoUploadField.tsx] - 照片上传字段
+
+**现有测试代码:**
+- [Source: web/tests/e2e/pages/admin/disability-person.page.ts:179-204] - 旧的 uploadPhoto 方法(需替换)
+
+## Dev Agent Record
+
+### Agent Model Used
+
+Claude Opus 4 (claude-opus-4-5-20251101)
+
+### Debug Log References
+
+### Completion Notes List
+
+1. ✅ 加载并分析 Epic 9 Story 9.1 需求(从 epics.md)
+2. ✅ 加载并分析架构文档(architecture.md)
+3. ✅ 加载并分析项目上下文(project-context.md)
+4. ✅ 分析前置 Story 3.1 的实现和组件修改
+5. ✅ 分析现有 Page Object 和测试基础设施
+6. ✅ 创建完整的 Story 9.1 文档,包含:
+   - Story 和验收标准
+   - 详细的任务分解
+   - Epic 9 背景和目标
+   - 业务功能分析
+   - 技术规范(可用工具、文件结构、fixtures 要求)
+   - Page Object 更新指南
+   - 完整的测试用例模板
+   - 项目结构说明
+   - 测试隔离策略
+   - TypeScript + Playwright 陷阱警告
+   - 完整的参考文档列表
+
+### File List
+
+**创建的文件:**
+- `_bmad-output/implementation-artifacts/9-1-photo-upload-tests.md` - 本 story 文档
+
+**需要创建的文件(开发者任务):**
+- `web/tests/fixtures/images/` - 测试图片目录
+  - `id-card-front.jpg`
+  - `id-card-back.jpg`
+  - `disability-card.jpg`
+  - `photo.jpg`
+  - `photo.png`
+  - `photo.webp`
+  - `large-file.jpg`
+  - `invalid.txt`
+
+- `web/tests/e2e/specs/admin/disability-person-photo.spec.ts` - 测试文件
+
+**需要修改的文件(开发者任务):**
+- `web/tests/e2e/pages/admin/disability-person.page.ts` - 更新 uploadPhoto() 方法
+

+ 4 - 4
_bmad-output/implementation-artifacts/sprint-status.yaml

@@ -76,8 +76,8 @@ development_status:
 
   # Epic 4: 表单工具开发与验证
   # 模式: 工具开发 → 真实 E2E 测试验证 → 稳定性验证
-  epic-4: backlog
-  4-1-form-helper-tool: backlog          # 开发表单辅助工具函数
+  epic-4: in-progress
+  4-1-form-helper-tool: ready-for-dev          # 开发表单辅助工具函数
   4-2-form-unit-tests: backlog           # 编写表单工具的单元测试
   4-3-form-e2e-integration: backlog      # 在 web/tests/e2e 中验证表单工具
   4-4-form-stability-test: backlog       # 表单稳定性验证
@@ -128,8 +128,8 @@ development_status:
   # 模式: 业务测试优先,工具按需扩展(遵循新 PRD 方向)
   # 优先级: HIGH - 阻塞 Epic B(区域管理测试)
   # 详情参见: _bmad-output/implementation-artifacts/epic-9-plan.md
-  epic-9: backlog
-  9-1-photo-upload-tests: backlog        # 照片上传功能完整测试(真实上传、多文件、格式验证)
+  epic-9: in-progress
+  9-1-photo-upload-tests: ready-for-dev  # 照片上传功能完整测试(真实上传、多文件、格式验证)
   9-2-bankcard-tests: backlog            # 银行卡管理功能测试(添加、编辑、删除)
   9-3-note-tests: backlog                # 备注管理功能测试(添加、修改、删除)
   9-4-visit-tests: backlog               # 回访记录管理测试(创建、查看、编辑)