1255 lines
37 KiB
Markdown
1255 lines
37 KiB
Markdown
# 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<CategoryGroup, string> = {
|
|
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<string, string> = {
|
|
'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>): 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
|
|
<div>
|
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">任务类型 *</label>
|
|
<select value={categoryId} onChange={(e) => setCategoryId(e.target.value)} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
|
|
{testCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
</select>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **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
|
|
<CategoryChip category={category} />
|
|
```
|
|
|
|
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
|
|
<div className="col-span-2 flex items-center gap-2">
|
|
<span className="text-[var(--ink-muted)]">任务类型:</span>
|
|
<CategoryChip category={category} />
|
|
</div>
|
|
```
|
|
|
|
- [ ] **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
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className="text-[11px] text-[var(--ink-muted)]">
|
|
已选 {selectedReqs.size} / {linkedRequirements.length}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={() => setSelectedReqs(new Set(linkedRequirements.map((r) => r.id)))} className="text-[11px] text-[var(--accent)] hover:underline">全选</button>
|
|
<button type="button" onClick={() => setSelectedReqs(new Set())} className="text-[11px] text-[var(--ink-muted)] hover:underline">清空</button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **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' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setCompletingPlan(plan)}
|
|
className="rounded-md p-1 text-emerald-600 hover:bg-emerald-50"
|
|
title="完成"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
```
|
|
|
|
Replace green submit prompt with:
|
|
|
|
```tsx
|
|
{plan.status !== 'completed' && completionState.canSubmitResult && (
|
|
<div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
|
|
<span className="text-[12px] text-green-700">已满足提交成果条件</span>
|
|
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">提交成果</button>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
Show missing reasons when in progress:
|
|
|
|
```tsx
|
|
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
|
<p className="mt-2 text-[11px] text-[var(--ink-muted)]">
|
|
还不能提交成果:{completionState.missingReasons.join('、')}
|
|
</p>
|
|
)}
|
|
```
|
|
|
|
- [ ] **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
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
|
|
{findCategoryByCode(categories, d.categoryCode)?.name ?? d.categoryCode}
|
|
</span>
|
|
```
|
|
|
|
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.
|