关键改动: - 增加版本表单、发布校验和调研方向进度规则 - 扩展小宝预警已读状态、风险签名和今日证据 - 补充组长权限、加班查看范围、活动记录与相关测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
466 lines
18 KiB
TypeScript
466 lines
18 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,
|
|
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('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 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);
|
|
});
|