import assert from 'node:assert/strict'; import test from 'node:test'; import type { VersionWithContext } from './derive'; import type { XiaobaoVersionRisk } from './xiaobao-risk'; import { filterXiaobaoRiskWarnings, filterXiaobaoWarningVersions, formatRemainingWork, getRiskScoreTone, getXiaobaoWarningRiskCount, getXiaobaoWarningUnreadUpdateCount, getXiaobaoWarningUpdateSignature, isXiaobaoWarningUpdated, markXiaobaoWarningRead, shouldSkipXiaobaoRiskInsightRequestForReadState, sanitizeRiskInsight, } from './xiaobao-warning-view'; function version(patch: Partial = {}): VersionWithContext { return { id: 'ver-1', name: 'V1.0', status: 'developing', releaseDate: null, createdAt: '2026-06-29T00:00:00.000Z', productId: 'prod-1', productName: 'FTB', projectId: 'proj-1', projectName: 'Project', expectedReleaseDate: '2026-07-05T10:00:00.000Z', members: [{ name: 'Alice', role: 'frontend' }], ...patch, }; } function risk(patch: Partial = {}): XiaobaoVersionRisk { return { versionId: 'ver-1', versionName: 'V1.0', productId: 'prod-1', productName: 'Product A', projectId: 'proj-1', projectName: 'Project A', riskScore: 68, riskLevel: 'attention', expectedReleaseDate: '2026-07-05', confidence: 80, confidenceLevel: 'high', delayDays: 0, remainingWorkHours: 8, reasons: [], silentRisks: [], signals: { unfinishedCount: 1, openBugCount: 0, criticalBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, }, currentSnapshot: { versionId: 'ver-1', date: '2026-06-30', riskScore: 68, riskLevel: 'attention', openBugCount: 0, criticalBugCount: 0, failedTestCount: 0, blockedCount: 0, silentRiskCount: 0, confidence: 80, createdAt: '2026-06-30T10:00:00.000Z', }, trend: { direction: 'flat', delta: 0, summary: 'Risk is stable.', pattern: 'stable' }, ...patch, } as XiaobaoVersionRisk; } test('filterXiaobaoWarningVersions lets managers see every unfinished version', () => { const result = filterXiaobaoWarningVersions( [ version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }), version({ id: 'ver-2', status: 'planned', members: [{ name: 'Bob', role: 'testing' }] }), version({ id: 'ver-3', status: 'released', members: [{ name: 'Alice', role: 'frontend' }] }), ], { canManage: true, userName: 'Alice' }, ); assert.deepEqual(result.map((item) => item.id), ['ver-1', 'ver-2']); }); test('filterXiaobaoWarningVersions excludes paused released closed versions and paused projects', () => { const versions = [ version({ id: 'ver-1', status: 'paused', members: [{ name: 'Alice', role: 'frontend' }] }), version({ id: 'ver-2', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }), version({ id: 'ver-3', status: 'released', members: [{ name: 'Alice', role: 'frontend' }] }), version({ id: 'ver-4', status: 'closed', members: [{ name: 'Alice', role: 'frontend' }] }), version({ id: 'ver-5', status: 'developing', projectStatus: 'paused', members: [{ name: 'Alice', role: 'frontend' }] }), ]; assert.deepEqual( filterXiaobaoWarningVersions(versions, { canManage: true, userName: 'Alice' }).map((item) => item.id), ['ver-2'], ); assert.deepEqual( filterXiaobaoWarningVersions(versions, { canManage: false, userName: 'Alice' }).map((item) => item.id), ['ver-2'], ); }); test('filterXiaobaoWarningVersions limits non-managers to versions where they are a member', () => { const result = filterXiaobaoWarningVersions( [ version({ id: 'ver-1', status: 'developing', members: [{ name: 'Alice', role: 'frontend' }] }), version({ id: 'ver-2', status: 'developing', members: [{ name: 'Bob', role: 'testing' }] }), version({ id: 'ver-3', status: 'closed', members: [{ name: 'Alice', role: 'frontend' }] }), ], { canManage: false, userName: 'Alice' }, ); assert.deepEqual(result.map((item) => item.id), ['ver-1']); }); test('filterXiaobaoRiskWarnings hides on_track risks and badge count follows visible risks', () => { const risks = [ risk({ versionId: 'ver-1', riskLevel: 'on_track', riskScore: 10 }), risk({ versionId: 'ver-2', riskLevel: 'attention', riskScore: 38 }), risk({ versionId: 'ver-3', riskLevel: 'blocked', riskScore: 100 }), ]; assert.deepEqual(filterXiaobaoRiskWarnings(risks).map((item) => item.versionId), ['ver-2', 'ver-3']); assert.equal(getXiaobaoWarningRiskCount(risks), 2); }); test('filterXiaobaoRiskWarnings filters by product project and risk tier', () => { const risks = [ risk({ versionId: 'ver-1', productId: 'prod-1', projectId: 'proj-1', riskLevel: 'attention', riskScore: 38 }), risk({ versionId: 'ver-2', productId: 'prod-1', projectId: 'proj-2', riskLevel: 'likely_delayed', riskScore: 88 }), risk({ versionId: 'ver-3', productId: 'prod-2', projectId: 'proj-3', riskLevel: 'blocked', riskScore: 100 }), ]; assert.deepEqual( filterXiaobaoRiskWarnings(risks, { productId: 'prod-1' }).map((item) => item.versionId), ['ver-1', 'ver-2'], ); assert.deepEqual( filterXiaobaoRiskWarnings(risks, { productId: 'prod-1', projectId: 'proj-2' }).map((item) => item.versionId), ['ver-2'], ); assert.deepEqual( filterXiaobaoRiskWarnings(risks, { riskFilter: 'high' }).map((item) => item.versionId), ['ver-2', 'ver-3'], ); }); test('formatRemainingWork keeps hours and adds work-day conversion', () => { assert.equal(formatRemainingWork(0), '0h / 0天'); assert.equal(formatRemainingWork(8), '8h / 1天'); assert.equal(formatRemainingWork(12), '12h / 1.5天'); assert.equal(formatRemainingWork(0.5), '0.5h / 0.1天'); }); test('getRiskScoreTone follows Xiaobao warning score thresholds', () => { assert.equal(getRiskScoreTone(75), 'danger'); assert.equal(getRiskScoreTone(55), 'warn'); assert.equal(getRiskScoreTone(54), 'ok'); }); test('sanitizeRiskInsight filters invalid page refresh suggested actions', () => { const result = sanitizeRiskInsight({ summary: '风险上升', why: ['P1 Bug 增加'], forecast: '预计延期 1 天', suggestedActions: [ '手动触发页面刷新,等待小宝预警自动更新', '优先处理 3 个 P1 Bug,并同步测试负责人复测', ], ownerHints: ['研发负责人协调修复顺序'], generatedAt: '2026-06-30T10:00:00.000Z', }); assert.deepEqual(result.suggestedActions, ['优先处理 3 个 P1 Bug,并同步测试负责人复测']); }); test('xiaobao warning update state is unread until the current user reads the same risk signature', () => { const baseRisk = risk(); assert.equal(isXiaobaoWarningUpdated(baseRisk, [], 'user-1'), true); const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z'); assert.equal(isXiaobaoWarningUpdated(baseRisk, readStates, 'user-1'), false); assert.equal(isXiaobaoWarningUpdated(baseRisk, readStates, 'user-2'), true); }); test('read current warning state suppresses refresh-triggered AI requests only for the same signature', () => { const baseRisk = risk(); const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z'); const changedRisk = risk({ signals: { ...baseRisk.signals, failedTestCount: 1, }, riskScore: 72, }); assert.equal( shouldSkipXiaobaoRiskInsightRequestForReadState(baseRisk, readStates, 'user-1', true), true, ); assert.equal( shouldSkipXiaobaoRiskInsightRequestForReadState(changedRisk, readStates, 'user-1', true), false, ); assert.equal( shouldSkipXiaobaoRiskInsightRequestForReadState(baseRisk, readStates, 'user-1', false), true, ); }); test('xiaobao warning update state becomes unread again when risk facts change', () => { const baseRisk = risk(); const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z'); const changedRisk = risk({ riskScore: 69, signals: { ...baseRisk.signals, failedTestCount: 1, }, }); assert.notEqual(getXiaobaoWarningUpdateSignature(changedRisk), getXiaobaoWarningUpdateSignature(baseRisk)); assert.equal(isXiaobaoWarningUpdated(changedRisk, readStates, 'user-1'), true); assert.equal(getXiaobaoWarningUnreadUpdateCount([baseRisk, changedRisk], readStates, 'user-1'), 1); }); test('xiaobao warning update state waits until AI suggestion update is complete', () => { const baseRisk = risk(); const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z'); const changedRisk = risk({ riskScore: 72, aiInsightUpdating: true, signals: { ...baseRisk.signals, failedTestCount: 1, }, }); const completedRisk = { ...changedRisk, aiInsightUpdating: false }; assert.equal(isXiaobaoWarningUpdated(changedRisk, readStates, 'user-1'), false); assert.equal(getXiaobaoWarningUnreadUpdateCount([changedRisk], readStates, 'user-1'), 0); assert.deepEqual(markXiaobaoWarningRead(readStates, 'user-1', changedRisk, '2026-06-30T12:00:00.000Z'), readStates); assert.equal(isXiaobaoWarningUpdated(completedRisk, readStates, 'user-1'), true); }); test('xiaobao warning update state ignores refresh-volatile timing and hour drift', () => { const baseRisk = risk({ forecastReleaseDate: '2026-07-05T10:00:00.000Z', dailyEvidence: { todayDeliveries: [], todayProgress: [ { id: 'ev-1', title: 'Progress', summary: '开始开发:核心流程', occurredAt: '2026-06-30T10:00:00.000Z' }, ], todayCreations: [], todayRisks: [], progressNotes: [], needsProgressItems: [], recentActivityCount: 2, totalActivityCount: 1, todayActualHours: 1, lastActivityAt: '2026-06-30T10:00:00.000Z', }, }); const readStates = markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T11:00:00.000Z'); const refreshDriftRisk = risk({ forecastReleaseDate: '2026-07-05T11:30:00.000Z', signals: { ...baseRisk.signals, daysToExpectedRelease: 4.2, }, dailyEvidence: { ...baseRisk.dailyEvidence!, recentActivityCount: 3, todayActualHours: 2.5, lastActivityAt: '2026-06-30T11:30:00.000Z', todayProgress: [ { id: 'ev-1', title: 'Progress', summary: '开始开发:核心流程', occurredAt: '2026-06-30T11:30:00.000Z' }, ], }, }); assert.equal(isXiaobaoWarningUpdated(refreshDriftRisk, readStates, 'user-1'), false); }); test('markXiaobaoWarningRead replaces only the matching user and version read marker', () => { const baseRisk = risk(); const otherRisk = risk({ versionId: 'ver-2', versionName: 'V2.0' }); const initial = [ ...markXiaobaoWarningRead([], 'user-1', baseRisk, '2026-06-30T10:00:00.000Z'), ...markXiaobaoWarningRead([], 'user-2', baseRisk, '2026-06-30T10:05:00.000Z'), ...markXiaobaoWarningRead([], 'user-1', otherRisk, '2026-06-30T10:10:00.000Z'), ]; const updated = markXiaobaoWarningRead(initial, 'user-1', baseRisk, '2026-06-30T11:00:00.000Z'); assert.equal(updated.filter((item) => item.userId === 'user-1' && item.versionId === 'ver-1').length, 1); assert.equal(updated.find((item) => item.userId === 'user-1' && item.versionId === 'ver-1')?.readAt, '2026-06-30T11:00:00.000Z'); assert.equal(updated.some((item) => item.userId === 'user-2' && item.versionId === 'ver-1'), true); assert.equal(updated.some((item) => item.userId === 'user-1' && item.versionId === 'ver-2'), true); });