diff --git a/docs/superpowers/plans/2026-06-25-version-module-rules.md b/docs/superpowers/plans/2026-06-25-version-module-rules.md new file mode 100644 index 0000000..f15a0db --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-version-module-rules.md @@ -0,0 +1,1254 @@ +# Version Module Rules Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Unify version-plan completion rules, project-scoped requirement selection, task category semantics, TestCase task types, and AI category output. + +**Architecture:** Add small pure helper layers before touching UI: `requirement-selector.ts`, `version-plan-workflow.ts`, and stable `TaskCategory.code` mapping. UI components consume those helpers; stores keep current JSONB/AppData shape with compatibility normalization. + +**Tech Stack:** Next.js 14, React, Zustand, TypeScript, Node `node:test` for web pure-function tests, NestJS/Jest for server AI schema tests. + +--- + +## File Structure + +- Create `apps/web/tsconfig.test.json`: emits web lib tests into `.tmp-test`. +- Create `apps/web/scripts/run-node-tests.mjs`: runs compiled Node test files. +- Modify `apps/web/package.json`: add `test` script. +- Modify `apps/web/lib/task-category.ts`: add `code`, `testing` group, presets, normalization and mapping helpers. +- Create `apps/web/lib/task-category.test.ts`: category normalization and mapping tests. +- Create `apps/web/lib/requirement-selector.ts`: project-scoped adopted requirement selector. +- Create `apps/web/lib/requirement-selector.test.ts`: selector and historical selection tests. +- Create `apps/web/lib/version-plan-workflow.ts`: plan completion state helper. +- Create `apps/web/lib/version-plan-workflow.test.ts`: completion rule tests. +- Modify `apps/web/lib/test-case.ts`: add `categoryId`. +- Modify `apps/web/stores/useTaskCategoryStore.ts`: normalize fetched categories. +- Modify `apps/web/stores/useTestCaseStore.ts`: backfill missing `categoryId`. +- Modify `apps/web/components/test-case/TestCaseCreateModal.tsx`: required task type field. +- Modify `apps/web/components/test-case/TestCaseTab.tsx`: category map and row prop. +- Modify `apps/web/components/test-case/TestCaseRow.tsx`: show category chip. +- Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx`: show category. +- Modify `apps/web/components/version/PlanTab.tsx`: use plan workflow and requirement selector UI. +- Modify `apps/web/components/version/PlanDetailDrawer.tsx`: use plan workflow for completion. +- Modify `apps/web/app/versions/[id]/page.tsx`: pass project-scoped requirement candidates to plan tabs. +- Modify `apps/web/components/version/DecomposeReportModal.tsx`: map/display `categoryCode`. +- Modify `packages/shared/src/agent.ts`: add `categoryCode` to dev/test drafts. +- Modify `apps/server/src/modules/ai/prompts/decompose.ts`: require `categoryCode` in tool schema and prompt. +- Modify `apps/server/src/modules/ai/ai.service.spec.ts`: assert schema accepts category codes. + +--- + +### Task 1: Add Web Pure-Function Test Harness + +**Files:** +- Create: `apps/web/tsconfig.test.json` +- Create: `apps/web/scripts/run-node-tests.mjs` +- Modify: `apps/web/package.json` + +- [ ] **Step 1: Add test TypeScript config** + +Create `apps/web/tsconfig.test.json`: + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "outDir": ".tmp-test", + "rootDir": ".", + "jsx": "react-jsx", + "types": ["node"] + }, + "include": ["lib/**/*.ts"], + "exclude": ["node_modules", ".next", ".tmp-test"] +} +``` + +- [ ] **Step 2: Add Node test runner script** + +Create `apps/web/scripts/run-node-tests.mjs`: + +```js +import { readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +function collectTestFiles(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) out.push(...collectTestFiles(full)); + if (stat.isFile() && full.endsWith('.test.js')) out.push(full); + } + return out; +} + +const root = process.argv[2] || '.tmp-test'; +const files = collectTestFiles(root); + +if (files.length === 0) { + console.error(`No compiled .test.js files found under ${root}`); + process.exit(1); +} + +const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit' }); +process.exit(result.status ?? 1); +``` + +- [ ] **Step 3: Add `test` script to web package** + +Modify `apps/web/package.json` scripts: + +```json +"test": "tsc -p tsconfig.test.json && node scripts/run-node-tests.mjs .tmp-test" +``` + +- [ ] **Step 4: Run existing web test harness** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: existing `product-overview-persistence.test.ts` compiles and passes. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/package.json apps/web/tsconfig.test.json apps/web/scripts/run-node-tests.mjs +git commit -m "test(web): 增加纯函数测试脚本" +``` + +--- + +### Task 2: Extend TaskCategory With Stable Codes + +**Files:** +- Modify: `apps/web/lib/task-category.ts` +- Modify: `apps/web/stores/useTaskCategoryStore.ts` +- Create: `apps/web/lib/task-category.test.ts` + +- [ ] **Step 1: Write category mapping tests** + +Create `apps/web/lib/task-category.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DEFAULT_TEST_CATEGORY_ID, + PRESET_CATEGORIES, + findCategoryByCode, + getDefaultCategoryByGroup, + normalizeTaskCategories, + resolveCategoryIdFromCode, +} from './task-category'; + +test('preset categories include stable codes and testing group', () => { + assert.ok(PRESET_CATEGORIES.some((c) => c.code === 'test_functional' && c.group === 'testing')); + assert.ok(PRESET_CATEGORIES.every((c) => c.code.length > 0)); +}); + +test('normalizes legacy categories without code', () => { + const normalized = normalizeTaskCategories([ + { id: 'cat-1', name: '前端开发', group: 'development', sortOrder: 1, isSystem: true } as any, + ]); + + assert.equal(normalized[0].code, 'frontend_development'); +}); + +test('resolves category id from stable code', () => { + const id = resolveCategoryIdFromCode(PRESET_CATEGORIES, 'test_functional', 'testing'); + assert.equal(id, DEFAULT_TEST_CATEGORY_ID); +}); + +test('falls back to default group category for unknown code', () => { + const category = getDefaultCategoryByGroup(PRESET_CATEGORIES, 'testing'); + assert.equal(resolveCategoryIdFromCode(PRESET_CATEGORIES, 'unknown_code', 'testing'), category.id); +}); + +test('finds category by code', () => { + assert.equal(findCategoryByCode(PRESET_CATEGORIES, 'backend_api')?.name, '后端接口'); +}); +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: FAIL because helper exports do not exist yet. + +- [ ] **Step 3: Update category type and presets** + +Modify `apps/web/lib/task-category.ts`: + +```ts +export type CategoryGroup = 'development' | 'testing' | 'implementation' | 'other'; + +export const DEFAULT_TEST_CATEGORY_ID = 'cat-test-functional'; + +export interface TaskCategory { + id: string; + code: string; + name: string; + group: CategoryGroup; + color?: string; + sortOrder: number; + isSystem: boolean; +} + +export const CATEGORY_GROUP_LABEL: Record = { + development: '开发', + testing: '测试', + implementation: '实施', + other: '其他', +}; + +export const PRESET_CATEGORIES: TaskCategory[] = [ + { id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', color: '#3b82f6', sortOrder: 1, isSystem: true }, + { id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', color: '#6366f1', sortOrder: 2, isSystem: true }, + { id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', color: '#2563eb', sortOrder: 3, isSystem: true }, + { id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', color: '#0ea5e9', sortOrder: 4, isSystem: true }, + { id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', color: '#8b5cf6', sortOrder: 5, isSystem: true }, + { id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', color: '#06b6d4', sortOrder: 6, isSystem: true }, + { id: DEFAULT_TEST_CATEGORY_ID, code: 'test_functional', name: '功能测试', group: 'testing', color: '#22c55e', sortOrder: 20, isSystem: true }, + { id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', color: '#14b8a6', sortOrder: 21, isSystem: true }, + { id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', color: '#f97316', sortOrder: 22, isSystem: true }, + { id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', color: '#a855f7', sortOrder: 23, isSystem: true }, + { id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', color: '#f59e0b', sortOrder: 40, isSystem: true }, + { id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', color: '#10b981', sortOrder: 41, isSystem: true }, + { id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', color: '#64748b', sortOrder: 60, isSystem: true }, +]; +``` + +- [ ] **Step 4: Add normalization helpers** + +Add to `apps/web/lib/task-category.ts`: + +```ts +const LEGACY_CODE_BY_ID: Record = { + 'cat-1': 'frontend_development', + 'cat-2': 'backend_development', + 'cat-3': 'database_schema', + 'cat-4': 'api_integration', + 'cat-5': 'data_processing', + 'cat-6': 'implementation_support', +}; + +function slugifyCategoryName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_') + .replace(/^_+|_+$/g, '') || `category_${Date.now()}`; +} + +export function normalizeTaskCategory(category: any, index = 0): TaskCategory { + const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id); + return { + ...category, + code: category?.code || LEGACY_CODE_BY_ID[category?.id] || preset?.code || slugifyCategoryName(category?.name || `category_${index}`), + group: category?.group || 'other', + sortOrder: typeof category?.sortOrder === 'number' ? category.sortOrder : index + 1, + isSystem: Boolean(category?.isSystem), + } as TaskCategory; +} + +export function normalizeTaskCategories(categories: any[]): TaskCategory[] { + const merged = [...(Array.isArray(categories) ? categories : [])]; + for (const preset of PRESET_CATEGORIES) { + if (!merged.some((c) => c.id === preset.id)) merged.push(preset); + } + return merged.map((c, index) => normalizeTaskCategory(c, index)); +} + +export function findCategoryByCode(categories: TaskCategory[], code?: string): TaskCategory | undefined { + if (!code) return undefined; + return categories.find((c) => c.code === code); +} + +export function getDefaultCategoryByGroup(categories: TaskCategory[], group: CategoryGroup): TaskCategory { + return getCategoriesByGroup(categories, group)[0] ?? categories[0] ?? PRESET_CATEGORIES[0]; +} + +export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string { + return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id; +} +``` + +- [ ] **Step 5: Normalize task category store data** + +Modify `apps/web/stores/useTaskCategoryStore.ts`: + +```ts +import { PRESET_CATEGORIES, normalizeTaskCategories } from '@/lib/task-category'; +``` + +In `fetchCategories`: + +```ts +const cached = await loadStored(); +if (cached) set({ categories: normalizeTaskCategories(cached) }); +``` + +In `addCategory`, include `code`: + +```ts +code: name.trim().toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '_'), +``` + +- [ ] **Step 6: Run tests** + +Run: + +```bash +pnpm --filter web test +pnpm --filter web type-check +``` + +Expected: both pass. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/lib/task-category.ts apps/web/lib/task-category.test.ts apps/web/stores/useTaskCategoryStore.ts +git commit -m "feat(任务类型): 增加稳定分类语义码" +``` + +--- + +### Task 3: Add Project-Scoped Requirement Selector + +**Files:** +- Create: `apps/web/lib/requirement-selector.ts` +- Create: `apps/web/lib/requirement-selector.test.ts` + +- [ ] **Step 1: Write selector tests** + +Create `apps/web/lib/requirement-selector.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { getProjectAdoptedRequirementCandidates, mergeSelectedRequirementOptions } from './requirement-selector'; +import type { Requirement } from './requirement'; + +const base = { + description: '', + productId: 'product-1', + sourceType: 'internal', + sourceTarget: '', + platforms: [], + typeId: '', + priority: 'P2', + effort: 'M', + creator: 'tester', + createdAt: '2026-06-25', +} as const; + +function req(id: string, projectId: string, status: Requirement['status']): Requirement { + return { + ...base, + id, + code: id.toUpperCase(), + title: `Requirement ${id}`, + projectId, + status, + } as Requirement; +} + +test('returns only adopted requirements from current project', () => { + const result = getProjectAdoptedRequirementCandidates([ + req('r1', 'project-1', 'adopted'), + req('r2', 'project-1', 'pending_review'), + req('r3', 'project-2', 'adopted'), + ], 'project-1'); + + assert.deepEqual(result.map((r) => r.id), ['r1']); +}); + +test('keeps historical selected requirements as non-candidate options', () => { + const options = mergeSelectedRequirementOptions( + [req('r1', 'project-1', 'adopted')], + [req('r2', 'project-1', 'developing')], + ['r1', 'r2'], + ); + + assert.equal(options.find((o) => o.id === 'r2')?.isHistorical, true); +}); +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: FAIL because `requirement-selector.ts` does not exist. + +- [ ] **Step 3: Implement selector** + +Create `apps/web/lib/requirement-selector.ts`: + +```ts +import type { Requirement } from './requirement'; + +export interface RequirementOption { + id: string; + code: string; + title: string; + productOwner?: string; + status: Requirement['status']; + isHistorical?: boolean; +} + +export function toRequirementOption(requirement: Requirement, isHistorical = false): RequirementOption { + return { + id: requirement.id, + code: requirement.code, + title: requirement.title, + productOwner: requirement.productOwner, + status: requirement.status, + isHistorical, + }; +} + +export function getProjectAdoptedRequirementCandidates(requirements: Requirement[], projectId: string): Requirement[] { + return requirements + .filter((r) => r.projectId === projectId && r.status === 'adopted') + .sort((a, b) => a.code.localeCompare(b.code, 'zh-CN')); +} + +export function mergeSelectedRequirementOptions( + candidates: Requirement[], + allRequirements: Requirement[], + selectedIds: string[] = [], +): RequirementOption[] { + const candidateOptions = candidates.map((r) => toRequirementOption(r)); + const candidateIds = new Set(candidateOptions.map((r) => r.id)); + const historical = selectedIds + .filter((id) => !candidateIds.has(id)) + .map((id) => allRequirements.find((r) => r.id === id)) + .filter((r): r is Requirement => Boolean(r)) + .map((r) => toRequirementOption(r, true)); + return [...candidateOptions, ...historical]; +} +``` + +- [ ] **Step 4: Run tests** + +Run: + +```bash +pnpm --filter web test +pnpm --filter web type-check +``` + +Expected: both pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/lib/requirement-selector.ts apps/web/lib/requirement-selector.test.ts +git commit -m "feat(需求): 统一项目已采纳需求选择器" +``` + +--- + +### Task 4: Add VersionPlan Workflow Engine + +**Files:** +- Create: `apps/web/lib/version-plan-workflow.ts` +- Create: `apps/web/lib/version-plan-workflow.test.ts` +- Modify: `apps/web/stores/useVersionPlanStore.ts` + +- [ ] **Step 1: Write workflow tests** + +Create `apps/web/lib/version-plan-workflow.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { getPlanCompletionState, hasPlanResult } from './version-plan-workflow'; +import type { VersionPlan } from './version-plan'; + +function plan(patch: Partial): VersionPlan { + return { + id: 'plan-1', + versionId: 'version-1', + type: 'product', + title: '产品方案', + owner: 'PM', + startTime: '2026-06-25T09:00', + endTime: '2026-06-25T18:00', + status: 'in_progress', + tasks: [{ id: 'task-1', title: '梳理方案', status: 'pending' }], + linkedRequirementIds: ['r1'], + completedRequirementIds: [], + createdAt: '2026-06-25', + addedBy: 'PM', + ...patch, + }; +} + +test('does not allow result submission when subtasks are incomplete', () => { + const state = getPlanCompletionState(plan({})); + assert.equal(state.canSubmitResult, false); + assert.ok(state.missingReasons.includes('子任务未全部完成')); +}); + +test('requires product requirement coverage when linked requirements exist', () => { + const state = getPlanCompletionState(plan({ + tasks: [{ id: 'task-1', title: '梳理方案', status: 'completed' }], + })); + assert.equal(state.canSubmitResult, false); + assert.ok(state.missingReasons.includes('关联需求未全部覆盖')); +}); + +test('allows result submission after checklist and coverage are complete', () => { + const state = getPlanCompletionState(plan({ + tasks: [{ id: 'task-1', title: '梳理方案', status: 'completed' }], + completedRequirementIds: ['r1'], + })); + assert.equal(state.canSubmitResult, true); + assert.equal(state.canComplete, false); +}); + +test('allows completion only after result exists', () => { + const state = getPlanCompletionState(plan({ + tasks: [{ id: 'task-1', title: '梳理方案', status: 'completed' }], + completedRequirementIds: ['r1'], + resultType: 'link', + resultTitle: '原型', + resultUrl: 'https://example.com/prototype', + })); + assert.equal(state.canComplete, true); +}); + +test('research requires tasks and result but not requirement coverage', () => { + const state = getPlanCompletionState(plan({ + type: 'research', + tasks: [{ id: 'task-1', title: '调研', status: 'completed' }], + linkedRequirementIds: ['r1'], + completedRequirementIds: [], + resultType: 'file', + resultTitle: '调研报告', + resultFileName: 'report.pdf', + resultFileData: 'data:application/pdf;base64,abc', + })); + assert.equal(state.canComplete, true); +}); + +test('detects link and file result payloads', () => { + assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型', resultUrl: 'https://example.com' }), true); + assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true); + assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false); +}); +``` + +- [ ] **Step 2: Run test and confirm failure** + +Run: + +```bash +pnpm --filter web test +``` + +Expected: FAIL because `version-plan-workflow.ts` does not exist. + +- [ ] **Step 3: Implement workflow helper** + +Create `apps/web/lib/version-plan-workflow.ts`: + +```ts +import type { VersionPlan } from './version-plan'; + +export interface PlanResultPayload { + resultType?: 'link' | 'file'; + resultTitle?: string; + resultUrl?: string; + resultFileName?: string; + resultFileData?: string; +} + +export interface PlanCompletionState { + checklistTotal: number; + checklistCompleted: number; + requirementTotal: number; + requirementCompleted: number; + hasResult: boolean; + canSubmitResult: boolean; + canComplete: boolean; + missingReasons: string[]; +} + +export function hasPlanResult(plan: PlanResultPayload): boolean { + const hasTitle = Boolean(plan.resultTitle?.trim()); + if (!hasTitle || !plan.resultType) return false; + if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim()); + return Boolean(plan.resultFileData || plan.resultFileName); +} + +function requiresRequirementCoverage(plan: VersionPlan): boolean { + return plan.type === 'product' || plan.type === 'ui'; +} + +export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState { + const tasks = plan.tasks ?? []; + const checklistTotal = tasks.length; + const checklistCompleted = tasks.filter((t) => t.status === 'completed').length; + + const linked = plan.linkedRequirementIds ?? []; + const completed = new Set(plan.completedRequirementIds ?? []); + const requirementTotal = linked.length; + const requirementCompleted = linked.filter((id) => completed.has(id)).length; + + const missingReasons: string[] = []; + if (checklistTotal === 0) missingReasons.push('缺少子任务'); + if (checklistTotal > 0 && checklistCompleted < checklistTotal) missingReasons.push('子任务未全部完成'); + if (requiresRequirementCoverage(plan) && requirementTotal > 0 && requirementCompleted < requirementTotal) { + missingReasons.push('关联需求未全部覆盖'); + } + + const canSubmitResult = missingReasons.length === 0; + const hasResult = hasPlanResult(plan); + if (canSubmitResult && !hasResult) missingReasons.push('尚未提交成果'); + + return { + checklistTotal, + checklistCompleted, + requirementTotal, + requirementCompleted, + hasResult, + canSubmitResult, + canComplete: canSubmitResult && hasResult, + missingReasons, + }; +} + +export function canTogglePlanChecklist(plan: VersionPlan, now: Date = new Date()): boolean { + return plan.status === 'in_progress' || (plan.status === 'pending' && Boolean(plan.startTime) && new Date(plan.startTime) <= now); +} + +export function canEditPlanRequirementCoverage(plan: VersionPlan, now: Date = new Date()): boolean { + return canTogglePlanChecklist(plan, now); +} +``` + +- [ ] **Step 4: Guard store completion** + +Modify `apps/web/stores/useVersionPlanStore.ts`: + +```ts +import { getPlanCompletionState } from '@/lib/version-plan-workflow'; +``` + +Change `completePlan` type: + +```ts +completePlan: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string }; +``` + +Update implementation: + +```ts +completePlan: (id, result) => { + let response: { ok: boolean; message?: string } = { ok: false, message: '计划不存在' }; + const plans = get().plans.map((p) => { + if (p.id !== id) return p; + const next = { ...p, ...result, status: 'completed' as const, completedAt: new Date().toISOString() }; + const state = getPlanCompletionState(next); + if (!state.canComplete) { + response = { ok: false, message: state.missingReasons.join('、') || '计划未满足完成条件' }; + return p; + } + response = { ok: true }; + return next; + }); + set({ plans }); + saveStored(plans); + return response; +}, +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +pnpm --filter web test +pnpm --filter web type-check +``` + +Expected: both pass. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/lib/version-plan-workflow.ts apps/web/lib/version-plan-workflow.test.ts apps/web/stores/useVersionPlanStore.ts +git commit -m "feat(版本计划): 增加完成规则引擎" +``` + +--- + +### Task 5: Add Task Type to TestCase + +**Files:** +- Modify: `apps/web/lib/test-case.ts` +- Modify: `apps/web/stores/useTestCaseStore.ts` +- Modify: `apps/web/components/test-case/TestCaseCreateModal.tsx` +- Modify: `apps/web/components/test-case/TestCaseTab.tsx` +- Modify: `apps/web/components/test-case/TestCaseRow.tsx` +- Modify: `apps/web/components/test-case/TestCaseDetailDrawer.tsx` + +- [ ] **Step 1: Add `categoryId` to TestCase type** + +Modify `apps/web/lib/test-case.ts`: + +```ts +export interface TestCase { + id: string; + caseNo: string; + versionId: string; + requirementId?: string; + title: string; + description?: string; + categoryId: string; + priority: Priority; + assigneeId?: string; + status: TestCaseStatus; + // existing fields stay unchanged +} +``` + +- [ ] **Step 2: Backfill old TestCase data** + +Modify `apps/web/stores/useTestCaseStore.ts`: + +```ts +import { DEFAULT_TEST_CATEGORY_ID } from '@/lib/task-category'; +``` + +In `fetchTestCases`: + +```ts +if (cached) { + set({ + testCases: cached.map((c) => ({ + ...c, + categoryId: c.categoryId || DEFAULT_TEST_CATEGORY_ID, + })), + }); +} +``` + +- [ ] **Step 3: Add task type selector to TestCase create modal** + +Modify `apps/web/components/test-case/TestCaseCreateModal.tsx`: + +```ts +import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; +import { getDefaultCategoryByGroup, getCategoriesByGroup } from '@/lib/task-category'; +``` + +Add state: + +```ts +const { categories } = useTaskCategoryStore(); +const testCategories = useMemo(() => getCategoriesByGroup(categories, 'testing'), [categories]); +const [categoryId, setCategoryId] = useState(getDefaultCategoryByGroup(categories, 'testing').id); +``` + +Update submit guard: + +```ts +const canSubmit = title.trim() && categoryId; +``` + +Pass `categoryId` into `createTestCase`. + +Add field near priority: + +```tsx +
+ + +
+``` + +- [ ] **Step 4: Show category in TestCase list** + +Modify `apps/web/components/test-case/TestCaseRow.tsx`: + +```ts +import type { TaskCategory } from '@/lib/task-category'; +import { CategoryChip } from '@/components/dev-task/CategoryChip'; + +interface Props { + testCase: TestCase; + category?: TaskCategory; + bugCount: number; + onClick?: () => void; +} +``` + +Render: + +```tsx + +``` + +Modify `apps/web/components/test-case/TestCaseTab.tsx` to fetch categories and pass `categoryMap.get(tc.categoryId)`. + +- [ ] **Step 5: Show category in TestCase drawer** + +Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx`: + +```ts +import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; +import { CategoryChip } from '@/components/dev-task/CategoryChip'; +``` + +Find category: + +```ts +const { categories } = useTaskCategoryStore(); +const category = categories.find((c) => c.id === tc.categoryId); +``` + +Render in basic info: + +```tsx +
+ 任务类型: + +
+``` + +- [ ] **Step 6: Run verification** + +Run: + +```bash +pnpm --filter web type-check +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/lib/test-case.ts apps/web/stores/useTestCaseStore.ts apps/web/components/test-case/TestCaseCreateModal.tsx apps/web/components/test-case/TestCaseTab.tsx apps/web/components/test-case/TestCaseRow.tsx apps/web/components/test-case/TestCaseDetailDrawer.tsx +git commit -m "feat(测试用例): 增加任务类型" +``` + +--- + +### Task 6: Wire Requirement Selector and Plan Workflow Into Version UI + +**Files:** +- Modify: `apps/web/app/versions/[id]/page.tsx` +- Modify: `apps/web/components/version/PlanTab.tsx` +- Modify: `apps/web/components/version/PlanDetailDrawer.tsx` + +- [ ] **Step 1: Pass project-scoped adopted requirements to PlanTab** + +Modify `apps/web/app/versions/[id]/page.tsx`: + +```ts +import { getProjectAdoptedRequirementCandidates } from '@/lib/requirement-selector'; +``` + +In the plan tab render block: + +```ts +const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId); +const linkedReqs = projectAdoptedReqs.map((r) => ({ + id: r.id, + title: r.title, + code: r.code, + productOwner: r.productOwner, +})); +``` + +Pass to every `PlanTab`: + +```tsx +linkedRequirements={linkedReqs} +``` + +- [ ] **Step 2: Make requirement selection available for all plan types** + +Modify `PlanFormModal` in `apps/web/components/version/PlanTab.tsx`: + +```ts +const showReqSelect = true; +``` + +Remove the old `planType === 'product' || planType === 'ui'` guard. + +- [ ] **Step 3: Add select-all controls** + +Inside the linked requirement selection block, add: + +```tsx +
+ + 已选 {selectedReqs.size} / {linkedRequirements.length} + +
+ + +
+
+``` + +- [ ] **Step 4: Make tasks required for research/product/ui** + +In `PlanFormModal`, change validation: + +```ts +if (tasks.length === 0) return; +``` + +Remove the `planType === 'research'` condition around the task checklist UI so every plan type can add subtasks. + +- [ ] **Step 5: Use workflow state in PlanTab** + +Import: + +```ts +import { getPlanCompletionState, canTogglePlanChecklist, canEditPlanRequirementCoverage } from '@/lib/version-plan-workflow'; +``` + +Inside each plan render: + +```ts +const completionState = getPlanCompletionState(plan); +const canToggle = canTogglePlanChecklist(plan); +const canEditCoverage = canEditPlanRequirementCoverage(plan); +``` + +Use `canToggle` for task checkbox disabled state. + +Use `canEditCoverage` for requirement checkbox disabled state. + +Remove this direct-complete button block entirely: + +```tsx +{plan.status !== 'completed' && plan.status !== 'pending' && ( + +)} +``` + +Replace green submit prompt with: + +```tsx +{plan.status !== 'completed' && completionState.canSubmitResult && ( +
+ 已满足提交成果条件 + +
+)} +``` + +Show missing reasons when in progress: + +```tsx +{plan.status === 'in_progress' && !completionState.canSubmitResult && ( +

+ 还不能提交成果:{completionState.missingReasons.join('、')} +

+)} +``` + +- [ ] **Step 6: Use workflow state in PlanDetailDrawer** + +Modify `apps/web/components/version/PlanDetailDrawer.tsx` similarly: + +```ts +const completionState = getPlanCompletionState(plan); +``` + +Disable “提交完成” button unless `completionState.canSubmitResult`. + +Show missing reasons under the disabled button. + +When submitting result: + +```ts +const res = completePlan(plan.id, { resultType, resultTitle: title, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined }); +if (!res.ok) { + alert(res.message || '计划未满足完成条件'); + return; +} +``` + +- [ ] **Step 7: Run verification** + +Run: + +```bash +pnpm --filter web test +pnpm --filter web type-check +``` + +Expected: both pass. + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/app/versions/[id]/page.tsx apps/web/components/version/PlanTab.tsx apps/web/components/version/PlanDetailDrawer.tsx +git commit -m "feat(版本计划): 接入完成规则和需求全选" +``` + +--- + +### Task 7: Upgrade AI Decompose Category Contract + +**Files:** +- Modify: `packages/shared/src/agent.ts` +- Modify: `apps/server/src/modules/ai/prompts/decompose.ts` +- Modify: `apps/server/src/modules/ai/ai.service.spec.ts` +- Modify: `apps/web/components/version/DecomposeReportModal.tsx` + +- [ ] **Step 1: Update shared agent types** + +Modify `packages/shared/src/agent.ts`: + +```ts +export type AgentTaskCategoryCode = + | 'frontend_development' + | 'frontend_interaction' + | 'backend_development' + | 'backend_api' + | 'database_schema' + | 'api_integration' + | 'test_functional' + | 'test_api' + | 'test_exception' + | 'test_compatibility' + | 'data_processing' + | 'implementation_support' + | 'documentation'; + +export interface AgentDevTaskDraft { + title: string; + description?: string; + categoryCode: AgentTaskCategoryCode; + priority: 'P0' | 'P1' | 'P2' | 'P3'; + estimateHours: number; + references: AgentReference[]; +} + +export interface AgentTestCaseDraft { + title: string; + description: string; + categoryCode: AgentTaskCategoryCode; + priority: 'P0' | 'P1' | 'P2' | 'P3'; + references: AgentReference[]; +} +``` + +Keep `AgentRole` only if other code still imports it; remove usage from dev draft after all references are updated. + +- [ ] **Step 2: Update server prompt and schema** + +Modify `apps/server/src/modules/ai/prompts/decompose.ts`: + +- Replace role instruction with category instruction: + +```ts +4. 任务类型 + - 每条开发任务和测试用例都必须输出 categoryCode + - 开发任务优先使用 frontend_development / frontend_interaction / backend_development / backend_api / database_schema / api_integration + - 测试用例优先使用 test_functional / test_api / test_exception / test_compatibility + - 不输出数据库 categoryId +``` + +- In `devTaskDrafts.items.properties`, replace `role` with: + +```ts +categoryCode: { + type: 'string', + enum: [ + 'frontend_development', + 'frontend_interaction', + 'backend_development', + 'backend_api', + 'database_schema', + 'api_integration', + 'data_processing', + 'implementation_support', + 'documentation', + ], +}, +``` + +- In `testCaseDrafts.items.properties`, add: + +```ts +categoryCode: { + type: 'string', + enum: ['test_functional', 'test_api', 'test_exception', 'test_compatibility'], +}, +``` + +- Update required arrays: + +```ts +required: ['title', 'categoryCode', 'priority', 'estimateHours', 'references'] +required: ['title', 'description', 'categoryCode', 'priority', 'references'] +``` + +- [ ] **Step 3: Update server schema test** + +Modify `apps/server/src/modules/ai/ai.service.spec.ts` or add a new test: + +```ts +import { DECOMPOSE_TOOL_INPUT_SCHEMA } from './prompts/decompose'; + +it('requires categoryCode for dev task and test case drafts', () => { + const devRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.devTaskDrafts.items as any).required; + const tcRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts.items as any).required; + expect(devRequired).toContain('categoryCode'); + expect(tcRequired).toContain('categoryCode'); +}); +``` + +- [ ] **Step 4: Map categoryCode in DecomposeReportModal** + +Modify `apps/web/components/version/DecomposeReportModal.tsx`: + +```ts +import { resolveCategoryIdFromCode, findCategoryByCode } from '@/lib/task-category'; +``` + +For dev tasks: + +```ts +const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development'); +``` + +For test cases: + +```ts +const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing'); +``` + +Pass `categoryId` into `createTestCase`. + +Display category label: + +```tsx + + {findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode} + +``` + +Remove `ROLE_LABEL[d.role]` usage for AI dev drafts. + +- [ ] **Step 5: Run verification** + +Run: + +```bash +pnpm --filter server test +pnpm --filter server type-check +pnpm --filter web type-check +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add packages/shared/src/agent.ts apps/server/src/modules/ai/prompts/decompose.ts apps/server/src/modules/ai/ai.service.spec.ts apps/web/components/version/DecomposeReportModal.tsx +git commit -m "feat(AI): 拆解结果输出任务类型语义码" +``` + +--- + +### Task 8: Final Verification and Browser Smoke Check + +**Files:** +- No source edits unless verification finds issues. + +- [ ] **Step 1: Run full focused verification** + +Run: + +```bash +pnpm --filter web test +pnpm --filter web type-check +pnpm --filter server test +pnpm --filter server type-check +``` + +Expected: all pass. + +- [ ] **Step 2: Smoke check frontend routes** + +If dev server is already running, run: + +```bash +curl http://localhost:3000/versions +curl http://localhost:3000/admin/categories +``` + +Expected: both return HTML and HTTP 200. + +- [ ] **Step 3: Manual browser checks** + +Open `http://localhost:3000/versions`, choose any existing version card, enter its detail page, and verify: + +- Research/Product/UI plan creation shows associated requirements and select-all. +- Candidate requirements are only current project adopted requirements. +- Plan cards do not show the top-right direct completion check. +- Plan completion requires subtasks and result. +- Test case create modal requires task type. +- AI report modal shows category labels for dev tasks and test cases. + +- [ ] **Step 4: Inspect final diff** + +Run: + +```bash +git status --short +git diff --stat +``` + +Expected: only intended implementation files changed since the last commit. + +- [ ] **Step 5: Commit final verification fixes if any** + +Only if Step 1-4 revealed small fixes, apply the focused patch, rerun the failed verification command, inspect `git diff --name-only`, and stage only the concrete files changed by that fix. Use the same commit message: + +```bash +git commit -m "fix(版本模块): 修正规则接入验证问题" +``` + +If no fixes were needed, do not create an empty commit.