304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers } from './member-recommendation';
|
|
import type { DevTask } from './dev-task';
|
|
import type { Requirement } from './requirement';
|
|
import type { TestCase } from './test-case';
|
|
import { PRESET_CATEGORIES } from './task-category';
|
|
|
|
function devTask(patch: Partial<DevTask>): DevTask {
|
|
return {
|
|
id: patch.id || 'dev-1',
|
|
taskNo: patch.taskNo || 'DEV-001',
|
|
requirementId: patch.requirementId || 'req-1',
|
|
title: patch.title || 'Dev task',
|
|
categoryId: patch.categoryId || 'cat-1',
|
|
assigneeId: patch.assigneeId || 'Backend A',
|
|
priority: patch.priority || 'P2',
|
|
expectedStartAt: patch.expectedStartAt || '2026-06-24T09:00:00',
|
|
expectedEndAt: patch.expectedEndAt || '2026-06-24T18:00:00',
|
|
estimateHours: patch.estimateHours,
|
|
aiEstimateHours: patch.aiEstimateHours,
|
|
actualStartAt: patch.actualStartAt,
|
|
actualEndAt: patch.actualEndAt,
|
|
status: patch.status || 'in_progress',
|
|
isBlocked: patch.isBlocked ?? false,
|
|
createdBy: patch.createdBy || 'PM',
|
|
createdAt: patch.createdAt || '2026-06-23T09:00:00',
|
|
updatedAt: patch.updatedAt || '2026-06-24T10:00:00',
|
|
};
|
|
}
|
|
|
|
function requirement(patch: Partial<Requirement>): Requirement {
|
|
return {
|
|
id: patch.id || 'req-1',
|
|
code: patch.code || 'REQ-001',
|
|
title: patch.title || 'Requirement',
|
|
description: patch.description || 'Requirement description',
|
|
productId: patch.productId || 'prod-1',
|
|
projectId: patch.projectId || 'proj-1',
|
|
versionId: patch.versionId || 'ver-1',
|
|
sourceType: patch.sourceType || 'internal',
|
|
sourceTarget: patch.sourceTarget || 'Product',
|
|
platforms: patch.platforms || ['web'],
|
|
typeId: patch.typeId || 'type-1',
|
|
status: patch.status || 'planned',
|
|
priority: patch.priority || 'P2',
|
|
effort: patch.effort || 'M',
|
|
productOwner: patch.productOwner || 'PM',
|
|
creator: patch.creator || 'PM',
|
|
createdAt: patch.createdAt || '2026-06-23',
|
|
};
|
|
}
|
|
|
|
function testCase(patch: Partial<TestCase>): TestCase {
|
|
return {
|
|
id: patch.id || 'tc-1',
|
|
caseNo: patch.caseNo || 'TC-001',
|
|
versionId: patch.versionId || 'ver-1',
|
|
requirementId: patch.requirementId,
|
|
title: patch.title || 'Test case',
|
|
categoryId: patch.categoryId || 'cat-test-functional',
|
|
priority: patch.priority || 'P2',
|
|
assigneeId: patch.assigneeId,
|
|
status: patch.status || 'pending',
|
|
estimateHours: patch.estimateHours,
|
|
aiEstimateHours: patch.aiEstimateHours,
|
|
startedAt: patch.startedAt,
|
|
completedAt: patch.completedAt,
|
|
createdBy: patch.createdBy || 'QA',
|
|
createdAt: patch.createdAt || '2026-06-23T09:00:00',
|
|
updatedAt: patch.updatedAt || '2026-06-24T10:00:00',
|
|
};
|
|
}
|
|
|
|
test('recommendVersionMembers only recommends frontend backend and testing candidates outside current members', () => {
|
|
const groups = recommendVersionMembers({
|
|
candidates: [
|
|
{ id: 'm-1', name: 'Frontend', departmentName: '前端组' },
|
|
{ id: 'm-2', name: 'Backend', departmentName: '后端组' },
|
|
{ id: 'm-3', name: 'Tester', departmentName: '测试组' },
|
|
{ id: 'm-4', name: 'Product', departmentName: '产品部' },
|
|
{ id: 'm-5', name: 'Designer', departmentName: '设计部' },
|
|
],
|
|
currentMembers: [{ name: 'Frontend', role: 'frontend' }],
|
|
devTasks: [],
|
|
testCases: [],
|
|
bugs: [],
|
|
});
|
|
|
|
assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']);
|
|
assert.deepEqual(groups.flatMap((group) => group.items.map((item) => item.name)), ['Backend', 'Tester']);
|
|
});
|
|
|
|
test('recommendVersionMembers ranks idle people above overloaded people with deadline conflicts', () => {
|
|
const groups = recommendVersionMembers({
|
|
candidates: [
|
|
{ id: 'm-1', name: 'Backend Busy', departmentName: '后端组' },
|
|
{ id: 'm-2', name: 'Backend Idle', departmentName: '后端组' },
|
|
],
|
|
currentMembers: [],
|
|
devTasks: [
|
|
devTask({
|
|
assigneeId: 'Backend Busy',
|
|
estimateHours: 16,
|
|
expectedEndAt: '2026-06-30T18:00:00',
|
|
}),
|
|
],
|
|
testCases: [],
|
|
bugs: [],
|
|
versionDeadline: '2026-06-28T18:00:00',
|
|
});
|
|
|
|
const backendItems = groups.find((group) => group.role === 'backend')?.items ?? [];
|
|
assert.equal(backendItems[0].name, 'Backend Idle');
|
|
assert.equal(backendItems[1].metrics.deadlineConflictCount, 1);
|
|
assert.ok(backendItems[1].warnings.some((warning) => warning.includes('截止')));
|
|
});
|
|
|
|
test('recommendVersionMembers uses historical bug rate without punishing missing history', () => {
|
|
const groups = recommendVersionMembers({
|
|
candidates: [
|
|
{ id: 'm-1', name: 'Stable Dev', departmentName: '后端组' },
|
|
{ id: 'm-2', name: 'Risky Dev', departmentName: '后端组' },
|
|
{ id: 'm-3', name: 'New Dev', departmentName: '后端组' },
|
|
],
|
|
currentMembers: [],
|
|
devTasks: [],
|
|
testCases: [],
|
|
bugs: [],
|
|
historicalStats: [
|
|
{ name: 'Stable Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 2 },
|
|
{ name: 'Risky Dev', projectParticipationCount: 4, deliveredTaskCount: 20, severityWeightedBugCount: 14 },
|
|
],
|
|
});
|
|
|
|
const backendItems = groups.find((group) => group.role === 'backend')?.items ?? [];
|
|
const stable = backendItems.find((item) => item.name === 'Stable Dev')!;
|
|
const risky = backendItems.find((item) => item.name === 'Risky Dev')!;
|
|
const newcomer = backendItems.find((item) => item.name === 'New Dev')!;
|
|
|
|
assert.ok(stable.score > risky.score);
|
|
assert.equal(stable.confidence, 'high');
|
|
assert.equal(newcomer.confidence, 'medium');
|
|
assert.equal(newcomer.metrics.bugRate, undefined);
|
|
});
|
|
|
|
test('recommendVersionMembers limits candidates to strict AI-assisted role demand from requirements', () => {
|
|
const groups = recommendVersionMembers({
|
|
candidates: [
|
|
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
|
|
{ id: 'm-f2', name: 'Frontend B', departmentName: '前端组' },
|
|
{ id: 'm-f3', name: 'Frontend C', departmentName: '前端组' },
|
|
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
|
|
{ id: 'm-b2', name: 'Backend B', departmentName: '后端组' },
|
|
{ id: 'm-q1', name: 'QA A', departmentName: '测试组' },
|
|
{ id: 'm-q2', name: 'QA B', departmentName: '测试组' },
|
|
],
|
|
currentMembers: [{ name: 'Backend Existing', role: 'backend' }],
|
|
devTasks: [],
|
|
testCases: [],
|
|
bugs: [],
|
|
scopeRequirements: [
|
|
requirement({ id: 'req-xl', effort: 'XL' }),
|
|
requirement({ id: 'req-l', effort: 'L' }),
|
|
requirement({ id: 'req-m', effort: 'M' }),
|
|
],
|
|
scopeDevTasks: [],
|
|
scopeTestCases: [],
|
|
});
|
|
|
|
const frontend = groups.find((group) => group.role === 'frontend')!;
|
|
const backend = groups.find((group) => group.role === 'backend')!;
|
|
const testing = groups.find((group) => group.role === 'testing')!;
|
|
|
|
assert.equal(frontend.requiredCount, 2);
|
|
assert.equal(frontend.currentCount, 0);
|
|
assert.equal(frontend.missingCount, 2);
|
|
assert.equal(frontend.items.length, 2);
|
|
assert.equal(backend.requiredCount, 2);
|
|
assert.equal(backend.currentCount, 1);
|
|
assert.equal(backend.missingCount, 1);
|
|
assert.equal(backend.items.length, 1);
|
|
assert.equal(testing.requiredCount, 1);
|
|
assert.equal(testing.items.length, 1);
|
|
});
|
|
|
|
test('recommendVersionMembers omits roles without scoped task demand', () => {
|
|
const groups = recommendVersionMembers({
|
|
candidates: [
|
|
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
|
|
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
|
|
{ id: 'm-q1', name: 'QA A', departmentName: '测试组' },
|
|
],
|
|
currentMembers: [],
|
|
devTasks: [],
|
|
testCases: [],
|
|
bugs: [],
|
|
scopeRequirements: [],
|
|
scopeDevTasks: [
|
|
devTask({ id: 'dev-backend', categoryId: 'cat-backend-api', estimateHours: 20, assigneeId: 'Someone' }),
|
|
],
|
|
scopeTestCases: [
|
|
testCase({ id: 'tc-functional', estimateHours: 6 }),
|
|
],
|
|
taskCategories: PRESET_CATEGORIES,
|
|
});
|
|
|
|
assert.deepEqual(groups.map((group) => group.role), ['backend', 'testing']);
|
|
assert.equal(groups.find((group) => group.role === 'backend')?.items.length, 1);
|
|
assert.equal(groups.find((group) => group.role === 'testing')?.items.length, 1);
|
|
});
|
|
|
|
test('addRecommendedVersionMembers appends selected people with recommended roles once', () => {
|
|
const next = addRecommendedVersionMembers(
|
|
[
|
|
{ name: 'Product Owner', role: 'product' },
|
|
{ name: 'Backend Existing', role: 'backend' },
|
|
],
|
|
['Frontend Candidate', 'Backend Existing', 'QA Candidate'],
|
|
[
|
|
{
|
|
name: 'Frontend Candidate',
|
|
role: 'frontend',
|
|
score: 88,
|
|
confidence: 'medium',
|
|
reasons: [],
|
|
warnings: [],
|
|
metrics: {
|
|
activeTaskCount: 0,
|
|
activeBugCount: 0,
|
|
deadlineConflictCount: 0,
|
|
remainingHours: 0,
|
|
recentOvertimeHours: 0,
|
|
availableInDays: 0,
|
|
},
|
|
},
|
|
{
|
|
name: 'Backend Existing',
|
|
role: 'backend',
|
|
score: 80,
|
|
confidence: 'medium',
|
|
reasons: [],
|
|
warnings: [],
|
|
metrics: {
|
|
activeTaskCount: 0,
|
|
activeBugCount: 0,
|
|
deadlineConflictCount: 0,
|
|
remainingHours: 0,
|
|
recentOvertimeHours: 0,
|
|
availableInDays: 0,
|
|
},
|
|
},
|
|
{
|
|
name: 'QA Candidate',
|
|
role: 'testing',
|
|
score: 84,
|
|
confidence: 'medium',
|
|
reasons: [],
|
|
warnings: [],
|
|
metrics: {
|
|
activeTaskCount: 0,
|
|
activeBugCount: 0,
|
|
deadlineConflictCount: 0,
|
|
remainingHours: 0,
|
|
recentOvertimeHours: 0,
|
|
availableInDays: 0,
|
|
},
|
|
},
|
|
],
|
|
);
|
|
|
|
assert.deepEqual(next, [
|
|
{ name: 'Product Owner', role: 'product' },
|
|
{ name: 'Backend Existing', role: 'backend' },
|
|
{ name: 'Frontend Candidate', role: 'frontend' },
|
|
{ name: 'QA Candidate', role: 'testing' },
|
|
]);
|
|
});
|
|
|
|
test('getDefaultRecommendedMemberNames selects every displayed recommendation within role gaps', () => {
|
|
const groups = recommendVersionMembers({
|
|
candidates: [
|
|
{ id: 'm-f1', name: 'Frontend A', departmentName: '前端组' },
|
|
{ id: 'm-f2', name: 'Frontend B', departmentName: '前端组' },
|
|
{ id: 'm-f3', name: 'Frontend C', departmentName: '前端组' },
|
|
{ id: 'm-b1', name: 'Backend A', departmentName: '后端组' },
|
|
{ id: 'm-b2', name: 'Backend B', departmentName: '后端组' },
|
|
],
|
|
currentMembers: [],
|
|
devTasks: [],
|
|
testCases: [],
|
|
bugs: [],
|
|
scopeRequirements: [
|
|
requirement({ id: 'req-xl', effort: 'XL' }),
|
|
requirement({ id: 'req-xl-2', effort: 'XL' }),
|
|
],
|
|
scopeDevTasks: [],
|
|
scopeTestCases: [],
|
|
});
|
|
|
|
assert.deepEqual(getDefaultRecommendedMemberNames(groups), ['Frontend A', 'Frontend B', 'Backend A', 'Backend B']);
|
|
});
|