Files
ftb-project-management/apps/web/lib/xiaobao-warning-view.test.ts
Script Generator 3d3d56697a feat(版本): 完善研发计划与预警已读
关键改动:

- 增加版本表单、发布校验和调研方向进度规则

- 扩展小宝预警已读状态、风险签名和今日证据

- 补充组长权限、加班查看范围、活动记录与相关测试

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

266 lines
9.8 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 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);
});