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 { 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 { 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; } function insight(patch: Partial = {}): 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), ), 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('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.'); }); 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); });