Files
ftb-project-management/apps/web/lib/xiaobao-risk-ai.test.ts
Script Generator e5ef9ee28b fix(小宝预警): 稳定风险证据与建议更新
关键改动:

- 优化风险证据的日报工时与静默风险计算

- 稳定 AI 触发签名,避免证据细节变化造成重复请求

- 调整预警建议更新状态与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-01 11:26:16 +08:00

542 lines
21 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,
findLatestRiskInsightForVersion,
findPreviousRiskSnapshot,
getReusableInsight,
shouldRequestRiskInsight,
shouldRequestRiskInsightWithRequestGate,
shouldRequestRiskInsightWithCacheGate,
shouldRequestRiskInsightWithCooldown,
} from './xiaobao-risk-ai';
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
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' }],
todayCreations: [{ id: 'ev-create', title: 'Creation', summary: 'Created regression case.', occurredAt: '2026-06-29T10:30: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,
totalActivityCount: 6,
todayActualHours: 4.5,
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;
}
function insight(patch: Partial<XiaobaoRiskInsightCacheItem> = {}): XiaobaoRiskInsightCacheItem {
return {
versionId: 'ver-1',
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'attention', riskScore: 52 })),
insight: {
summary: 'Risk needs attention.',
why: ['Risk rose.'],
forecast: 'May slip.',
suggestedActions: ['Confirm scope.'],
ownerHints: ['PM'],
generatedAt: '2026-06-29T08:00:00.000Z',
},
generatedAt: '2026-06-29T08:00:00.000Z',
...patch,
};
}
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('findLatestRiskInsightForVersion returns latest generated insight for a version', () => {
const latest = findLatestRiskInsightForVersion([
insight({ versionId: 'ver-1', generatedAt: '2026-06-29T08:00:00.000Z' }),
insight({ versionId: 'ver-1', generatedAt: '2026-06-29T10:00:00.000Z' }),
insight({ versionId: 'ver-2', generatedAt: '2026-06-29T11:00:00.000Z' }),
], 'ver-1');
assert.equal(latest?.generatedAt, '2026-06-29T10:00:00.000Z');
});
test('shouldRequestRiskInsightWithCooldown skips repeated AI requests inside cooldown', () => {
const current = risk({ riskLevel: 'at_risk', riskScore: 62 });
const previous = snapshot({ riskScore: 40 });
const latest = insight({
generatedAt: '2026-06-29T10:00:00.000Z',
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'at_risk', riskScore: 60 })),
});
assert.equal(shouldRequestRiskInsightWithCooldown(current, previous, latest, new Date('2026-06-29T11:00:00.000Z')), false);
});
test('shouldRequestRiskInsightWithCooldown allows AI requests after cooldown', () => {
const current = risk({ riskLevel: 'at_risk', riskScore: 62 });
const previous = snapshot({ riskScore: 40 });
const latest = insight({ generatedAt: '2026-06-29T04:00:00.000Z' });
assert.equal(shouldRequestRiskInsightWithCooldown(current, previous, latest, new Date('2026-06-29T11:00:00.000Z')), true);
});
test('shouldRequestRiskInsightWithCooldown bypasses cooldown when risk level escalates', () => {
const previousInsight = insight({
generatedAt: '2026-06-29T10:30:00.000Z',
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'attention', riskScore: 52 })),
});
const current = risk({ riskLevel: 'blocked', riskScore: 90 });
assert.equal(shouldRequestRiskInsightWithCooldown(current, snapshot({ riskScore: 50 }), previousInsight, new Date('2026-06-29T11:00:00.000Z')), true);
});
test('shouldRequestRiskInsightWithCacheGate waits for cache loading before AI requests', () => {
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
assert.equal(
shouldRequestRiskInsightWithCacheGate(false, [], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')),
false,
);
});
test('shouldRequestRiskInsightWithCacheGate reuses unchanged cached insight after cache loading', () => {
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
const cached = insight({
riskSignature: buildRiskInsightSignature(current),
generatedAt: '2026-06-29T02:00:00.000Z',
});
assert.equal(
shouldRequestRiskInsightWithCacheGate(true, [cached], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')),
false,
);
});
test('shouldRequestRiskInsightWithCacheGate allows changed risk facts after cache loading and cooldown', () => {
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
const previousInsight = insight({
riskSignature: buildRiskInsightSignature(risk({ riskLevel: 'at_risk', riskScore: 62 })),
generatedAt: '2026-06-29T02:00:00.000Z',
});
assert.equal(
shouldRequestRiskInsightWithCacheGate(true, [previousInsight], current, snapshot({ riskScore: 45 }), new Date('2026-06-29T11:00:00.000Z')),
true,
);
});
test('shouldRequestRiskInsightWithRequestGate skips repeat requests during request cooldown without cache', () => {
const current = risk({ riskLevel: 'at_risk', riskScore: 82 });
assert.equal(
shouldRequestRiskInsightWithRequestGate({
riskCacheLoaded: true,
cache: [],
current,
previous: snapshot({ riskScore: 45 }),
lastRequestedAt: '2026-06-29T10:30:00.000Z',
now: new Date('2026-06-29T11:00:00.000Z'),
}),
false,
);
});
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('buildRiskInsightSignature stays stable when only volatile same-day forecast timing changes', () => {
const base = buildRiskInsightSignature(risk({
forecastReleaseDate: '2026-07-14T03:06:58.211Z',
signals: { ...risk().signals, daysToExpectedRelease: 30.9 },
}));
const changed = buildRiskInsightSignature(risk({
forecastReleaseDate: '2026-07-14T03:37:15.903Z',
signals: { ...risk().signals, daysToExpectedRelease: 30.1 },
}));
assert.equal(base, changed);
});
test('getReusableInsight reuses legacy signatures with volatile forecast timestamps', () => {
const current = risk({
forecastReleaseDate: '2026-07-14T03:37:15.903Z',
signals: { ...risk().signals, daysToExpectedRelease: 30.1 },
});
const legacy = insight({
riskSignature: JSON.stringify({
...JSON.parse(buildRiskInsightSignature(current)),
forecastReleaseDate: '2026-07-14T03:06:58.211Z',
signals: {
...current.signals,
daysToExpectedRelease: 30.9,
},
}),
});
assert.equal(getReusableInsight([legacy], current)?.insight.summary, legacy.insight.summary);
});
test('getReusableInsight keeps showing cached insight when only refresh-volatile fields drift', () => {
const cachedRisk = risk({
riskLevel: 'likely_delayed',
riskScore: 100,
confidence: 65,
forecastReleaseDate: '2026-07-14T03:37:15.903Z',
signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 1 },
dailyEvidence: {
...risk().dailyEvidence!,
recentActivityCount: 12,
todayActualHours: 1.5,
lastActivityAt: '2026-06-29T03:37:15.903Z',
todayProgress: [
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T03:37:15.903Z' },
],
},
currentSnapshot: snapshot({ riskScore: 100, failedTestCount: 14, silentRiskCount: 58 }),
});
const current = risk({
riskLevel: 'likely_delayed',
riskScore: 100,
confidence: 65,
forecastReleaseDate: '2026-07-15T06:21:38.597Z',
signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 0 },
dailyEvidence: {
...risk().dailyEvidence!,
recentActivityCount: 13,
todayActualHours: 3,
lastActivityAt: '2026-06-29T06:21:38.597Z',
todayProgress: [
{ id: 'ev-2', title: 'Progress', summary: 'Fixed login issue.', occurredAt: '2026-06-29T06:21:38.597Z' },
],
},
currentSnapshot: snapshot({ riskScore: 100, failedTestCount: 14, silentRiskCount: 58 }),
});
const cached = insight({
riskSignature: buildRiskInsightSignature(cachedRisk),
insight: { ...insight().insight, summary: 'Cached Xiaobao advice stays visible.' },
generatedAt: '2026-06-29T08:00:00.000Z',
});
assert.equal(getReusableInsight([cached], current)?.insight.summary, 'Cached Xiaobao advice stays visible.');
assert.equal(
shouldRequestRiskInsightWithCacheGate(true, [cached], current, snapshot({ riskScore: 70 }), new Date('2026-06-29T09:00:00.000Z')),
false,
);
});
test('getReusableInsight keeps cached insight when only daily report evidence details change', () => {
const cachedRisk = risk({
riskLevel: 'at_risk',
riskScore: 82,
confidence: 70,
signals: { ...risk().signals, failedTestCount: 2, silentRiskCount: 1, daysToExpectedRelease: 1 },
dailyEvidence: {
todayDeliveries: [],
todayProgress: [
{ id: 'ev-old', title: 'Progress', summary: 'Worked on checkout flow.', occurredAt: '2026-06-30T03:00:00.000Z' },
],
todayCreations: [],
todayRisks: [],
progressNotes: [],
needsProgressItems: [],
recentActivityCount: 2,
totalActivityCount: 1,
todayActualHours: 1,
lastActivityAt: '2026-06-30T03:00:00.000Z',
},
currentSnapshot: snapshot({ riskScore: 82, failedTestCount: 2, silentRiskCount: 1 }),
});
const current = risk({
riskLevel: 'at_risk',
riskScore: 82,
confidence: 70,
signals: { ...risk().signals, failedTestCount: 2, silentRiskCount: 1, daysToExpectedRelease: 1 },
dailyEvidence: {
todayDeliveries: [],
todayProgress: [
{ id: 'ev-new', title: 'Progress', summary: 'Updated checkout flow and added handoff notes.', occurredAt: '2026-07-01T04:00:00.000Z' },
{ id: 'ev-new-2', title: 'Progress', summary: 'Confirmed QA scope.', occurredAt: '2026-07-01T05:00:00.000Z' },
],
todayCreations: [],
todayRisks: [],
progressNotes: [
{ id: 'note-new', title: 'Note', summary: 'Next work starts tomorrow morning.', occurredAt: '2026-07-01T06:00:00.000Z' },
],
needsProgressItems: [],
recentActivityCount: 6,
totalActivityCount: 3,
todayActualHours: 4,
lastActivityAt: '2026-07-01T06:00:00.000Z',
},
currentSnapshot: snapshot({ riskScore: 82, failedTestCount: 2, silentRiskCount: 1 }),
});
const cached = insight({
riskSignature: buildRiskInsightSignature(cachedRisk),
insight: { ...insight().insight, summary: 'Cached advice should remain visible.' },
generatedAt: '2026-06-30T08:00:00.000Z',
});
assert.equal(getReusableInsight([cached], current)?.insight.summary, 'Cached advice should remain visible.');
assert.equal(
shouldRequestRiskInsightWithCacheGate(true, [cached], current, snapshot({ riskScore: 70 }), new Date('2026-07-01T09:00:00.000Z')),
false,
);
});
test('getReusableInsight does not reuse cached insight when core risk facts change', () => {
const cachedRisk = risk({
riskLevel: 'likely_delayed',
riskScore: 100,
confidence: 65,
forecastReleaseDate: '2026-07-14T03:37:15.903Z',
signals: { ...risk().signals, failedTestCount: 14, silentRiskCount: 58, daysToExpectedRelease: 1 },
currentSnapshot: snapshot({ riskScore: 100, failedTestCount: 14, silentRiskCount: 58 }),
});
const current = risk({
riskLevel: 'likely_delayed',
riskScore: 100,
confidence: 65,
forecastReleaseDate: '2026-07-15T06:21:38.597Z',
signals: { ...risk().signals, failedTestCount: 15, silentRiskCount: 58, daysToExpectedRelease: 0 },
currentSnapshot: snapshot({ riskScore: 100, failedTestCount: 15, silentRiskCount: 58 }),
});
const cached = insight({ riskSignature: buildRiskInsightSignature(cachedRisk) });
assert.equal(getReusableInsight([cached], current), undefined);
});
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.todayCreations, ['Created regression case.']);
assert.deepEqual(payload.dailyEvidence.needsProgressItems, ['Backend task has no update.']);
assert.equal(payload.dailyEvidence.totalActivityCount, 6);
assert.equal(payload.dailyEvidence.todayActualHours, 4.5);
assert.equal(payload.silentRisks[0].detail, 'No update for 5 days.');
});
test('findPreviousRiskSnapshot returns latest snapshot before today for the version', () => {
const previous = findPreviousRiskSnapshot(
[
snapshot({ versionId: 'ver-1', date: '2026-06-29', riskScore: 70, createdAt: '2026-06-29T10:00:00.000Z' }),
snapshot({ versionId: 'ver-1', date: '2026-06-28', riskScore: 52, createdAt: '2026-06-28T10:00:00.000Z' }),
snapshot({ versionId: 'ver-1', date: '2026-06-27', riskScore: 38, createdAt: '2026-06-27T10:00:00.000Z' }),
snapshot({ versionId: 'ver-2', date: '2026-06-28', riskScore: 90, createdAt: '2026-06-28T11:00:00.000Z' }),
],
'ver-1',
'2026-06-29',
);
assert.equal(previous?.riskScore, 52);
});