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