fix(ai): 自动追加AI任务类型
This commit is contained in:
@@ -56,6 +56,7 @@ test('filters dev task drafts already adopted into existing tasks', () => {
|
||||
{
|
||||
title: '实现拖拽排序',
|
||||
description: '实现范围:支持列表拖拽调整顺序。验收点:拖拽后顺序保持一致。',
|
||||
taskTypeName: '前端交互',
|
||||
categoryCode: 'frontend_interaction',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.25,
|
||||
@@ -92,6 +93,7 @@ test('filters test case drafts already adopted into existing test cases', () =>
|
||||
{
|
||||
title: '验证拖拽排序成功',
|
||||
description: '拖拽后顺序保存',
|
||||
taskTypeName: '功能测试',
|
||||
categoryCode: 'test_functional',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.2,
|
||||
@@ -125,6 +127,7 @@ test('keeps drafts with same references but different normalized title', () => {
|
||||
{
|
||||
title: '保存拖拽排序结果',
|
||||
description: '实现范围:保存拖拽后的排序结果。验收点:刷新后顺序不丢失。',
|
||||
taskTypeName: '前端交互',
|
||||
categoryCode: 'frontend_interaction',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.5,
|
||||
@@ -138,3 +141,45 @@ test('keeps drafts with same references but different normalized title', () => {
|
||||
assert.equal(result.removedDevTaskCount, 0);
|
||||
assert.equal(result.result.devTaskDrafts.length, 1);
|
||||
});
|
||||
|
||||
test('filters adopted drafts by AI task type name even when categoryCode is unknown', () => {
|
||||
const customCategories: TaskCategory[] = [
|
||||
...categories,
|
||||
{
|
||||
id: 'cat-ai-name-card',
|
||||
code: '人员名片交互',
|
||||
name: '人员名片交互',
|
||||
group: 'development',
|
||||
sortOrder: 3,
|
||||
isSystem: false,
|
||||
},
|
||||
];
|
||||
const existingDevTasks = [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: '实现同名员工名片入口',
|
||||
categoryId: 'cat-ai-name-card',
|
||||
references: [{ type: 'prototype_note', id: 'QY0019', label: 'QY0019' }],
|
||||
},
|
||||
] as DevTask[];
|
||||
|
||||
const result = filterDuplicateDecomposeDrafts(
|
||||
makeResult({
|
||||
devTaskDrafts: [
|
||||
{
|
||||
title: '实现同名员工名片入口',
|
||||
description: '实现范围:姓名后展示名片入口。验收点:点击后展示组织详情。',
|
||||
taskTypeName: '人员名片交互',
|
||||
categoryCode: 'ai_unknown',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.5,
|
||||
references: [{ type: 'prototype_note', id: 'QY0019', label: 'QY0019' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ existingDevTasks, existingTestCases: [], categories: customCategories, requirements },
|
||||
);
|
||||
|
||||
assert.equal(result.removedDevTaskCount, 1);
|
||||
assert.equal(result.result.devTaskDrafts.length, 0);
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ function draftFingerprint(
|
||||
draft: AgentDevTaskDraft | AgentTestCaseDraft,
|
||||
input: DecomposeDedupeInput,
|
||||
): string {
|
||||
return buildFingerprint(draft.title, draft.categoryCode, draft.references, input);
|
||||
return buildFingerprint(draft.title, draftCategoryIdentity(draft, input), draft.references, input);
|
||||
}
|
||||
|
||||
function existingItemFingerprint(
|
||||
@@ -85,13 +85,25 @@ function existingItemFingerprint(
|
||||
references: Reference[],
|
||||
input: DecomposeDedupeInput,
|
||||
): string {
|
||||
const categoryCode = input.categories.find((category) => category.id === categoryId)?.code ?? categoryId;
|
||||
return buildFingerprint(title, categoryCode, references, input);
|
||||
const categoryName = input.categories.find((category) => category.id === categoryId)?.name ?? categoryId;
|
||||
return buildFingerprint(title, categoryName, references, input);
|
||||
}
|
||||
|
||||
function draftCategoryIdentity(
|
||||
draft: AgentDevTaskDraft | AgentTestCaseDraft,
|
||||
input: DecomposeDedupeInput,
|
||||
): string {
|
||||
const explicitName = draft.taskTypeName?.trim();
|
||||
if (explicitName) return explicitName;
|
||||
if (draft.categoryCode) {
|
||||
return input.categories.find((category) => category.code === draft.categoryCode)?.name ?? draft.categoryCode;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildFingerprint(
|
||||
title: string,
|
||||
categoryCode: string,
|
||||
categoryName: string,
|
||||
references: Array<AgentReference | Reference>,
|
||||
input: DecomposeDedupeInput,
|
||||
): string {
|
||||
@@ -100,7 +112,7 @@ function buildFingerprint(
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join('|');
|
||||
return `${categoryCode}::${normalizeTitle(title)}::${refKey}`;
|
||||
return `${normalizeTitle(categoryName)}::${normalizeTitle(title)}::${refKey}`;
|
||||
}
|
||||
|
||||
function normalizeTitle(title: string): string {
|
||||
@@ -122,4 +134,3 @@ function normalizeReferenceKey(
|
||||
}
|
||||
return `${ref.type}:${rawId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ function makeResult(): AgentDecomposeResult {
|
||||
{
|
||||
title: '实现登录表单',
|
||||
description: '实现范围:渲染账号密码登录表单。验收点:输入合法信息后可提交。',
|
||||
taskTypeName: '前端开发',
|
||||
categoryCode: 'frontend_development',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.5,
|
||||
@@ -21,6 +22,7 @@ function makeResult(): AgentDecomposeResult {
|
||||
{
|
||||
title: '验证登录成功',
|
||||
description: '输入正确账号密码后进入首页',
|
||||
taskTypeName: '功能测试',
|
||||
categoryCode: 'test_functional',
|
||||
priority: 'P2',
|
||||
aiEstimateHours: 0.2,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PRESET_CATEGORIES,
|
||||
findCategoryByCode,
|
||||
getDefaultCategoryByGroup,
|
||||
ensureTaskCategoryByName,
|
||||
normalizeTaskCategories,
|
||||
resolveCategoryIdFromCode,
|
||||
} from './task-category';
|
||||
@@ -47,3 +48,23 @@ test('falls back to default group category for unknown code', () => {
|
||||
test('finds category by code', () => {
|
||||
assert.equal(findCategoryByCode(PRESET_CATEGORIES, 'backend_api')?.name, '后端接口');
|
||||
});
|
||||
|
||||
test('returns existing category when AI task type name already exists in same group', () => {
|
||||
const result = ensureTaskCategoryByName(PRESET_CATEGORIES, ' 前端开发 ', 'development');
|
||||
|
||||
assert.equal(result.created, false);
|
||||
assert.equal(result.category.id, 'cat-1');
|
||||
assert.equal(result.categories.length, PRESET_CATEGORIES.length);
|
||||
});
|
||||
|
||||
test('creates non-system category for missing AI task type name', () => {
|
||||
const result = ensureTaskCategoryByName(PRESET_CATEGORIES, '人员名片交互', 'development');
|
||||
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.category.name, '人员名片交互');
|
||||
assert.equal(result.category.group, 'development');
|
||||
assert.equal(result.category.isSystem, false);
|
||||
assert.equal(result.category.code, '人员名片交互');
|
||||
assert.ok(result.category.id.startsWith('cat-ai-'));
|
||||
assert.equal(result.categories.at(-1)?.id, result.category.id);
|
||||
});
|
||||
|
||||
@@ -61,6 +61,33 @@ function slugifyCategoryName(name: string, index: number): string {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCategoryNameForMatch(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function hashCategoryName(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function uniqueCategoryCode(base: string, categories: TaskCategory[]): string {
|
||||
if (!categories.some((category) => category.code === base)) return base;
|
||||
let suffix = 2;
|
||||
while (categories.some((category) => category.code === `${base}_${suffix}`)) suffix++;
|
||||
return `${base}_${suffix}`;
|
||||
}
|
||||
|
||||
function uniqueAiCategoryId(name: string, categories: TaskCategory[]): string {
|
||||
const base = `cat-ai-${hashCategoryName(name)}-${categories.length + 1}`;
|
||||
if (!categories.some((category) => category.id === base)) return base;
|
||||
let suffix = 2;
|
||||
while (categories.some((category) => category.id === `${base}-${suffix}`)) suffix++;
|
||||
return `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
export function normalizeTaskCategory(category: Partial<TaskCategory> | undefined, index = 0): TaskCategory {
|
||||
const preset = PRESET_CATEGORIES.find((c) => c.id === category?.id);
|
||||
const name = category?.name || preset?.name || `分类 ${index + 1}`;
|
||||
@@ -103,3 +130,43 @@ export function getDefaultCategoryByGroup(categories: TaskCategory[], group: Cat
|
||||
export function resolveCategoryIdFromCode(categories: TaskCategory[], code: string | undefined, fallbackGroup: CategoryGroup): string {
|
||||
return findCategoryByCode(categories, code)?.id ?? getDefaultCategoryByGroup(categories, fallbackGroup).id;
|
||||
}
|
||||
|
||||
export function ensureTaskCategoryByName(
|
||||
categories: TaskCategory[],
|
||||
name: string | undefined,
|
||||
group: CategoryGroup,
|
||||
): { categories: TaskCategory[]; category: TaskCategory; created: boolean } {
|
||||
const normalizedCategories = normalizeTaskCategories(categories);
|
||||
const trimmedName = name?.trim() ?? '';
|
||||
if (!trimmedName) {
|
||||
return {
|
||||
categories: normalizedCategories,
|
||||
category: getDefaultCategoryByGroup(normalizedCategories, group),
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
const targetKey = normalizeCategoryNameForMatch(trimmedName);
|
||||
const existing = normalizedCategories.find(
|
||||
(category) => category.group === group && normalizeCategoryNameForMatch(category.name) === targetKey,
|
||||
);
|
||||
if (existing) {
|
||||
return { categories: normalizedCategories, category: existing, created: false };
|
||||
}
|
||||
|
||||
const codeBase = slugifyCategoryName(trimmedName, normalizedCategories.length);
|
||||
const category: TaskCategory = {
|
||||
id: uniqueAiCategoryId(trimmedName, normalizedCategories),
|
||||
code: uniqueCategoryCode(codeBase, normalizedCategories),
|
||||
name: trimmedName,
|
||||
group,
|
||||
sortOrder: Math.max(0, ...normalizedCategories.map((item) => item.sortOrder)) + 1,
|
||||
isSystem: false,
|
||||
};
|
||||
|
||||
return {
|
||||
categories: [...normalizedCategories, category],
|
||||
category,
|
||||
created: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user