feat(ai): 优化拆解重跑与结果展示
This commit is contained in:
138
apps/web/lib/ai-decompose-dedupe.test.ts
Normal file
138
apps/web/lib/ai-decompose-dedupe.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { filterDuplicateDecomposeDrafts } from './ai-decompose-dedupe';
|
||||
import type { AgentDecomposeResult } from '@ftb/shared';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { TaskCategory } from './task-category';
|
||||
|
||||
const categories: TaskCategory[] = [
|
||||
{
|
||||
id: 'cat-frontend',
|
||||
code: 'frontend_interaction',
|
||||
name: '前端交互',
|
||||
group: 'development',
|
||||
sortOrder: 1,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-test-functional',
|
||||
code: 'test_functional',
|
||||
name: '功能测试',
|
||||
group: 'testing',
|
||||
sortOrder: 2,
|
||||
isSystem: true,
|
||||
},
|
||||
];
|
||||
|
||||
const requirements = [{ id: 'req-1', code: 'REQ001' }];
|
||||
|
||||
function makeResult(patch: Partial<AgentDecomposeResult>): AgentDecomposeResult {
|
||||
return {
|
||||
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [],
|
||||
testCaseDrafts: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('filters dev task drafts already adopted into existing tasks', () => {
|
||||
const existingDevTasks = [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: '实现拖拽排序',
|
||||
categoryId: 'cat-frontend',
|
||||
references: [
|
||||
{ type: 'requirement', id: 'req-1', label: 'REQ001 拖拽排序' },
|
||||
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
|
||||
],
|
||||
},
|
||||
] as DevTask[];
|
||||
|
||||
const result = filterDuplicateDecomposeDrafts(
|
||||
makeResult({
|
||||
devTaskDrafts: [
|
||||
{
|
||||
title: '实现拖拽排序',
|
||||
categoryCode: 'frontend_interaction',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.25,
|
||||
references: [
|
||||
{ type: 'requirement', id: 'REQ001', label: 'REQ001 拖拽排序' },
|
||||
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ existingDevTasks, existingTestCases: [], categories, requirements },
|
||||
);
|
||||
|
||||
assert.equal(result.removedDevTaskCount, 1);
|
||||
assert.equal(result.result.devTaskDrafts.length, 0);
|
||||
});
|
||||
|
||||
test('filters test case drafts already adopted into existing test cases', () => {
|
||||
const existingTestCases = [
|
||||
{
|
||||
id: 'tc-1',
|
||||
title: '验证拖拽排序成功',
|
||||
categoryId: 'cat-test-functional',
|
||||
references: [
|
||||
{ type: 'requirement', id: 'req-1', label: 'REQ001 拖拽排序' },
|
||||
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
|
||||
],
|
||||
},
|
||||
] as TestCase[];
|
||||
|
||||
const result = filterDuplicateDecomposeDrafts(
|
||||
makeResult({
|
||||
testCaseDrafts: [
|
||||
{
|
||||
title: '验证拖拽排序成功',
|
||||
description: '拖拽后顺序保存',
|
||||
categoryCode: 'test_functional',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.2,
|
||||
references: [
|
||||
{ type: 'requirement', id: 'REQ001', label: 'REQ001 拖拽排序' },
|
||||
{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ existingDevTasks: [], existingTestCases, categories, requirements },
|
||||
);
|
||||
|
||||
assert.equal(result.removedTestCaseCount, 1);
|
||||
assert.equal(result.result.testCaseDrafts.length, 0);
|
||||
});
|
||||
|
||||
test('keeps drafts with same references but different normalized title', () => {
|
||||
const existingDevTasks = [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: '实现拖拽排序',
|
||||
categoryId: 'cat-frontend',
|
||||
references: [{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' }],
|
||||
},
|
||||
] as DevTask[];
|
||||
|
||||
const result = filterDuplicateDecomposeDrafts(
|
||||
makeResult({
|
||||
devTaskDrafts: [
|
||||
{
|
||||
title: '保存拖拽排序结果',
|
||||
categoryCode: 'frontend_interaction',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.5,
|
||||
references: [{ type: 'prototype_note', id: 'QY0001', label: 'QY0001' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ existingDevTasks, existingTestCases: [], categories, requirements },
|
||||
);
|
||||
|
||||
assert.equal(result.removedDevTaskCount, 0);
|
||||
assert.equal(result.result.devTaskDrafts.length, 1);
|
||||
});
|
||||
125
apps/web/lib/ai-decompose-dedupe.ts
Normal file
125
apps/web/lib/ai-decompose-dedupe.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
AgentDecomposeResult,
|
||||
AgentDevTaskDraft,
|
||||
AgentReference,
|
||||
AgentTestCaseDraft,
|
||||
} from '@ftb/shared';
|
||||
import type { DevTask, Reference } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { TaskCategory } from './task-category';
|
||||
|
||||
interface RequirementIdentity {
|
||||
id: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface DecomposeDedupeInput {
|
||||
existingDevTasks: DevTask[];
|
||||
existingTestCases: TestCase[];
|
||||
categories: TaskCategory[];
|
||||
requirements: RequirementIdentity[];
|
||||
}
|
||||
|
||||
export interface DecomposeDedupeResult {
|
||||
result: AgentDecomposeResult;
|
||||
removedDevTaskCount: number;
|
||||
removedTestCaseCount: number;
|
||||
}
|
||||
|
||||
export function filterDuplicateDecomposeDrafts(
|
||||
result: AgentDecomposeResult,
|
||||
input: DecomposeDedupeInput,
|
||||
): DecomposeDedupeResult {
|
||||
const devFingerprints = new Set(
|
||||
input.existingDevTasks
|
||||
.map((task) => existingItemFingerprint(task.title, task.categoryId, task.references ?? [], input))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const testCaseFingerprints = new Set(
|
||||
input.existingTestCases
|
||||
.map((testCase) => existingItemFingerprint(testCase.title, testCase.categoryId, testCase.references ?? [], input))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const filteredDevTaskDrafts = filterDrafts(result.devTaskDrafts, devFingerprints, input, draftFingerprint);
|
||||
const filteredTestCaseDrafts = filterDrafts(result.testCaseDrafts, testCaseFingerprints, input, draftFingerprint);
|
||||
|
||||
return {
|
||||
result: {
|
||||
...result,
|
||||
devTaskDrafts: filteredDevTaskDrafts.items,
|
||||
testCaseDrafts: filteredTestCaseDrafts.items,
|
||||
},
|
||||
removedDevTaskCount: result.devTaskDrafts.length - filteredDevTaskDrafts.items.length,
|
||||
removedTestCaseCount: result.testCaseDrafts.length - filteredTestCaseDrafts.items.length,
|
||||
};
|
||||
}
|
||||
|
||||
function filterDrafts<T extends AgentDevTaskDraft | AgentTestCaseDraft>(
|
||||
drafts: T[],
|
||||
existingFingerprints: Set<string>,
|
||||
input: DecomposeDedupeInput,
|
||||
getFingerprint: (draft: T, input: DecomposeDedupeInput) => string,
|
||||
): { items: T[] } {
|
||||
const seen = new Set<string>();
|
||||
const items: T[] = [];
|
||||
for (const draft of drafts) {
|
||||
const fingerprint = getFingerprint(draft, input);
|
||||
if (existingFingerprints.has(fingerprint) || seen.has(fingerprint)) continue;
|
||||
seen.add(fingerprint);
|
||||
items.push(draft);
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
|
||||
function draftFingerprint(
|
||||
draft: AgentDevTaskDraft | AgentTestCaseDraft,
|
||||
input: DecomposeDedupeInput,
|
||||
): string {
|
||||
return buildFingerprint(draft.title, draft.categoryCode, draft.references, input);
|
||||
}
|
||||
|
||||
function existingItemFingerprint(
|
||||
title: string,
|
||||
categoryId: string,
|
||||
references: Reference[],
|
||||
input: DecomposeDedupeInput,
|
||||
): string {
|
||||
const categoryCode = input.categories.find((category) => category.id === categoryId)?.code ?? categoryId;
|
||||
return buildFingerprint(title, categoryCode, references, input);
|
||||
}
|
||||
|
||||
function buildFingerprint(
|
||||
title: string,
|
||||
categoryCode: string,
|
||||
references: Array<AgentReference | Reference>,
|
||||
input: DecomposeDedupeInput,
|
||||
): string {
|
||||
const refKey = references
|
||||
.map((ref) => normalizeReferenceKey(ref, input.requirements))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join('|');
|
||||
return `${categoryCode}::${normalizeTitle(title)}::${refKey}`;
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s::,,.。;;、\-—_]/g, '');
|
||||
}
|
||||
|
||||
function normalizeReferenceKey(
|
||||
ref: AgentReference | Reference,
|
||||
requirements: RequirementIdentity[],
|
||||
): string {
|
||||
const rawId = String(ref.id || '').trim();
|
||||
if (!rawId) return '';
|
||||
if (ref.type === 'requirement') {
|
||||
const matched = requirements.find((requirement) => requirement.id === rawId || requirement.code === rawId);
|
||||
return `requirement:${matched?.id ?? rawId}`;
|
||||
}
|
||||
return `${ref.type}:${rawId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
32
apps/web/lib/ai-decompose-report.test.ts
Normal file
32
apps/web/lib/ai-decompose-report.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { formatReportRequirementLabel } from './ai-decompose-report';
|
||||
|
||||
const requirements = [
|
||||
{
|
||||
id: 'rea-17823017283970',
|
||||
code: 'REQ0010',
|
||||
title: '支持主题拖拽排序',
|
||||
description: '用户可以调整主题顺序',
|
||||
},
|
||||
];
|
||||
|
||||
test('formatReportRequirementLabel resolves internal requirement id to code and title', () => {
|
||||
assert.equal(
|
||||
formatReportRequirementLabel('rea-17823017283970', requirements),
|
||||
'REQ0010 支持主题拖拽排序',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatReportRequirementLabel resolves requirement code to code and title', () => {
|
||||
assert.equal(
|
||||
formatReportRequirementLabel('REQ0010', requirements),
|
||||
'REQ0010 支持主题拖拽排序',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatReportRequirementLabel falls back to raw id when requirement is unknown', () => {
|
||||
assert.equal(formatReportRequirementLabel('rea-missing', requirements), 'rea-missing');
|
||||
});
|
||||
|
||||
13
apps/web/lib/ai-decompose-report.ts
Normal file
13
apps/web/lib/ai-decompose-report.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface ReportRequirement {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function formatReportRequirementLabel(reqId: string, requirements: ReportRequirement[]): string {
|
||||
const requirement = requirements.find((item) => item.id === reqId || item.code === reqId);
|
||||
if (!requirement) return reqId;
|
||||
return `${requirement.code} ${requirement.title}`;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,25 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
clampDevEstimateHours,
|
||||
clampTestCaseEstimateHours,
|
||||
getDefaultTestCaseEstimateHours,
|
||||
clampDevAiEstimateHours,
|
||||
clampTestCaseAiEstimateHours,
|
||||
getDefaultTestCaseAiEstimateHours,
|
||||
} 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);
|
||||
assert.equal(clampDevAiEstimateHours('frontend_interaction', 5), 0.5);
|
||||
assert.equal(clampDevAiEstimateHours('frontend_interaction', 0.1), 0.25);
|
||||
});
|
||||
|
||||
test('keeps backend API within strict range', () => {
|
||||
assert.equal(clampDevEstimateHours('backend_api', 0.25), 0.75);
|
||||
assert.equal(clampDevEstimateHours('backend_api', 2), 1.5);
|
||||
assert.equal(clampDevAiEstimateHours('backend_api', 0.25), 0.5);
|
||||
assert.equal(clampDevAiEstimateHours('backend_api', 2), 1);
|
||||
});
|
||||
|
||||
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);
|
||||
test('defaults and clamps test case AI estimates below half an hour when appropriate', () => {
|
||||
assert.equal(getDefaultTestCaseAiEstimateHours('test_functional'), 0.2);
|
||||
assert.equal(clampTestCaseAiEstimateHours('test_functional', 2), 0.3);
|
||||
assert.equal(clampTestCaseAiEstimateHours('test_functional', 0.05), 0.1);
|
||||
assert.equal(clampTestCaseAiEstimateHours('test_api', 0.1), 0.2);
|
||||
assert.equal(clampTestCaseAiEstimateHours('test_exception', 2), 0.5);
|
||||
});
|
||||
|
||||
@@ -3,41 +3,58 @@ 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 },
|
||||
frontend_development: { min: 0.25, max: 1, fallback: 0.5 },
|
||||
frontend_interaction: { min: 0.25, max: 0.5, fallback: 0.25 },
|
||||
backend_development: { min: 0.75, max: 2, fallback: 1.25 },
|
||||
backend_api: { min: 0.5, max: 1, fallback: 0.75 },
|
||||
database_schema: { min: 0.25, max: 0.5, fallback: 0.25 },
|
||||
api_integration: { min: 0.5, max: 1.25, fallback: 0.75 },
|
||||
data_processing: { min: 0.75, max: 1.5, fallback: 1 },
|
||||
implementation_support: { min: 0.25, max: 1, fallback: 0.5 },
|
||||
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 },
|
||||
test_functional: { min: 0.1, max: 0.3, fallback: 0.2 },
|
||||
test_api: { min: 0.2, max: 0.5, fallback: 0.3 },
|
||||
test_exception: { min: 0.2, max: 0.5, fallback: 0.3 },
|
||||
test_compatibility: { min: 0.3, max: 0.8, fallback: 0.4 },
|
||||
};
|
||||
|
||||
function roundQuarterHour(hours: number): number {
|
||||
return Math.round(hours * 4) / 4;
|
||||
const EXECUTOR_TEST_ESTIMATE_RANGES: Record<string, EstimateRange> = {
|
||||
test_functional: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_api: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_exception: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
test_compatibility: { min: 0.25, max: 2, fallback: 0.5 },
|
||||
};
|
||||
|
||||
function roundToStep(hours: number, step: number): number {
|
||||
return Number((Math.round(hours / step) * step).toFixed(2));
|
||||
}
|
||||
|
||||
function clampToRange(raw: number | undefined, range: EstimateRange): number {
|
||||
function clampToRange(raw: number | undefined, range: EstimateRange, step: number): 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)));
|
||||
return roundToStep(Math.min(range.max, Math.max(range.min, base)), step);
|
||||
}
|
||||
|
||||
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 clampDevAiEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
||||
return clampToRange(raw, DEV_ESTIMATE_RANGES[code ?? ''] ?? { min: 0.25, max: 1.5, fallback: 0.75 }, 0.25);
|
||||
}
|
||||
|
||||
export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
|
||||
export function getDefaultTestCaseAiEstimateHours(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);
|
||||
export function clampTestCaseAiEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
||||
return clampToRange(raw, TEST_ESTIMATE_RANGES[code ?? ''] ?? TEST_ESTIMATE_RANGES.test_functional, 0.1);
|
||||
}
|
||||
|
||||
export const clampDevEstimateHours = clampDevAiEstimateHours;
|
||||
|
||||
export function getDefaultTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined): number {
|
||||
return (EXECUTOR_TEST_ESTIMATE_RANGES[code ?? ''] ?? EXECUTOR_TEST_ESTIMATE_RANGES.test_functional).fallback;
|
||||
}
|
||||
|
||||
export function clampTestCaseEstimateHours(code: AgentTaskCategoryCode | string | undefined, raw: number | undefined): number {
|
||||
return clampToRange(raw, EXECUTOR_TEST_ESTIMATE_RANGES[code ?? ''] ?? EXECUTOR_TEST_ESTIMATE_RANGES.test_functional, 0.25);
|
||||
}
|
||||
|
||||
52
apps/web/lib/dev-task.test.ts
Normal file
52
apps/web/lib/dev-task.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { getEstimateHours, type DevTask } from './dev-task';
|
||||
|
||||
function makeTask(patch: Partial<DevTask>): DevTask {
|
||||
return {
|
||||
id: 'task-1',
|
||||
taskNo: 'DEV-001',
|
||||
requirementId: 'req-1',
|
||||
title: '实现筛选',
|
||||
categoryId: 'cat-frontend',
|
||||
assigneeId: '张三',
|
||||
priority: 'P2',
|
||||
expectedStartAt: '',
|
||||
expectedEndAt: '',
|
||||
status: 'todo',
|
||||
isBlocked: false,
|
||||
createdBy: 'tester',
|
||||
createdAt: '2026-06-26T01:00:00.000Z',
|
||||
updatedAt: '2026-06-26T01:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('getEstimateHours prefers executor estimate over AI estimate', () => {
|
||||
const task = makeTask({
|
||||
estimateHours: 1.5,
|
||||
aiEstimateHours: 0.5,
|
||||
});
|
||||
|
||||
assert.equal(getEstimateHours(task), 1.5);
|
||||
});
|
||||
|
||||
test('getEstimateHours falls back to AI estimate before schedule-derived estimate', () => {
|
||||
const task = makeTask({
|
||||
aiEstimateHours: 0.25,
|
||||
expectedStartAt: '2026-06-26T01:00:00.000Z',
|
||||
expectedEndAt: '2026-06-26T03:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(getEstimateHours(task), 0.25);
|
||||
});
|
||||
|
||||
test('getEstimateHours uses schedule when no executor or AI estimate exists', () => {
|
||||
const task = makeTask({
|
||||
expectedStartAt: '2026-06-26T01:00:00.000Z',
|
||||
expectedEndAt: '2026-06-26T03:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(getEstimateHours(task), 2);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ export interface DevTask {
|
||||
expectedStartAt: string;
|
||||
expectedEndAt: string;
|
||||
estimateHours?: number;
|
||||
aiEstimateHours?: number;
|
||||
actualStartAt?: string;
|
||||
actualEndAt?: string;
|
||||
|
||||
@@ -98,12 +99,19 @@ export function formatHours(hours: number): string {
|
||||
|
||||
export function getEstimateHours(task: DevTask): number {
|
||||
if (typeof task.estimateHours === 'number' && task.estimateHours > 0) {
|
||||
return Math.round(task.estimateHours * 2) / 2;
|
||||
return roundEffortHours(task.estimateHours);
|
||||
}
|
||||
if (typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0) {
|
||||
return roundEffortHours(task.aiEstimateHours);
|
||||
}
|
||||
if (!task.expectedStartAt || !task.expectedEndAt) return 0;
|
||||
return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
|
||||
}
|
||||
|
||||
function roundEffortHours(hours: number): number {
|
||||
return Number(hours.toFixed(2));
|
||||
}
|
||||
|
||||
export function getActualHours(task: DevTask, now: Date = new Date()): number {
|
||||
if (!task.actualStartAt) return 0;
|
||||
const end = task.actualEndAt ?? now.toISOString();
|
||||
|
||||
@@ -43,7 +43,7 @@ test('normalizeTestCase keeps existing categoryId', () => {
|
||||
assert.equal(tc.categoryId, 'cat-test-api');
|
||||
});
|
||||
|
||||
test('normalizeTestCase backfills missing estimateHours', () => {
|
||||
test('normalizeTestCase keeps missing executor estimate empty', () => {
|
||||
const tc = normalizeTestCase({
|
||||
id: 'tc-1',
|
||||
caseNo: 'TC-001',
|
||||
@@ -57,8 +57,45 @@ test('normalizeTestCase backfills missing estimateHours', () => {
|
||||
updatedAt: '2026-06-25',
|
||||
} as any);
|
||||
|
||||
assert.equal(tc.estimateHours, 0.5);
|
||||
assert.equal(getTestCaseEstimateHours(tc), 0.5);
|
||||
assert.equal(tc.estimateHours, undefined);
|
||||
assert.equal(getTestCaseEstimateHours(tc), 0);
|
||||
});
|
||||
|
||||
test('getTestCaseEstimateHours prefers executor estimate over AI estimate', () => {
|
||||
const tc = normalizeTestCase({
|
||||
id: 'tc-1',
|
||||
caseNo: 'TC-001',
|
||||
versionId: 'v1',
|
||||
title: '登录正常',
|
||||
priority: 'P2',
|
||||
status: 'pending',
|
||||
categoryId: 'cat-test-api',
|
||||
estimateHours: 0.7,
|
||||
aiEstimateHours: 0.2,
|
||||
createdBy: 'tester',
|
||||
createdAt: '2026-06-25',
|
||||
updatedAt: '2026-06-25',
|
||||
} as any);
|
||||
|
||||
assert.equal(getTestCaseEstimateHours(tc), 0.7);
|
||||
});
|
||||
|
||||
test('getTestCaseEstimateHours falls back to AI estimate', () => {
|
||||
const tc = normalizeTestCase({
|
||||
id: 'tc-1',
|
||||
caseNo: 'TC-001',
|
||||
versionId: 'v1',
|
||||
title: '登录正常',
|
||||
priority: 'P2',
|
||||
status: 'pending',
|
||||
categoryId: 'cat-test-api',
|
||||
aiEstimateHours: 0.2,
|
||||
createdBy: 'tester',
|
||||
createdAt: '2026-06-25',
|
||||
updatedAt: '2026-06-25',
|
||||
} as any);
|
||||
|
||||
assert.equal(getTestCaseEstimateHours(tc), 0.2);
|
||||
});
|
||||
|
||||
test('test case status labels use waiting and testing wording', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface TestCase {
|
||||
assigneeId?: string;
|
||||
status: TestCaseStatus;
|
||||
estimateHours?: number;
|
||||
aiEstimateHours?: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
executedAt?: string;
|
||||
@@ -88,7 +89,8 @@ export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestC
|
||||
priority: testCase.priority || 'P2',
|
||||
assigneeId: testCase.assigneeId,
|
||||
status: testCase.status || 'pending',
|
||||
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5,
|
||||
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : undefined,
|
||||
aiEstimateHours: typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0 ? testCase.aiEstimateHours : undefined,
|
||||
startedAt: testCase.startedAt,
|
||||
completedAt: testCase.completedAt,
|
||||
executedAt: testCase.executedAt,
|
||||
@@ -125,9 +127,13 @@ export function calcTestProgress(cases: TestCase[]): { total: number; executed:
|
||||
}
|
||||
|
||||
export function getTestCaseEstimateHours(tc: TestCase): number {
|
||||
return typeof tc.estimateHours === 'number' && tc.estimateHours > 0
|
||||
? Math.round(tc.estimateHours * 2) / 2
|
||||
: 0.5;
|
||||
if (typeof tc.estimateHours === 'number' && tc.estimateHours > 0) {
|
||||
return Number(tc.estimateHours.toFixed(2));
|
||||
}
|
||||
if (typeof tc.aiEstimateHours === 'number' && tc.aiEstimateHours > 0) {
|
||||
return Number(tc.aiEstimateHours.toFixed(2));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
|
||||
|
||||
@@ -52,6 +52,7 @@ test('allows completion only after result exists', () => {
|
||||
resultType: 'link',
|
||||
resultTitle: '原型',
|
||||
resultUrl: 'https://example.com/prototype',
|
||||
prototypeReviewConfirmed: true,
|
||||
}));
|
||||
assert.equal(state.canComplete, true);
|
||||
});
|
||||
@@ -83,3 +84,61 @@ test('detects link and file result payloads', () => {
|
||||
assert.equal(hasPlanResult({ resultType: 'file', resultTitle: '文件', resultFileName: 'a.pdf', resultFileData: 'data:pdf' }), true);
|
||||
assert.equal(hasPlanResult({ resultType: 'link', resultTitle: '原型' }), false);
|
||||
});
|
||||
|
||||
test('product design plans require a prototype link and review confirmation', () => {
|
||||
const withoutReviewConfirmation = getPlanCompletionState(plan({
|
||||
productPlanKind: 'design',
|
||||
completedRequirementIds: ['r1'],
|
||||
resultType: 'link',
|
||||
resultTitle: 'Prototype',
|
||||
resultUrl: 'https://example.com/prototype',
|
||||
} as Partial<VersionPlan>));
|
||||
assert.equal(withoutReviewConfirmation.canComplete, false);
|
||||
|
||||
const withReviewConfirmation = getPlanCompletionState(plan({
|
||||
productPlanKind: 'design',
|
||||
completedRequirementIds: ['r1'],
|
||||
resultType: 'link',
|
||||
resultTitle: 'Prototype',
|
||||
resultUrl: 'https://example.com/prototype',
|
||||
prototypeReviewConfirmed: true,
|
||||
} as Partial<VersionPlan>));
|
||||
assert.equal(withReviewConfirmation.canComplete, true);
|
||||
});
|
||||
|
||||
test('product design plans do not accept file results', () => {
|
||||
assert.equal(hasPlanResult({
|
||||
productPlanKind: 'design',
|
||||
resultType: 'file',
|
||||
resultTitle: 'Axure file',
|
||||
resultFileName: 'prototype.rp',
|
||||
resultFileData: 'data:application/octet-stream;base64,abc',
|
||||
} as any), false);
|
||||
});
|
||||
|
||||
test('product review plans can complete with a pass result without prototype link', () => {
|
||||
const state = getPlanCompletionState(plan({
|
||||
productPlanKind: 'review',
|
||||
completedRequirementIds: ['r1'],
|
||||
reviewResult: 'passed',
|
||||
} as Partial<VersionPlan>));
|
||||
assert.equal(state.canComplete, true);
|
||||
});
|
||||
|
||||
test('product review plans require failure type and detail when review fails', () => {
|
||||
const missingFailureDetail = getPlanCompletionState(plan({
|
||||
productPlanKind: 'review',
|
||||
completedRequirementIds: ['r1'],
|
||||
reviewResult: 'failed',
|
||||
} as Partial<VersionPlan>));
|
||||
assert.equal(missingFailureDetail.canComplete, false);
|
||||
|
||||
const completeFailureDetail = getPlanCompletionState(plan({
|
||||
productPlanKind: 'review',
|
||||
completedRequirementIds: ['r1'],
|
||||
reviewResult: 'failed',
|
||||
reviewFailureTypes: ['interaction_flow'],
|
||||
reviewFailureReason: 'Critical path is missing empty and rollback states.',
|
||||
} as Partial<VersionPlan>));
|
||||
assert.equal(completeFailureDetail.canComplete, true);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from './version-plan';
|
||||
|
||||
export interface PlanResultPayload {
|
||||
type?: VersionPlan['type'];
|
||||
productPlanKind?: ProductPlanKind;
|
||||
resultType?: 'link' | 'file';
|
||||
resultTitle?: string;
|
||||
resultUrl?: string;
|
||||
resultFileName?: string;
|
||||
resultFileData?: string;
|
||||
prototypeReviewConfirmed?: boolean;
|
||||
reviewResult?: ProductPlanReviewResult;
|
||||
reviewFailureTypes?: ProductPlanReviewFailureType[];
|
||||
reviewFailureReason?: string;
|
||||
}
|
||||
|
||||
export interface PlanCompletionState {
|
||||
@@ -20,6 +26,23 @@ export interface PlanCompletionState {
|
||||
}
|
||||
|
||||
export function hasPlanResult(plan: PlanResultPayload): boolean {
|
||||
if (plan.productPlanKind === 'review') {
|
||||
if (plan.reviewResult === 'passed') return true;
|
||||
if (plan.reviewResult === 'failed') {
|
||||
return Boolean(plan.reviewFailureTypes?.length && plan.reviewFailureReason?.trim());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (plan.productPlanKind === 'design') {
|
||||
return Boolean(
|
||||
plan.resultType === 'link'
|
||||
&& plan.resultTitle?.trim()
|
||||
&& plan.resultUrl?.trim()
|
||||
&& plan.prototypeReviewConfirmed,
|
||||
);
|
||||
}
|
||||
|
||||
const hasTitle = Boolean(plan.resultTitle?.trim());
|
||||
if (!hasTitle || !plan.resultType) return false;
|
||||
if (plan.resultType === 'link') return Boolean(plan.resultUrl?.trim());
|
||||
@@ -34,6 +57,11 @@ function requiresChecklist(plan: VersionPlan): boolean {
|
||||
return plan.type === 'research';
|
||||
}
|
||||
|
||||
function getProductPlanKind(plan: VersionPlan): ProductPlanKind | undefined {
|
||||
if (plan.type !== 'product') return undefined;
|
||||
return plan.productPlanKind ?? 'design';
|
||||
}
|
||||
|
||||
export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
|
||||
const tasks = plan.tasks ?? [];
|
||||
const checklistTotal = tasks.length;
|
||||
@@ -52,8 +80,25 @@ export function getPlanCompletionState(plan: VersionPlan): PlanCompletionState {
|
||||
}
|
||||
|
||||
const canSubmitResult = missingReasons.length === 0;
|
||||
const hasResult = hasPlanResult(plan);
|
||||
if (canSubmitResult && !hasResult) missingReasons.push('尚未提交成果');
|
||||
const productPlanKind = getProductPlanKind(plan);
|
||||
const resultPlan = productPlanKind ? { ...plan, productPlanKind } : plan;
|
||||
const hasResult = hasPlanResult(resultPlan);
|
||||
if (canSubmitResult && !hasResult) {
|
||||
if (productPlanKind === 'design') {
|
||||
if (plan.resultType === 'file') missingReasons.push('产品设计方案只支持原型链接');
|
||||
if (plan.resultType === 'link' && plan.resultUrl?.trim() && !plan.prototypeReviewConfirmed) {
|
||||
missingReasons.push('请先确认方案评审已通过');
|
||||
} else {
|
||||
missingReasons.push('尚未提交原型链接');
|
||||
}
|
||||
} else if (productPlanKind === 'review') {
|
||||
if (!plan.reviewResult) missingReasons.push('请选择评审结论');
|
||||
if (plan.reviewResult === 'failed' && !plan.reviewFailureTypes?.length) missingReasons.push('请选择评审不通过类型');
|
||||
if (plan.reviewResult === 'failed' && !plan.reviewFailureReason?.trim()) missingReasons.push('请填写评审不通过原因');
|
||||
} else {
|
||||
missingReasons.push('尚未提交成果');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checklistTotal,
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
|
||||
export type ProductPlanKind = 'design' | 'review';
|
||||
export type ProductPlanReviewResult = 'passed' | 'failed';
|
||||
export type ProductPlanReviewFailureType =
|
||||
| 'requirement_mismatch'
|
||||
| 'information_architecture'
|
||||
| 'interaction_flow'
|
||||
| 'state_coverage'
|
||||
| 'edge_case_missing'
|
||||
| 'business_rule_gap'
|
||||
| 'role_permission_gap'
|
||||
| 'data_rule_gap'
|
||||
| 'copywriting_ambiguity'
|
||||
| 'risk_dependency';
|
||||
|
||||
export const PRODUCT_PLAN_KIND_LABEL: Record<ProductPlanKind, string> = {
|
||||
design: '设计方案',
|
||||
review: '方案评审',
|
||||
};
|
||||
|
||||
export const PRODUCT_PLAN_REVIEW_RESULT_LABEL: Record<ProductPlanReviewResult, string> = {
|
||||
passed: '评审通过',
|
||||
failed: '评审不通过',
|
||||
};
|
||||
|
||||
export const PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS: { value: ProductPlanReviewFailureType; label: string }[] = [
|
||||
{ value: 'requirement_mismatch', label: '需求覆盖不完整/偏离需求' },
|
||||
{ value: 'information_architecture', label: '信息架构或页面层级不清晰' },
|
||||
{ value: 'interaction_flow', label: '关键交互流程不闭环' },
|
||||
{ value: 'state_coverage', label: '缺少状态、异常或空数据场景' },
|
||||
{ value: 'edge_case_missing', label: '边界场景考虑不足' },
|
||||
{ value: 'business_rule_gap', label: '业务规则、审批或口径缺失' },
|
||||
{ value: 'role_permission_gap', label: '角色权限与可见范围不明确' },
|
||||
{ value: 'data_rule_gap', label: '字段、数据来源或计算逻辑不清楚' },
|
||||
{ value: 'copywriting_ambiguity', label: '文案表达有歧义或误导' },
|
||||
{ value: 'risk_dependency', label: '依赖、风险或上线影响未说明' },
|
||||
];
|
||||
|
||||
export interface PlanTask {
|
||||
id: string;
|
||||
@@ -18,11 +54,16 @@ export interface VersionPlan {
|
||||
tasks?: PlanTask[];
|
||||
completedRequirementIds?: string[];
|
||||
linkedRequirementIds?: string[];
|
||||
productPlanKind?: ProductPlanKind;
|
||||
resultType?: 'link' | 'file';
|
||||
resultTitle?: string;
|
||||
resultUrl?: string;
|
||||
resultFileName?: string;
|
||||
resultFileData?: string;
|
||||
prototypeReviewConfirmed?: boolean;
|
||||
reviewResult?: ProductPlanReviewResult;
|
||||
reviewFailureTypes?: ProductPlanReviewFailureType[];
|
||||
reviewFailureReason?: string;
|
||||
remark?: string;
|
||||
overdueReason?: string;
|
||||
actualStartAt?: string;
|
||||
|
||||
@@ -21,6 +21,15 @@ test('aggregateWorkEffort calculates weighted progress by estimate', () => {
|
||||
assert.equal(result.progress, 75);
|
||||
});
|
||||
|
||||
test('aggregateWorkEffort preserves sub-half-hour AI estimates', () => {
|
||||
const result = aggregateWorkEffort([
|
||||
{ estimateHours: 0.2, actualHours: 0, progress: 50 },
|
||||
]);
|
||||
|
||||
assert.equal(result.estimateHours, 0.2);
|
||||
assert.equal(result.progress, 50);
|
||||
});
|
||||
|
||||
test('aggregateWorkEffort falls back to item average when estimates are zero', () => {
|
||||
const result = aggregateWorkEffort([
|
||||
{ estimateHours: 0, actualHours: 0, progress: 50 },
|
||||
|
||||
@@ -15,10 +15,15 @@ export function roundHalfHour(hours: number): number {
|
||||
return Math.round(hours * 2) / 2;
|
||||
}
|
||||
|
||||
export function roundEstimateHours(hours: number): number {
|
||||
if (!Number.isFinite(hours) || hours <= 0) return 0;
|
||||
return Number(hours.toFixed(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 estimateHours = roundEstimateHours(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) {
|
||||
|
||||
Reference in New Issue
Block a user