Files
ftb-project-management/apps/web/lib/xiaobao-risk-ai.test.ts
2026-06-29 19:50:33 +08:00

246 lines
8.9 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type { XiaobaoVersionRisk } from './xiaobao-risk';
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
import {
buildRiskInsightSignature,
buildRiskInterpretRequest,
shouldRequestRiskInsight,
} from './xiaobao-risk-ai';
function snapshot(patch: Partial<XiaobaoRiskSnapshot> = {}): XiaobaoRiskSnapshot {
return {
versionId: 'ver-1',
date: '2026-06-28',
riskScore: 35,
riskLevel: 'attention',
forecastReleaseDate: '2026-07-02T10:00:00.000Z',
openBugCount: 0,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
confidence: 80,
createdAt: '2026-06-28T10:00:00.000Z',
...patch,
};
}
function risk(patch: Partial<XiaobaoVersionRisk> = {}): XiaobaoVersionRisk {
return {
versionId: 'ver-1',
versionName: 'V1.0',
productName: 'FTB',
projectName: 'PM',
riskScore: 68,
riskLevel: 'attention',
expectedReleaseDate: '2026-07-01T10:00:00.000Z',
confidence: 80,
confidenceLevel: 'high',
forecastReleaseDate: '2026-07-02T10:00:00.000Z',
delayDays: 1,
remainingWorkHours: 12,
reasons: [
{ key: 'failed_test', title: 'Failed tests', detail: '1 test failed.', severity: 'warning', count: 1 },
{ key: 'critical_bug', title: 'Critical bug', detail: 'P1 bug exists.', severity: 'danger', count: 1 },
],
silentRisks: [{ key: 'no_update', title: 'No update', detail: 'No update for 5 days.' }],
dailyEvidence: {
todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core flow.', occurredAt: '2026-06-29T09:00:00.000Z' }],
todayProgress: [{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T10:00:00.000Z' }],
todayRisks: [{ id: 'ev-3', title: 'Risk', summary: 'Regression failed.', occurredAt: '2026-06-29T11:00:00.000Z' }],
progressNotes: [{ id: 'ev-4', title: 'Note', summary: 'Need QA retest.', occurredAt: '2026-06-29T12:00:00.000Z' }],
needsProgressItems: [{ id: 'ev-5', title: 'Need update', summary: 'Backend task has no update.', occurredAt: '2026-06-29T13:00:00.000Z' }],
recentActivityCount: 3,
lastActivityAt: '2026-06-29T13:00:00.000Z',
},
signals: {
unfinishedCount: 4,
openBugCount: 3,
criticalBugCount: 1,
failedTestCount: 1,
blockedCount: 0,
silentRiskCount: 1,
daysToExpectedRelease: 1,
},
currentSnapshot: snapshot({ riskScore: 68, openBugCount: 3, failedTestCount: 1, silentRiskCount: 1 }),
trend: { direction: 'up', delta: 33, summary: 'Risk rose by 33 points.', pattern: 'score_delta' },
...patch,
} as unknown as XiaobaoVersionRisk;
}
test('shouldRequestRiskInsight skips on_track', () => {
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'on_track', riskScore: 10 }), undefined), false);
});
test('shouldRequestRiskInsight does not trigger ordinary attention without a previous worsening signal', () => {
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'attention', riskScore: 42, signals: { ...risk().signals, unfinishedCount: 0, daysToExpectedRelease: 5 } }), undefined), false);
});
test('shouldRequestRiskInsight triggers attention when risk score jumps', () => {
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'attention', riskScore: 68 }), snapshot({ riskScore: 35 })), true);
});
test('shouldRequestRiskInsight triggers attention when risk signals worsen without level change', () => {
assert.equal(
shouldRequestRiskInsight(
risk({
riskLevel: 'attention',
riskScore: 52,
signals: {
unfinishedCount: 4,
openBugCount: 3,
criticalBugCount: 3,
failedTestCount: 1,
blockedCount: 1,
silentRiskCount: 1,
daysToExpectedRelease: 1,
},
}),
snapshot({ riskScore: 50, openBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0 }),
),
true,
);
});
test('shouldRequestRiskInsight triggers attention when critical bugs increase even if open bug total is unchanged', () => {
assert.equal(
shouldRequestRiskInsight(
risk({
riskLevel: 'attention',
riskScore: 45,
signals: {
unfinishedCount: 0,
openBugCount: 2,
criticalBugCount: 1,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
daysToExpectedRelease: 5,
},
}),
snapshot({ riskScore: 44, openBugCount: 2, criticalBugCount: 0 } as Partial<XiaobaoRiskSnapshot>),
),
true,
);
});
test('shouldRequestRiskInsight triggers attention on continuously rising trend', () => {
assert.equal(
shouldRequestRiskInsight(
risk({
riskLevel: 'attention',
riskScore: 46,
signals: {
unfinishedCount: 0,
openBugCount: 0,
criticalBugCount: 0,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
daysToExpectedRelease: 5,
},
trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' },
}),
snapshot({ riskScore: 38 }),
),
true,
);
});
test('shouldRequestRiskInsight triggers attention on continuously rising trend without previous snapshot', () => {
assert.equal(
shouldRequestRiskInsight(
risk({
riskLevel: 'attention',
riskScore: 46,
signals: {
unfinishedCount: 0,
openBugCount: 0,
criticalBugCount: 0,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
daysToExpectedRelease: 5,
},
trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' },
}),
undefined,
),
true,
);
});
test('shouldRequestRiskInsight triggers attention near release with unfinished work even without history', () => {
assert.equal(
shouldRequestRiskInsight(risk({ riskLevel: 'attention', signals: { ...risk().signals, unfinishedCount: 2, daysToExpectedRelease: 1 } }), undefined),
true,
);
});
test('shouldRequestRiskInsight triggers high risk levels', () => {
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'at_risk' }), undefined), true);
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'likely_delayed' }), undefined), true);
assert.equal(shouldRequestRiskInsight(risk({ riskLevel: 'blocked' }), undefined), true);
});
test('buildRiskInsightSignature uses all open bugs, not only critical bugs', () => {
const base = buildRiskInsightSignature(risk({
signals: { ...risk().signals, openBugCount: 1, criticalBugCount: 0 },
currentSnapshot: snapshot({ openBugCount: 1, riskScore: 45 }),
}));
const changed = buildRiskInsightSignature(risk({
signals: { ...risk().signals, openBugCount: 2, criticalBugCount: 0 },
currentSnapshot: snapshot({ openBugCount: 2, riskScore: 45 }),
}));
assert.notEqual(base, changed);
});
test('buildRiskInsightSignature changes when trend or evidence changes', () => {
const base = buildRiskInsightSignature(risk({
trend: { direction: 'flat', delta: 0, summary: 'Risk is stable.', pattern: 'stable' },
}));
const changed = buildRiskInsightSignature(risk({
trend: { direction: 'up', delta: 16, summary: 'Risk rose continuously from 30 to 46.', pattern: 'continuous_rising' },
dailyEvidence: {
...risk().dailyEvidence!,
todayRisks: [{ id: 'ev-new', title: 'Risk', summary: 'New P1 regression appeared.', occurredAt: '2026-06-29T15:00:00.000Z' }],
},
}));
assert.notEqual(base, changed);
});
test('buildRiskInsightSignature changes when trend direction delta or activity metadata changes', () => {
const base = buildRiskInsightSignature(risk({
trend: { direction: 'up', delta: 10, summary: 'Risk changed.', pattern: 'score_delta' },
dailyEvidence: {
...risk().dailyEvidence!,
recentActivityCount: 1,
lastActivityAt: '2026-06-29T10:00:00.000Z',
},
}));
const changed = buildRiskInsightSignature(risk({
trend: { direction: 'down', delta: -10, summary: 'Risk changed.', pattern: 'score_delta' },
dailyEvidence: {
...risk().dailyEvidence!,
recentActivityCount: 2,
lastActivityAt: '2026-06-29T11:00:00.000Z',
},
}));
assert.notEqual(base, changed);
});
test('buildRiskInterpretRequest compresses frontend risk evidence for the backend AI contract', () => {
const payload = buildRiskInterpretRequest(risk());
assert.equal(payload.versionName, 'V1.0');
assert.equal(payload.reasons[0].label, 'Failed tests');
assert.equal(payload.reasons[0].severity, 'medium');
assert.equal(payload.reasons[1].severity, 'critical');
assert.deepEqual(payload.dailyEvidence.todayDeliveries, ['Submitted core flow.']);
assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']);
assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.');
});