1500 lines
43 KiB
Markdown
1500 lines
43 KiB
Markdown
# Workflow and Effort Engine 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:** Make DevTask, TestCase, and Bug status changes single-item only, move timestamp rules into workflow helpers, add TestCase estimates and weighted effort progress, and tighten AI estimates for AI-assisted delivery.
|
|
|
|
**Architecture:** Add small pure rule helpers in `apps/web/lib/` before changing stores or UI. Stores will call workflow helpers for single-item commands; UI will display derived effort from helpers instead of encoding status or time rules inline. AI estimate policy stays as a pure helper used when adopting AI drafts, while the server prompt/schema defines the upstream contract.
|
|
|
|
**Tech Stack:** Next.js 14, React, Zustand, TypeScript, Node `node:test` for web pure-function tests, NestJS/Jest for AI schema tests, existing PostgreSQL AppData persistence.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Create `apps/web/lib/dev-task-workflow.ts`: DevTask single-item transition helper and create normalization.
|
|
- Create `apps/web/lib/dev-task-workflow.test.ts`: DevTask transition, timestamp, and AI draft default tests.
|
|
- Create `apps/web/lib/test-case-workflow.ts`: TestCase single-item transition helper and create normalization.
|
|
- Create `apps/web/lib/test-case-workflow.test.ts`: TestCase transition, timestamp, and reason cleanup tests.
|
|
- Create `apps/web/lib/bug-workflow.ts`: Bug single-item transition helper with log patch generation.
|
|
- Create `apps/web/lib/bug-workflow.test.ts`: Bug transition and timestamp tests.
|
|
- Create `apps/web/lib/work-effort-engine.ts`: shared estimate, actual, weighted progress aggregation.
|
|
- Create `apps/web/lib/work-effort-engine.test.ts`: weighted progress and rounding tests.
|
|
- Create `apps/web/lib/ai-estimation-policy.ts`: strict estimate ranges for AI-assisted dev/test work.
|
|
- Create `apps/web/lib/ai-estimation-policy.test.ts`: estimate clamp and default tests.
|
|
- Modify `apps/web/lib/dev-task.ts`: add `estimateHours`, make estimates prefer explicit hours, remove `estimateHours` from legacy discard marker.
|
|
- Modify `apps/web/lib/dev-task-transitions.ts`: keep delay detection only; stop using auto-start helpers.
|
|
- Modify `apps/web/stores/useDevTaskStore.ts`: remove auto-start scan, use workflow helper for `changeStatus`, normalize created tasks.
|
|
- Modify `apps/web/components/dev-task/DevTaskCreateModal.tsx`: persist computed `estimateHours`.
|
|
- Modify `apps/web/components/dev-task/DevTaskDetailDrawer.tsx`: import transitions from workflow constants if needed.
|
|
- Modify `apps/web/lib/test-case.ts`: add `estimateHours`, labels `待测试/测试中`, derived estimate/actual/progress helpers.
|
|
- Modify `apps/web/stores/useTestCaseStore.ts`: normalize `estimateHours`, use workflow helper for `changeStatus`.
|
|
- Modify `apps/web/components/test-case/TestCaseCreateModal.tsx`: add strict estimate input with 0.25h step.
|
|
- Modify `apps/web/components/test-case/TestCaseTab.tsx`: show total estimate, actual, weighted completion, and pass rate.
|
|
- Modify `apps/web/components/test-case/TestCaseRow.tsx`: show actual/estimate per row.
|
|
- Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx`: show estimate, actual start, completed time, and actual/estimate.
|
|
- Modify `apps/web/stores/useBugStore.ts`: use Bug workflow helper.
|
|
- Modify `packages/shared/src/agent.ts`: add `estimateHours` to `AgentTestCaseDraft`.
|
|
- Modify `apps/server/src/modules/ai/prompts/decompose.ts`: require TestCase estimate and tighten estimate rules.
|
|
- Modify `apps/server/src/modules/ai/ai.service.spec.ts`: assert TestCase estimate schema.
|
|
- Modify `apps/web/components/version/DecomposeReportModal.tsx`: clamp AI estimates, use `addWorkHours`, persist TestCase estimates.
|
|
|
|
---
|
|
|
|
### Task 1: Add Shared Work Effort Engine
|
|
|
|
**Files:**
|
|
- Create: `apps/web/lib/work-effort-engine.ts`
|
|
- Create: `apps/web/lib/work-effort-engine.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing effort tests**
|
|
|
|
Create `apps/web/lib/work-effort-engine.test.ts`:
|
|
|
|
```ts
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { aggregateWorkEffort, roundHalfHour } from './work-effort-engine';
|
|
|
|
test('roundHalfHour rounds to nearest half hour', () => {
|
|
assert.equal(roundHalfHour(0.24), 0);
|
|
assert.equal(roundHalfHour(0.25), 0.5);
|
|
assert.equal(roundHalfHour(1.24), 1);
|
|
assert.equal(roundHalfHour(1.25), 1.5);
|
|
});
|
|
|
|
test('aggregateWorkEffort calculates weighted progress by estimate', () => {
|
|
const result = aggregateWorkEffort([
|
|
{ estimateHours: 1, actualHours: 0, progress: 0 },
|
|
{ estimateHours: 3, actualHours: 1.25, progress: 100 },
|
|
]);
|
|
|
|
assert.equal(result.estimateHours, 4);
|
|
assert.equal(result.actualHours, 1.5);
|
|
assert.equal(result.progress, 75);
|
|
});
|
|
|
|
test('aggregateWorkEffort falls back to item average when estimates are zero', () => {
|
|
const result = aggregateWorkEffort([
|
|
{ estimateHours: 0, actualHours: 0, progress: 50 },
|
|
{ estimateHours: 0, actualHours: 0, progress: 100 },
|
|
]);
|
|
|
|
assert.equal(result.progress, 75);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test and confirm failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
```
|
|
|
|
Expected: FAIL because `work-effort-engine.ts` does not exist.
|
|
|
|
- [ ] **Step 3: Implement effort engine**
|
|
|
|
Create `apps/web/lib/work-effort-engine.ts`:
|
|
|
|
```ts
|
|
export interface WorkEffortItem {
|
|
estimateHours: number;
|
|
actualHours: number;
|
|
progress: number;
|
|
}
|
|
|
|
export interface WorkEffortSummary {
|
|
estimateHours: number;
|
|
actualHours: number;
|
|
progress: number;
|
|
}
|
|
|
|
export function roundHalfHour(hours: number): number {
|
|
if (!Number.isFinite(hours) || hours <= 0) return 0;
|
|
return Math.round(hours * 2) / 2;
|
|
}
|
|
|
|
export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary {
|
|
if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 };
|
|
|
|
const estimateHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
|
|
const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0));
|
|
|
|
if (estimateHours <= 0) {
|
|
const progress = Math.round(items.reduce((sum, item) => sum + item.progress, 0) / items.length);
|
|
return { estimateHours, actualHours, progress };
|
|
}
|
|
|
|
const weighted = items.reduce((sum, item) => {
|
|
const estimate = Math.max(0, item.estimateHours || 0);
|
|
return sum + estimate * item.progress;
|
|
}, 0);
|
|
|
|
return {
|
|
estimateHours,
|
|
actualHours,
|
|
progress: Math.round(weighted / estimateHours),
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/web/lib/work-effort-engine.ts apps/web/lib/work-effort-engine.test.ts
|
|
git commit -m "feat(工时): 增加通用工时汇总引擎"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Add DevTask Workflow Helper and Stop Auto Start
|
|
|
|
**Files:**
|
|
- Create: `apps/web/lib/dev-task-workflow.ts`
|
|
- Create: `apps/web/lib/dev-task-workflow.test.ts`
|
|
- Modify: `apps/web/lib/dev-task.ts`
|
|
- Modify: `apps/web/lib/dev-task-transitions.ts`
|
|
- Modify: `apps/web/stores/useDevTaskStore.ts`
|
|
- Modify: `apps/web/components/dev-task/DevTaskCreateModal.tsx`
|
|
|
|
- [ ] **Step 1: Write failing workflow tests**
|
|
|
|
Create `apps/web/lib/dev-task-workflow.test.ts`:
|
|
|
|
```ts
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import type { DevTask } from './dev-task';
|
|
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from './dev-task-workflow';
|
|
|
|
function task(patch: Partial<DevTask> = {}): DevTask {
|
|
return {
|
|
id: 'task-1',
|
|
taskNo: 'DEV-001',
|
|
requirementId: 'req-1',
|
|
title: '实现拖拽排序',
|
|
categoryId: 'cat-frontend-interaction',
|
|
assigneeId: 'Alice',
|
|
priority: 'P2',
|
|
expectedStartAt: '2026-06-25T01:00:00.000Z',
|
|
expectedEndAt: '2026-06-25T02:00:00.000Z',
|
|
status: 'todo',
|
|
isBlocked: false,
|
|
createdBy: 'PM',
|
|
createdAt: '2026-06-25T00:00:00.000Z',
|
|
updatedAt: '2026-06-25T00:00:00.000Z',
|
|
...patch,
|
|
};
|
|
}
|
|
|
|
test('normalizeDevTaskOnCreate forces todo and clears actual timestamps for AI drafts', () => {
|
|
const normalized = normalizeDevTaskOnCreate(task({
|
|
status: 'in_progress',
|
|
actualStartAt: '2026-06-25T01:00:00.000Z',
|
|
actualEndAt: '2026-06-25T02:00:00.000Z',
|
|
aiDraft: true,
|
|
}));
|
|
|
|
assert.equal(normalized.status, 'todo');
|
|
assert.equal(normalized.actualStartAt, undefined);
|
|
assert.equal(normalized.actualEndAt, undefined);
|
|
});
|
|
|
|
test('todo to in_progress writes actualStartAt from manual click time', () => {
|
|
const result = applyDevTaskTransition(task(), 'in_progress', {
|
|
now: new Date('2026-06-25T03:30:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.patch?.status, 'in_progress');
|
|
assert.equal(result.patch?.actualStartAt, '2026-06-25T03:30:00.000Z');
|
|
});
|
|
|
|
test('in_progress to testing does not write actualEndAt', () => {
|
|
const result = applyDevTaskTransition(task({
|
|
status: 'in_progress',
|
|
actualStartAt: '2026-06-25T03:30:00.000Z',
|
|
}), 'testing', {
|
|
now: new Date('2026-06-25T04:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.patch?.actualEndAt, undefined);
|
|
});
|
|
|
|
test('testing to submitted writes actualEndAt', () => {
|
|
const result = applyDevTaskTransition(task({
|
|
status: 'testing',
|
|
actualStartAt: '2026-06-25T03:30:00.000Z',
|
|
}), 'submitted', {
|
|
now: new Date('2026-06-25T05:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.patch?.actualEndAt, '2026-06-25T05:00:00.000Z');
|
|
});
|
|
|
|
test('invalid transition is rejected', () => {
|
|
const result = applyDevTaskTransition(task(), 'submitted', {
|
|
now: new Date('2026-06-25T05:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.message || '', /不允许/);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test and confirm failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
```
|
|
|
|
Expected: FAIL because `dev-task-workflow.ts` does not exist.
|
|
|
|
- [ ] **Step 3: Implement DevTask workflow helper**
|
|
|
|
Create `apps/web/lib/dev-task-workflow.ts`:
|
|
|
|
```ts
|
|
import type { DevTask, DevTaskStatus } from './dev-task';
|
|
import { canTransition } from './dev-task';
|
|
|
|
export interface DevTaskWorkflowResult {
|
|
ok: boolean;
|
|
patch?: Partial<DevTask>;
|
|
message?: string;
|
|
}
|
|
|
|
export interface DevTaskTransitionOptions {
|
|
now?: Date;
|
|
delayReason?: string;
|
|
}
|
|
|
|
export function normalizeDevTaskOnCreate(task: DevTask): DevTask {
|
|
return {
|
|
...task,
|
|
status: 'todo',
|
|
actualStartAt: undefined,
|
|
actualEndAt: undefined,
|
|
};
|
|
}
|
|
|
|
export function applyDevTaskTransition(
|
|
task: DevTask,
|
|
to: DevTaskStatus,
|
|
options: DevTaskTransitionOptions = {},
|
|
): DevTaskWorkflowResult {
|
|
if (!canTransition(task.status, to)) {
|
|
return { ok: false, message: `不允许从「${task.status}」流转到「${to}」` };
|
|
}
|
|
|
|
const nowIso = (options.now ?? new Date()).toISOString();
|
|
const patch: Partial<DevTask> = {
|
|
status: to,
|
|
aiDraft: false,
|
|
};
|
|
|
|
if (to === 'in_progress' && !task.actualStartAt) {
|
|
patch.actualStartAt = nowIso;
|
|
if (options.delayReason?.trim()) patch.delayReason = options.delayReason.trim();
|
|
}
|
|
|
|
if (to === 'submitted') {
|
|
patch.actualEndAt = nowIso;
|
|
}
|
|
|
|
return { ok: true, patch };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add explicit DevTask estimate support**
|
|
|
|
Modify `apps/web/lib/dev-task.ts`:
|
|
|
|
```ts
|
|
export interface DevTask {
|
|
// existing fields...
|
|
expectedStartAt: string;
|
|
expectedEndAt: string;
|
|
estimateHours?: number;
|
|
actualStartAt?: string;
|
|
actualEndAt?: string;
|
|
// existing fields...
|
|
}
|
|
```
|
|
|
|
Change `getEstimateHours`:
|
|
|
|
```ts
|
|
export function getEstimateHours(task: DevTask): number {
|
|
if (typeof task.estimateHours === 'number' && task.estimateHours > 0) {
|
|
return Math.round(task.estimateHours * 2) / 2;
|
|
}
|
|
if (!task.expectedStartAt || !task.expectedEndAt) return 0;
|
|
return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
|
|
}
|
|
```
|
|
|
|
Change `isLegacyTask` by removing `estimateHours` from the legacy marker list:
|
|
|
|
```ts
|
|
export function isLegacyTask(t: any): boolean {
|
|
if (!t || typeof t !== 'object') return false;
|
|
return (
|
|
'startDate' in t ||
|
|
'dueDate' in t ||
|
|
'completedAt' in t ||
|
|
'actualHours' in t ||
|
|
'overdueReason' in t
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Remove auto-start from DevTask fetch**
|
|
|
|
Modify `apps/web/stores/useDevTaskStore.ts` imports:
|
|
|
|
```ts
|
|
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
|
```
|
|
|
|
Remove these imports:
|
|
|
|
```ts
|
|
import { findTasksToAutoStart, deriveActualStartAt } from '@/lib/dev-task-transitions';
|
|
```
|
|
|
|
Replace `fetchTasks` with:
|
|
|
|
```ts
|
|
fetchTasks: async () => {
|
|
const cached = await loadStored();
|
|
set({ tasks: cached ?? [] });
|
|
},
|
|
```
|
|
|
|
Update `createTask`:
|
|
|
|
```ts
|
|
const task: DevTask = normalizeDevTaskOnCreate({
|
|
...data,
|
|
id: `task-${Date.now()}`,
|
|
taskNo: generateTaskNo(list),
|
|
isBlocked: false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
} as DevTask);
|
|
```
|
|
|
|
Update `changeStatus`:
|
|
|
|
```ts
|
|
changeStatus: (id, to, opts) => {
|
|
const task = get().tasks.find((t) => t.id === id);
|
|
if (!task) return { ok: false, message: '任务不存在' };
|
|
const result = applyDevTaskTransition(task, to, {
|
|
now: new Date(),
|
|
delayReason: opts?.delayReason,
|
|
});
|
|
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
|
get().updateTask(id, result.patch);
|
|
return { ok: true };
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 6: Keep delay helper but stop using auto-start helpers**
|
|
|
|
Modify `apps/web/lib/dev-task-transitions.ts` so only `needsDelayReason` remains exported:
|
|
|
|
```ts
|
|
import type { DevTask } from './dev-task';
|
|
|
|
export function needsDelayReason(task: DevTask, now: Date = new Date()): boolean {
|
|
if (task.status !== 'todo' || !task.expectedStartAt) return false;
|
|
return now.getTime() > new Date(task.expectedStartAt).getTime();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Persist manual DevTask estimate**
|
|
|
|
Modify `apps/web/components/dev-task/DevTaskCreateModal.tsx` in `createTask` payload:
|
|
|
|
```ts
|
|
estimateHours,
|
|
```
|
|
|
|
The payload section should include:
|
|
|
|
```ts
|
|
expectedStartAt: expectedStartISO,
|
|
expectedEndAt: expectedEndISO,
|
|
estimateHours,
|
|
actualStartAt: undefined,
|
|
actualEndAt: undefined,
|
|
status: 'todo',
|
|
```
|
|
|
|
- [ ] **Step 8: Run verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
pnpm --filter web type-check
|
|
```
|
|
|
|
Expected: both pass, and DevTask fetch no longer changes task status automatically.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add apps/web/lib/dev-task-workflow.ts apps/web/lib/dev-task-workflow.test.ts apps/web/lib/dev-task.ts apps/web/lib/dev-task-transitions.ts apps/web/stores/useDevTaskStore.ts apps/web/components/dev-task/DevTaskCreateModal.tsx
|
|
git commit -m "feat(开发任务): 状态流转接入单条工作流"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add TestCase Workflow, Estimates, and Weighted Progress
|
|
|
|
**Files:**
|
|
- Create: `apps/web/lib/test-case-workflow.ts`
|
|
- Create: `apps/web/lib/test-case-workflow.test.ts`
|
|
- Modify: `apps/web/lib/test-case.ts`
|
|
- Modify: `apps/web/stores/useTestCaseStore.ts`
|
|
|
|
- [ ] **Step 1: Write failing TestCase workflow tests**
|
|
|
|
Create `apps/web/lib/test-case-workflow.test.ts`:
|
|
|
|
```ts
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import type { TestCase } from './test-case';
|
|
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from './test-case-workflow';
|
|
|
|
function tc(patch: Partial<TestCase> = {}): TestCase {
|
|
return {
|
|
id: 'tc-1',
|
|
caseNo: 'TC-001',
|
|
versionId: 'version-1',
|
|
title: '拖拽排序正常',
|
|
description: '验证拖拽排序',
|
|
categoryId: 'cat-test-functional',
|
|
priority: 'P2',
|
|
status: 'pending',
|
|
estimateHours: 0.5,
|
|
createdBy: 'QA',
|
|
createdAt: '2026-06-25T00:00:00.000Z',
|
|
updatedAt: '2026-06-25T00:00:00.000Z',
|
|
...patch,
|
|
};
|
|
}
|
|
|
|
test('normalizeTestCaseOnCreate forces pending and clears actual timestamps', () => {
|
|
const result = normalizeTestCaseOnCreate(tc({
|
|
status: 'running',
|
|
startedAt: '2026-06-25T01:00:00.000Z',
|
|
completedAt: '2026-06-25T02:00:00.000Z',
|
|
}));
|
|
|
|
assert.equal(result.status, 'pending');
|
|
assert.equal(result.startedAt, undefined);
|
|
assert.equal(result.completedAt, undefined);
|
|
});
|
|
|
|
test('pending to running writes startedAt', () => {
|
|
const result = applyTestCaseTransition(tc(), 'running', {
|
|
now: new Date('2026-06-25T01:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.patch?.startedAt, '2026-06-25T01:00:00.000Z');
|
|
});
|
|
|
|
test('running to passed writes completedAt', () => {
|
|
const result = applyTestCaseTransition(tc({
|
|
status: 'running',
|
|
startedAt: '2026-06-25T01:00:00.000Z',
|
|
}), 'passed', {
|
|
now: new Date('2026-06-25T02:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.patch?.completedAt, '2026-06-25T02:00:00.000Z');
|
|
});
|
|
|
|
test('failed back to running clears failure and completion reason', () => {
|
|
const result = applyTestCaseTransition(tc({
|
|
status: 'failed',
|
|
startedAt: '2026-06-25T01:00:00.000Z',
|
|
completedAt: '2026-06-25T02:00:00.000Z',
|
|
failReason: '排序未保存',
|
|
}), 'running', {
|
|
now: new Date('2026-06-25T03:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.patch?.startedAt, undefined);
|
|
assert.equal(result.patch?.completedAt, undefined);
|
|
assert.equal(result.patch?.failReason, undefined);
|
|
});
|
|
|
|
test('invalid transition is rejected', () => {
|
|
const result = applyTestCaseTransition(tc(), 'passed', {
|
|
now: new Date('2026-06-25T02:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, false);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test and confirm failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
```
|
|
|
|
Expected: FAIL because `test-case-workflow.ts` does not exist.
|
|
|
|
- [ ] **Step 3: Implement TestCase workflow helper**
|
|
|
|
Create `apps/web/lib/test-case-workflow.ts`:
|
|
|
|
```ts
|
|
import type { TestCase, TestCaseStatus } from './test-case';
|
|
import { canTcTransition } from './test-case';
|
|
|
|
export interface TestCaseWorkflowResult {
|
|
ok: boolean;
|
|
patch?: Partial<TestCase>;
|
|
message?: string;
|
|
}
|
|
|
|
export interface TestCaseTransitionOptions {
|
|
now?: Date;
|
|
failReason?: string;
|
|
blockReason?: string;
|
|
}
|
|
|
|
export function normalizeTestCaseOnCreate(testCase: TestCase): TestCase {
|
|
return {
|
|
...testCase,
|
|
status: 'pending',
|
|
startedAt: undefined,
|
|
completedAt: undefined,
|
|
executedAt: undefined,
|
|
failReason: undefined,
|
|
blockReason: undefined,
|
|
};
|
|
}
|
|
|
|
export function applyTestCaseTransition(
|
|
testCase: TestCase,
|
|
to: TestCaseStatus,
|
|
options: TestCaseTransitionOptions = {},
|
|
): TestCaseWorkflowResult {
|
|
if (!canTcTransition(testCase.status, to)) {
|
|
return { ok: false, message: `不允许从「${testCase.status}」流转到「${to}」` };
|
|
}
|
|
|
|
const nowIso = (options.now ?? new Date()).toISOString();
|
|
const patch: Partial<TestCase> = {
|
|
status: to,
|
|
aiDraft: false,
|
|
executedAt: nowIso,
|
|
};
|
|
|
|
if (to === 'running') {
|
|
if (!testCase.startedAt) patch.startedAt = nowIso;
|
|
patch.completedAt = undefined;
|
|
patch.failReason = undefined;
|
|
patch.blockReason = undefined;
|
|
}
|
|
|
|
if (to === 'passed' || to === 'failed' || to === 'blocked') {
|
|
patch.completedAt = nowIso;
|
|
}
|
|
|
|
if (to === 'failed' && options.failReason?.trim()) patch.failReason = options.failReason.trim();
|
|
if (to === 'blocked' && options.blockReason?.trim()) patch.blockReason = options.blockReason.trim();
|
|
|
|
return { ok: true, patch };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add TestCase estimate and weighted helpers**
|
|
|
|
Modify `apps/web/lib/test-case.ts`:
|
|
|
|
```ts
|
|
import { aggregateWorkEffort } from './work-effort-engine';
|
|
```
|
|
|
|
Add `estimateHours?: number`:
|
|
|
|
```ts
|
|
export interface TestCase {
|
|
// existing fields...
|
|
status: TestCaseStatus;
|
|
estimateHours?: number;
|
|
startedAt?: string;
|
|
completedAt?: string;
|
|
// existing fields...
|
|
}
|
|
```
|
|
|
|
Change status labels:
|
|
|
|
```ts
|
|
export const TEST_CASE_STATUS_LABEL: Record<TestCaseStatus, string> = {
|
|
pending: '待测试',
|
|
running: '测试中',
|
|
passed: '通过',
|
|
failed: '不通过',
|
|
blocked: '阻塞',
|
|
};
|
|
```
|
|
|
|
Update `normalizeTestCase`:
|
|
|
|
```ts
|
|
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5,
|
|
```
|
|
|
|
Add progress and estimate helpers:
|
|
|
|
```ts
|
|
export const TEST_CASE_STATUS_PROGRESS: Record<TestCaseStatus, number> = {
|
|
pending: 0,
|
|
running: 50,
|
|
passed: 100,
|
|
failed: 100,
|
|
blocked: 100,
|
|
};
|
|
|
|
export function getTestCaseEstimateHours(tc: TestCase): number {
|
|
return typeof tc.estimateHours === 'number' && tc.estimateHours > 0
|
|
? Math.round(tc.estimateHours * 2) / 2
|
|
: 0.5;
|
|
}
|
|
|
|
export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } {
|
|
const summary = aggregateWorkEffort(cases.map((c) => ({
|
|
estimateHours: getTestCaseEstimateHours(c),
|
|
actualHours: getTestCaseActualHours(c, now),
|
|
progress: TEST_CASE_STATUS_PROGRESS[c.status],
|
|
})));
|
|
return { estimate: summary.estimateHours, actual: summary.actualHours };
|
|
}
|
|
```
|
|
|
|
Update `calcTestProgress` to keep the existing return shape but make `completionRate` weighted:
|
|
|
|
```ts
|
|
export function calcTestProgress(cases: TestCase[]): { total: number; executed: number; passed: number; failed: number; blocked: number; passRate: number; completionRate: number } {
|
|
const total = cases.length;
|
|
if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 };
|
|
const passed = cases.filter((c) => c.status === 'passed').length;
|
|
const failed = cases.filter((c) => c.status === 'failed').length;
|
|
const blocked = cases.filter((c) => c.status === 'blocked').length;
|
|
const executed = passed + failed + blocked;
|
|
const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0;
|
|
const effort = aggregateWorkEffort(cases.map((c) => ({
|
|
estimateHours: getTestCaseEstimateHours(c),
|
|
actualHours: getTestCaseActualHours(c),
|
|
progress: TEST_CASE_STATUS_PROGRESS[c.status],
|
|
})));
|
|
return { total, executed, passed, failed, blocked, passRate, completionRate: effort.progress };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Update TestCase store to use workflow**
|
|
|
|
Modify `apps/web/stores/useTestCaseStore.ts` imports:
|
|
|
|
```ts
|
|
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
|
|
```
|
|
|
|
Update `createTestCase`:
|
|
|
|
```ts
|
|
const tc: TestCase = normalizeTestCaseOnCreate({
|
|
...data,
|
|
id: `tc-${Date.now()}`,
|
|
caseNo: generateCaseNo(list),
|
|
status: 'pending',
|
|
estimateHours: data.estimateHours ?? 0.5,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
} as TestCase);
|
|
```
|
|
|
|
Update `changeStatus`:
|
|
|
|
```ts
|
|
changeStatus: (id, to, extra) => {
|
|
const tc = get().testCases.find((c) => c.id === id);
|
|
if (!tc) return { ok: false, message: '用例不存在' };
|
|
const result = applyTestCaseTransition(tc, to, {
|
|
now: new Date(),
|
|
failReason: extra?.failReason,
|
|
blockReason: extra?.blockReason,
|
|
});
|
|
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
|
get().updateTestCase(id, result.patch);
|
|
return { ok: true };
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 6: Run verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
pnpm --filter web type-check
|
|
```
|
|
|
|
Expected: both pass.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add apps/web/lib/test-case-workflow.ts apps/web/lib/test-case-workflow.test.ts apps/web/lib/test-case.ts apps/web/stores/useTestCaseStore.ts
|
|
git commit -m "feat(测试用例): 状态流转接入单条工作流"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Add Bug Workflow Helper
|
|
|
|
**Files:**
|
|
- Create: `apps/web/lib/bug-workflow.ts`
|
|
- Create: `apps/web/lib/bug-workflow.test.ts`
|
|
- Modify: `apps/web/stores/useBugStore.ts`
|
|
|
|
- [ ] **Step 1: Write failing Bug workflow tests**
|
|
|
|
Create `apps/web/lib/bug-workflow.test.ts`:
|
|
|
|
```ts
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import type { Bug } from './bug';
|
|
import { applyBugTransition } from './bug-workflow';
|
|
|
|
function bug(patch: Partial<Bug> = {}): Bug {
|
|
return {
|
|
id: 'bug-1',
|
|
bugNo: 'BUG-001',
|
|
versionId: 'version-1',
|
|
testCaseId: 'tc-1',
|
|
title: '排序未保存',
|
|
description: '拖拽后刷新丢失',
|
|
severity: 'major',
|
|
priority: 'P1',
|
|
reportedBy: 'QA',
|
|
assigneeId: 'Dev',
|
|
status: 'open',
|
|
logs: [],
|
|
createdAt: '2026-06-25T00:00:00.000Z',
|
|
updatedAt: '2026-06-25T00:00:00.000Z',
|
|
...patch,
|
|
};
|
|
}
|
|
|
|
test('open to fixing changes only one bug patch', () => {
|
|
const result = applyBugTransition(bug(), 'fixing', 'Dev', {
|
|
now: new Date('2026-06-25T01:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.patch?.status, 'fixing');
|
|
assert.equal(result.patch?.logs?.length, 1);
|
|
});
|
|
|
|
test('fixing to fixed writes resolvedAt and resolution', () => {
|
|
const result = applyBugTransition(bug({ status: 'fixing' }), 'fixed', 'Dev', {
|
|
now: new Date('2026-06-25T02:00:00.000Z'),
|
|
resolution: '补充保存接口',
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.patch?.resolvedAt, '2026-06-25T02:00:00.000Z');
|
|
assert.equal(result.patch?.resolution, '补充保存接口');
|
|
});
|
|
|
|
test('verifying to closed writes closedAt', () => {
|
|
const result = applyBugTransition(bug({ status: 'verifying' }), 'closed', 'QA', {
|
|
now: new Date('2026-06-25T03:00:00.000Z'),
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.patch?.closedAt, '2026-06-25T03:00:00.000Z');
|
|
});
|
|
|
|
test('invalid bug transition is rejected', () => {
|
|
const result = applyBugTransition(bug(), 'closed', 'QA');
|
|
assert.equal(result.ok, false);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test and confirm failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
```
|
|
|
|
Expected: FAIL because `bug-workflow.ts` does not exist.
|
|
|
|
- [ ] **Step 3: Implement Bug workflow helper**
|
|
|
|
Create `apps/web/lib/bug-workflow.ts`:
|
|
|
|
```ts
|
|
import type { Bug, BugLog, BugStatus } from './bug';
|
|
import { canBugTransition } from './bug';
|
|
|
|
export interface BugTransitionOptions {
|
|
now?: Date;
|
|
resolution?: string;
|
|
}
|
|
|
|
export interface BugWorkflowResult {
|
|
ok: boolean;
|
|
patch?: Partial<Bug>;
|
|
message?: string;
|
|
}
|
|
|
|
function makeBugLog(
|
|
action: BugLog['action'],
|
|
operator: string,
|
|
nowIso: string,
|
|
from?: string,
|
|
to?: string,
|
|
remark?: string,
|
|
): BugLog {
|
|
return {
|
|
id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
action,
|
|
fromValue: from,
|
|
toValue: to,
|
|
operator,
|
|
remark,
|
|
createdAt: nowIso,
|
|
};
|
|
}
|
|
|
|
export function applyBugTransition(
|
|
bug: Bug,
|
|
to: BugStatus,
|
|
operator: string,
|
|
options: BugTransitionOptions = {},
|
|
): BugWorkflowResult {
|
|
if (!canBugTransition(bug.status, to)) {
|
|
return { ok: false, message: `不允许从「${bug.status}」流转到「${to}」` };
|
|
}
|
|
|
|
const nowIso = (options.now ?? new Date()).toISOString();
|
|
const patch: Partial<Bug> = { status: to };
|
|
|
|
if (to === 'fixed') patch.resolvedAt = nowIso;
|
|
if (to === 'closed') patch.closedAt = nowIso;
|
|
if (options.resolution?.trim()) patch.resolution = options.resolution.trim();
|
|
|
|
const log = makeBugLog(
|
|
to === 'fixed' ? 'resolve' : 'status_change',
|
|
operator,
|
|
nowIso,
|
|
bug.status,
|
|
to,
|
|
options.resolution,
|
|
);
|
|
patch.logs = [...(bug.logs || []), log];
|
|
|
|
return { ok: true, patch };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Use workflow in Bug store**
|
|
|
|
Modify `apps/web/stores/useBugStore.ts` imports:
|
|
|
|
```ts
|
|
import { applyBugTransition } from '@/lib/bug-workflow';
|
|
```
|
|
|
|
Keep local `makeLog` for create/transfer, or rename it to `makeStoreLog` so it does not conflict.
|
|
|
|
Replace `changeStatus`:
|
|
|
|
```ts
|
|
changeStatus: (id, to, operator, extra) => {
|
|
const bug = get().bugs.find((b) => b.id === id);
|
|
if (!bug) return { ok: false, message: 'Bug不存在' };
|
|
const result = applyBugTransition(bug, to, operator, {
|
|
now: new Date(),
|
|
resolution: extra?.resolution,
|
|
});
|
|
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
|
get().updateBug(id, result.patch);
|
|
return { ok: true };
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 5: Run verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
pnpm --filter web type-check
|
|
```
|
|
|
|
Expected: both pass.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add apps/web/lib/bug-workflow.ts apps/web/lib/bug-workflow.test.ts apps/web/stores/useBugStore.ts
|
|
git commit -m "feat(Bug): 状态流转接入单条工作流"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add Strict AI Estimation Policy
|
|
|
|
**Files:**
|
|
- Create: `apps/web/lib/ai-estimation-policy.ts`
|
|
- Create: `apps/web/lib/ai-estimation-policy.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing estimate policy tests**
|
|
|
|
Create `apps/web/lib/ai-estimation-policy.test.ts`:
|
|
|
|
```ts
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { clampDevEstimateHours, clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from './ai-estimation-policy';
|
|
|
|
test('clamps simple frontend interaction to AI-assisted range', () => {
|
|
assert.equal(clampDevEstimateHours('frontend_interaction', 5), 1);
|
|
assert.equal(clampDevEstimateHours('frontend_interaction', 0.1), 0.5);
|
|
});
|
|
|
|
test('keeps backend API within strict range', () => {
|
|
assert.equal(clampDevEstimateHours('backend_api', 0.25), 0.75);
|
|
assert.equal(clampDevEstimateHours('backend_api', 2), 1.5);
|
|
});
|
|
|
|
test('defaults and clamps test case estimates', () => {
|
|
assert.equal(getDefaultTestCaseEstimateHours('test_functional'), 0.5);
|
|
assert.equal(clampTestCaseEstimateHours('test_functional', 2), 0.5);
|
|
assert.equal(clampTestCaseEstimateHours('test_api', 0.25), 0.5);
|
|
assert.equal(clampTestCaseEstimateHours('test_exception', 2), 1);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test and confirm failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
```
|
|
|
|
Expected: FAIL because `ai-estimation-policy.ts` does not exist.
|
|
|
|
- [ ] **Step 3: Implement estimate policy**
|
|
|
|
Create `apps/web/lib/ai-estimation-policy.ts`:
|
|
|
|
```ts
|
|
import type { AgentTaskCategoryCode } from '@ftb/shared';
|
|
|
|
type EstimateRange = { min: number; max: number; fallback: number };
|
|
|
|
const DEV_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
|
frontend_development: { min: 0.5, max: 1.5, fallback: 1 },
|
|
frontend_interaction: { min: 0.5, max: 1, fallback: 0.5 },
|
|
backend_development: { min: 1, max: 3, fallback: 2 },
|
|
backend_api: { min: 0.75, max: 1.5, fallback: 1 },
|
|
database_schema: { min: 0.5, max: 0.5, fallback: 0.5 },
|
|
api_integration: { min: 0.75, max: 1.5, fallback: 1 },
|
|
data_processing: { min: 1, max: 2, fallback: 1.5 },
|
|
implementation_support: { min: 0.5, max: 1.5, fallback: 1 },
|
|
documentation: { min: 0.25, max: 0.5, fallback: 0.5 },
|
|
};
|
|
|
|
const TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
|
test_functional: { min: 0.25, max: 0.5, fallback: 0.5 },
|
|
test_api: { min: 0.5, max: 1, fallback: 0.5 },
|
|
test_exception: { min: 0.5, max: 1, fallback: 0.5 },
|
|
test_compatibility: { min: 0.5, max: 1, fallback: 1 },
|
|
};
|
|
|
|
function roundQuarterHour(hours: number): number {
|
|
return Math.round(hours * 4) / 4;
|
|
}
|
|
|
|
function clampToRange(raw: number | undefined, range: EstimateRange): number {
|
|
const base = typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : range.fallback;
|
|
return roundQuarterHour(Math.min(range.max, Math.max(range.min, base)));
|
|
}
|
|
|
|
export function clampDevEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
|
return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.5, max: 2, fallback: 1 });
|
|
}
|
|
|
|
export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
|
|
return (TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional).fallback;
|
|
}
|
|
|
|
export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
|
return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web test
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/web/lib/ai-estimation-policy.ts apps/web/lib/ai-estimation-policy.test.ts
|
|
git commit -m "feat(AI): 增加严格估时策略"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Update TestCase UI for Estimates and Single-Item Flow
|
|
|
|
**Files:**
|
|
- 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 estimate input to TestCase create modal**
|
|
|
|
Modify `apps/web/components/test-case/TestCaseCreateModal.tsx` imports:
|
|
|
|
```ts
|
|
import { clampTestCaseEstimateHours, getDefaultTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
|
|
import { findCategoryByCode } from '@/lib/task-category';
|
|
```
|
|
|
|
Add state after `categoryId`:
|
|
|
|
```ts
|
|
const selectedCategory = useMemo(
|
|
() => categories.find((category) => category.id === categoryId),
|
|
[categories, categoryId],
|
|
);
|
|
const [estimateHours, setEstimateHours] = useState(0.5);
|
|
```
|
|
|
|
Add a category change handler:
|
|
|
|
```ts
|
|
const handleCategoryChange = (nextCategoryId: string) => {
|
|
setCategoryId(nextCategoryId);
|
|
const category = categories.find((c) => c.id === nextCategoryId);
|
|
setEstimateHours(getDefaultTestCaseEstimateHours(category?.code));
|
|
};
|
|
```
|
|
|
|
Use it in the task type select:
|
|
|
|
```tsx
|
|
<select value={categoryId} onChange={(e) => handleCategoryChange(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">
|
|
```
|
|
|
|
Add estimate input under task type:
|
|
|
|
```tsx
|
|
<div>
|
|
<label className="block text-[12px] text-[var(--ink-soft)] mb-1">预估耗时 *</label>
|
|
<input
|
|
type="number"
|
|
min="0.25"
|
|
max="2"
|
|
step="0.25"
|
|
value={estimateHours}
|
|
onChange={(e) => setEstimateHours(Number(e.target.value))}
|
|
onBlur={() => setEstimateHours(clampTestCaseEstimateHours(selectedCategory?.code, estimateHours))}
|
|
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"
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
Pass estimate to store:
|
|
|
|
```ts
|
|
estimateHours: clampTestCaseEstimateHours(selectedCategory?.code, estimateHours),
|
|
```
|
|
|
|
- [ ] **Step 2: Update TestCase tab stats**
|
|
|
|
Modify imports in `apps/web/components/test-case/TestCaseTab.tsx`:
|
|
|
|
```ts
|
|
import { aggregateTestCaseHours, calcTestProgress, TEST_CASE_STATUS_LABEL, testCaseIntervals } from '@/lib/test-case';
|
|
```
|
|
|
|
Add:
|
|
|
|
```ts
|
|
const { estimate: tcEstimateHours, actual: tcActualHours } = aggregateTestCaseHours(versionCases);
|
|
```
|
|
|
|
Replace the stat text:
|
|
|
|
```tsx
|
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">
|
|
{stats.total}例 · 通过{stats.passed} · 不通过{stats.failed} · 预 {formatWorkHours(tcEstimateHours)} / 实 {formatWorkHours(tcActualHours)}
|
|
</span>
|
|
```
|
|
|
|
Keep calendar/person metrics:
|
|
|
|
```tsx
|
|
{tcManhours > 0 && (
|
|
<span className="text-[11px] text-[var(--ink-muted)] shrink-0">
|
|
日历 {formatWorkHours(tcCalendarHours)} / 人力 {formatWorkHours(tcManhours)}
|
|
</span>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 3: Update TestCase row hours display**
|
|
|
|
Modify `apps/web/components/test-case/TestCaseRow.tsx`:
|
|
|
|
```ts
|
|
import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
|
|
```
|
|
|
|
Inside component:
|
|
|
|
```ts
|
|
const estimateHours = getTestCaseEstimateHours(testCase);
|
|
const actualHours = getTestCaseActualHours(testCase);
|
|
```
|
|
|
|
Replace hours render:
|
|
|
|
```tsx
|
|
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-32 text-right shrink-0 whitespace-nowrap">
|
|
{actualHours > 0 ? `${formatWorkHours(actualHours)} / ${formatWorkHours(estimateHours)}` : `预 ${formatWorkHours(estimateHours)}`}
|
|
</span>
|
|
```
|
|
|
|
- [ ] **Step 4: Update TestCase drawer time fields**
|
|
|
|
Modify `apps/web/components/test-case/TestCaseDetailDrawer.tsx` imports:
|
|
|
|
```ts
|
|
import { getTestCaseActualHours, getTestCaseEstimateHours } from '@/lib/test-case';
|
|
import { formatWorkHours } from '@/lib/work-hours';
|
|
```
|
|
|
|
Remove:
|
|
|
|
```ts
|
|
import { calcActualHoursByDates } from '@/lib/dev-task';
|
|
```
|
|
|
|
Add:
|
|
|
|
```ts
|
|
const estimateHours = getTestCaseEstimateHours(tc);
|
|
const actualHours = getTestCaseActualHours(tc);
|
|
```
|
|
|
|
Replace the testing duration line:
|
|
|
|
```tsx
|
|
<div><span className="text-[var(--ink-muted)]">预估耗时:</span><span className="text-[var(--ink)] font-medium">{formatWorkHours(estimateHours)}</span></div>
|
|
{tc.startedAt && <div><span className="text-[var(--ink-muted)]">实际耗时:</span><span className="text-[var(--ink)] font-medium">{formatWorkHours(actualHours)}</span></div>}
|
|
```
|
|
|
|
- [ ] **Step 5: Verify no batch status actions exist**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "batch|全部通过|全部不通过|全部提测|forEach\\(.*changeStatus|changeStatus\\(.*forEach" apps/web/components apps/web/stores
|
|
```
|
|
|
|
Expected: any result is either batch delete or plain display copy. No batch status transition remains.
|
|
|
|
- [ ] **Step 6: Run verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter web type-check
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add 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 7: Update AI Contract and Adoption Estimates
|
|
|
|
**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 TestCase draft type**
|
|
|
|
Modify `packages/shared/src/agent.ts`:
|
|
|
|
```ts
|
|
export interface AgentTestCaseDraft {
|
|
title: string;
|
|
description: string;
|
|
categoryCode: AgentTaskCategoryCode;
|
|
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
|
estimateHours: number;
|
|
references: AgentReference[];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Tighten prompt estimate policy**
|
|
|
|
Modify `apps/server/src/modules/ai/prompts/decompose.ts` section `5. 工时估算(小时)` to:
|
|
|
|
```ts
|
|
5. 工时估算(小时,按团队使用 AI 辅助研发/测试估算,必须偏严格)
|
|
- 简单前端字段、文案、展示调整: 0.25-0.5h
|
|
- 简单前端交互,如拖拽排序 UI、开关、筛选项: 0.5-1h
|
|
- 拖拽排序并需要持久化接口: 1-1.5h
|
|
- 简单 CRUD 接口: 0.75-1.5h
|
|
- 数据库字段/索引调整: 0.5h
|
|
- 中等业务规则变更: 1.5-3h
|
|
- 简单功能测试用例执行: 0.25-0.5h
|
|
- API/异常/兼容性测试用例执行: 0.5-1h
|
|
- 只有跨端同步、复杂权限、历史数据迁移、强一致性、复杂兼容性时,才允许超过上述区间
|
|
```
|
|
|
|
Add TestCase schema property:
|
|
|
|
```ts
|
|
estimateHours: { type: 'number' },
|
|
```
|
|
|
|
Update TestCase required array:
|
|
|
|
```ts
|
|
required: ['title', 'description', 'categoryCode', 'priority', 'estimateHours', 'references'],
|
|
```
|
|
|
|
- [ ] **Step 3: Update server schema test**
|
|
|
|
Modify `apps/server/src/modules/ai/ai.service.spec.ts`:
|
|
|
|
```ts
|
|
it('requires estimateHours for test case drafts', () => {
|
|
const testCaseRequired = (DECOMPOSE_TOOL_INPUT_SCHEMA.properties.testCaseDrafts as any).items.required;
|
|
assert(testCaseRequired.includes('estimateHours'));
|
|
});
|
|
```
|
|
|
|
Use the existing assertion style in that file. If the file uses Jest `expect`, write:
|
|
|
|
```ts
|
|
expect(testCaseRequired).toContain('estimateHours');
|
|
```
|
|
|
|
- [ ] **Step 4: Clamp estimates when adopting AI drafts**
|
|
|
|
Modify `apps/web/components/version/DecomposeReportModal.tsx` imports:
|
|
|
|
```ts
|
|
import { addWorkHours } from '@/lib/work-hours';
|
|
import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
|
|
```
|
|
|
|
Replace AI dev estimate logic:
|
|
|
|
```ts
|
|
const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
|
|
const startISO = new Date().toISOString();
|
|
const endISO = addWorkHours(startISO, estimateHours);
|
|
```
|
|
|
|
Include in `createTask`:
|
|
|
|
```ts
|
|
estimateHours,
|
|
expectedStartAt: startISO,
|
|
expectedEndAt: endISO,
|
|
actualStartAt: undefined,
|
|
actualEndAt: undefined,
|
|
status: 'todo',
|
|
```
|
|
|
|
For test cases:
|
|
|
|
```ts
|
|
const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
|
|
```
|
|
|
|
Include in `createTestCase`:
|
|
|
|
```ts
|
|
estimateHours,
|
|
```
|
|
|
|
Show estimate in TestCase draft preview:
|
|
|
|
```tsx
|
|
<span className="text-[10px] text-[var(--ink-muted)]">{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h</span>
|
|
```
|
|
|
|
- [ ] **Step 5: Run verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
pnpm --filter server test
|
|
pnpm --filter web test
|
|
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 Manual Smoke Check
|
|
|
|
**Files:**
|
|
- No source edits unless verification finds focused issues.
|
|
|
|
- [ ] **Step 1: Run focused automated 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: Confirm no auto-start code path remains**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "findTasksToAutoStart|deriveActualStartAt|status: 'in_progress'.*actualStartAt|autoStart|自动切换扫描" apps/web
|
|
```
|
|
|
|
Expected: no active DevTask auto-start usage remains. A historical comment is acceptable only if it is not imported or called.
|
|
|
|
- [ ] **Step 3: Confirm no batch status mutation remains**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n "全部提测|全部通过|全部不通过|batch.*status|forEach\\(.*changeStatus|changeStatus\\(.*forEach" apps/web
|
|
```
|
|
|
|
Expected: no batch status action remains. Batch delete may remain if found under delete handlers.
|
|
|
|
- [ ] **Step 4: Browser smoke check**
|
|
|
|
If the dev server is already running, open `http://localhost:3000/versions` and verify on a version detail page:
|
|
|
|
```text
|
|
1. AI adopted DevTask stays 待开发 and actual start/end are empty.
|
|
2. Clicking a single DevTask to 开发中 writes actual start only for that task.
|
|
3. Clicking a single DevTask to 已提测 writes actual end only for that task.
|
|
4. New TestCase starts 待测试, has estimateHours, and shows estimate in row/drawer.
|
|
5. Clicking a single TestCase to 测试中 writes startedAt only for that case.
|
|
6. Clicking passed/failed/blocked writes completedAt only for that case.
|
|
7. TestCase top progress shows weighted completion plus pre/actual hours.
|
|
8. Bug drawer still only changes the currently opened Bug.
|
|
```
|
|
|
|
- [ ] **Step 5: Inspect final diff**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git status --short
|
|
git diff --stat
|
|
```
|
|
|
|
Expected: only workflow/effort/AI estimate files and related UI/store files changed in this implementation series. Existing unrelated dirty files must not be reverted.
|
|
|
|
- [ ] **Step 6: Commit verification fixes if needed**
|
|
|
|
Only if Step 1-5 reveal small focused issues, fix them, rerun the failed command, then commit:
|
|
|
|
```bash
|
|
git add <focused-files>
|
|
git commit -m "fix(工作流): 修正单条流转验证问题"
|
|
```
|
|
|
|
Do not create an empty commit.
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
- Spec coverage: DevTask starts as `todo`, TestCase starts as `pending`, actual timestamps are only written by single-item workflow transitions, TestCase estimates and weighted progress are added, Bug status flow is centralized, AI estimates are stricter and clamped on adoption.
|
|
- Placeholder scan: passed. Each task includes concrete files, code, commands, and expected results.
|
|
- Type consistency: `estimateHours` is added to DevTask, TestCase, and `AgentTestCaseDraft`; helper names used by stores and UI match the definitions in earlier tasks.
|