fix(ai): 拆解原型无需求ID任务
This commit is contained in:
42
apps/web/lib/ai-decompose-adoption.test.ts
Normal file
42
apps/web/lib/ai-decompose-adoption.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveDecomposeDraftRequirementFields } from './ai-decompose-adoption';
|
||||
|
||||
const requirements = [
|
||||
{ id: 'req-1', code: 'REQ001', title: '案例分享基础功能' },
|
||||
{ id: 'req-2', code: 'REQ002', title: '排行榜' },
|
||||
];
|
||||
|
||||
test('keeps prototype-only drafts out of formal requirements during adoption', () => {
|
||||
const result = resolveDecomposeDraftRequirementFields(
|
||||
{
|
||||
requirementName: '预入职人员支持',
|
||||
references: [{ type: 'prototype_note', id: 'QY0020', label: 'QY0020' }],
|
||||
},
|
||||
requirements,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
requirementId: '',
|
||||
requirementName: '预入职人员支持',
|
||||
});
|
||||
});
|
||||
|
||||
test('maps formal requirement references by code during adoption', () => {
|
||||
const result = resolveDecomposeDraftRequirementFields(
|
||||
{
|
||||
requirementName: '不应覆盖正式需求',
|
||||
references: [
|
||||
{ type: 'requirement', id: 'REQ002', label: 'REQ002 排行榜' },
|
||||
{ type: 'prototype_note', id: 'QY0030', label: 'QY0030' },
|
||||
],
|
||||
},
|
||||
requirements,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
requirementId: 'req-2',
|
||||
requirementName: undefined,
|
||||
});
|
||||
});
|
||||
46
apps/web/lib/ai-decompose-adoption.ts
Normal file
46
apps/web/lib/ai-decompose-adoption.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { AgentReference } from '@ftb/shared';
|
||||
|
||||
export interface DecomposeAdoptionRequirement {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface DecomposeDraftRequirementInput {
|
||||
requirementName?: string;
|
||||
references: AgentReference[];
|
||||
}
|
||||
|
||||
export interface DecomposeDraftRequirementFields {
|
||||
requirementId: string;
|
||||
requirementName?: string;
|
||||
}
|
||||
|
||||
export function resolveDecomposeDraftRequirementFields(
|
||||
draft: DecomposeDraftRequirementInput,
|
||||
requirements: DecomposeAdoptionRequirement[],
|
||||
): DecomposeDraftRequirementFields {
|
||||
const reqRef = draft.references.find((ref) => ref.type === 'requirement');
|
||||
if (reqRef) {
|
||||
const requirement = requirements.find((item) => item.id === reqRef.id || item.code === reqRef.id);
|
||||
return {
|
||||
requirementId: requirement?.id ?? reqRef.id,
|
||||
requirementName: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requirementId: '',
|
||||
requirementName: normalizePrototypeOnlyRequirementName(draft.requirementName, draft.references),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePrototypeOnlyRequirementName(
|
||||
requirementName: string | undefined,
|
||||
references: AgentReference[],
|
||||
): string {
|
||||
const trimmed = requirementName?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
const note = references.find((ref) => ref.type === 'prototype_note');
|
||||
return note?.label?.trim() || note?.id?.trim() || '未命名需求';
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const requirements = [{ id: 'req-1', code: 'REQ001' }];
|
||||
|
||||
function makeResult(patch: Partial<AgentDecomposeResult>): AgentDecomposeResult {
|
||||
return {
|
||||
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
|
||||
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [],
|
||||
testCaseDrafts: [],
|
||||
...patch,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
const response: AgentDecomposeResponse = {
|
||||
ok: true,
|
||||
result: {
|
||||
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
|
||||
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [],
|
||||
testCaseDrafts: [],
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AgentDecomposeResult } from '@ftb/shared';
|
||||
|
||||
function makeResult(): AgentDecomposeResult {
|
||||
return {
|
||||
report: { matched: [], reqOnly: [], noteOnly: [], ambiguous: [] },
|
||||
report: { matched: [], reqOnly: [], prototypeOnly: [], ambiguous: [] },
|
||||
devTaskDrafts: [
|
||||
{
|
||||
title: '实现登录表单',
|
||||
|
||||
@@ -15,7 +15,9 @@ export interface Reference {
|
||||
export interface DevTask {
|
||||
id: string;
|
||||
taskNo: string;
|
||||
versionId?: string;
|
||||
requirementId: string;
|
||||
requirementName?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
categoryId: string;
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface TestCase {
|
||||
caseNo: string;
|
||||
versionId: string;
|
||||
requirementId?: string;
|
||||
requirementName?: string;
|
||||
roundNo?: number;
|
||||
sourceCaseId?: string;
|
||||
title: string;
|
||||
@@ -88,6 +89,7 @@ export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestC
|
||||
caseNo: testCase.caseNo || `TC-${String(index + 1).padStart(3, '0')}`,
|
||||
versionId: testCase.versionId || '',
|
||||
requirementId: testCase.requirementId,
|
||||
requirementName: testCase.requirementName,
|
||||
roundNo: getTestCaseRoundNo(testCase),
|
||||
sourceCaseId: testCase.sourceCaseId,
|
||||
title: testCase.title || `测试用例 ${index + 1}`,
|
||||
@@ -165,6 +167,7 @@ export function copyTestCaseToRound(source: TestCase, roundNo: number, createdBy
|
||||
return {
|
||||
versionId: source.versionId,
|
||||
requirementId: source.requirementId,
|
||||
requirementName: source.requirementName,
|
||||
roundNo,
|
||||
sourceCaseId: source.sourceCaseId ?? source.id,
|
||||
title: source.title,
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { calcVersionProgress } from './version-progress';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { Requirement } from './requirement';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
|
||||
@@ -64,3 +65,28 @@ test('version progress uses explicit requirement coverage before legacy complete
|
||||
|
||||
assert.equal(progress, 0);
|
||||
});
|
||||
|
||||
test('version progress includes no-requirement-id dev tasks by version id', () => {
|
||||
const noReqTask = {
|
||||
id: 'dev-no-req',
|
||||
taskNo: 'DEV-001',
|
||||
versionId: 'version-1',
|
||||
requirementId: '',
|
||||
requirementName: '预入职人员支持',
|
||||
title: '实现预入职人员选择',
|
||||
categoryId: 'frontend',
|
||||
assigneeId: '',
|
||||
priority: 'P1',
|
||||
expectedStartAt: '',
|
||||
expectedEndAt: '',
|
||||
status: 'submitted',
|
||||
isBlocked: false,
|
||||
createdBy: 'AI',
|
||||
createdAt: '2026-06-29T09:00:00.000Z',
|
||||
updatedAt: '2026-06-29T10:00:00.000Z',
|
||||
} as unknown as DevTask;
|
||||
|
||||
const progress = calcVersionProgress('version-1', [], [], [noReqTask], []);
|
||||
|
||||
assert.equal(progress, 100);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export function calcVersionProgress(
|
||||
const vPlans = plans.filter((p) => p.versionId === versionId);
|
||||
const vReqs = requirements.filter((r) => r.versionId === versionId);
|
||||
const vReqIds = new Set(vReqs.map((r) => r.id));
|
||||
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
|
||||
const vDevTasks = devTasks.filter((t) => isDevTaskInVersion(t, versionId, vReqIds));
|
||||
const vTestCases = testCases.filter((c) => c.versionId === versionId);
|
||||
|
||||
const segments: number[] = [];
|
||||
@@ -78,6 +78,11 @@ export function calcVersionProgress(
|
||||
return segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
|
||||
}
|
||||
|
||||
function isDevTaskInVersion(task: DevTask, versionId: string, requirementIds: Set<string>): boolean {
|
||||
if (task.versionId) return task.versionId === versionId;
|
||||
return requirementIds.has(task.requirementId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量为多个版本算进度,返回 versionId → 进度 (0-100) 的 Map
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getWorkspacePendingCount } from './workspace-engine';
|
||||
import { aggregateWorkItems, getWorkspacePendingCount } from './workspace-engine';
|
||||
import type { DevTask } from './dev-task';
|
||||
|
||||
test('getWorkspacePendingCount counts unfinished work items only', () => {
|
||||
const count = getWorkspacePendingCount([
|
||||
@@ -11,3 +12,37 @@ test('getWorkspacePendingCount counts unfinished work items only', () => {
|
||||
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
|
||||
test('aggregateWorkItems uses direct version id for no-requirement dev tasks', () => {
|
||||
const task = {
|
||||
id: 'dev-no-req',
|
||||
taskNo: 'DEV-001',
|
||||
versionId: 'ver-1',
|
||||
requirementId: '',
|
||||
requirementName: '预入职人员支持',
|
||||
title: '实现预入职人员选择',
|
||||
categoryId: 'frontend',
|
||||
assigneeId: '张三',
|
||||
priority: 'P1',
|
||||
expectedStartAt: '',
|
||||
expectedEndAt: '',
|
||||
status: 'todo',
|
||||
isBlocked: false,
|
||||
createdBy: 'AI',
|
||||
createdAt: '2026-06-29T09:00:00.000Z',
|
||||
updatedAt: '2026-06-29T09:00:00.000Z',
|
||||
} as DevTask;
|
||||
|
||||
const items = aggregateWorkItems(
|
||||
'张三',
|
||||
[],
|
||||
[task],
|
||||
[],
|
||||
[],
|
||||
new Map([['ver-1', { id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }]]),
|
||||
new Map(),
|
||||
);
|
||||
|
||||
assert.equal(items[0].versionId, 'ver-1');
|
||||
assert.equal(items[0].versionName, 'V1.0');
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export function aggregateWorkItems(
|
||||
|
||||
// DevTasks: assigneeId === userName
|
||||
devTasks.filter((t) => t.assigneeId === userName).forEach((t) => {
|
||||
const versionId = requirementVersionMap.get(t.requirementId) ?? '';
|
||||
const versionId = t.versionId || requirementVersionMap.get(t.requirementId) || '';
|
||||
const ver = versionMap.get(versionId);
|
||||
items.push({
|
||||
id: t.id,
|
||||
|
||||
@@ -363,3 +363,21 @@ test('buildXiaobaoWorkItems keeps all version work without current-user filterin
|
||||
assert.deepEqual(items.map((item) => item.id), ['plan-1', 'dev-1', 'tc-1', 'bug-1']);
|
||||
assert.deepEqual(items.map((item) => item.versionId), ['ver-1', 'ver-1', 'ver-1', 'ver-1']);
|
||||
});
|
||||
|
||||
test('buildXiaobaoWorkItems uses direct version id for no-requirement dev tasks', () => {
|
||||
const items = buildXiaobaoWorkItems({
|
||||
devTasks: [
|
||||
devTask({
|
||||
id: 'dev-no-req',
|
||||
versionId: 'ver-1',
|
||||
requirementId: '',
|
||||
requirementName: '预入职人员支持',
|
||||
}),
|
||||
],
|
||||
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
|
||||
requirementVersionMap: new Map(),
|
||||
});
|
||||
|
||||
assert.equal(items[0].versionId, 'ver-1');
|
||||
assert.equal(items[0].versionName, 'V1.0');
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkIt
|
||||
}
|
||||
|
||||
for (const task of input.devTasks ?? []) {
|
||||
const versionId = requirementVersionMap.get(task.requirementId) ?? '';
|
||||
const versionId = task.versionId || requirementVersionMap.get(task.requirementId) || '';
|
||||
const version = versionMap.get(versionId);
|
||||
items.push({
|
||||
id: task.id,
|
||||
|
||||
Reference in New Issue
Block a user