Files
ftb-project-management/apps/web/lib/xiaobao-warning-view.test.ts
Script Generator eef3c8f000 feat(版本): 优化概览与只读状态
关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-06-30 18:18:18 +08:00

285 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
sanitizeRiskInsight,
} from './xiaobao-warning-view';
function version(patch: Partial<VersionWithContext> = {}): 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> = {}): 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('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);
});