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 { 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 { 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 { 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']); });