feat(小宝预警): 增加日报证据与风险趋势
This commit is contained in:
163
apps/web/lib/xiaobao-risk-evidence.test.ts
Normal file
163
apps/web/lib/xiaobao-risk-evidence.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { Bug } from './bug';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import type { WorkActivity } from './work-activity';
|
||||
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from './xiaobao-risk-evidence';
|
||||
|
||||
function plan(patch: Partial<VersionPlan> = {}): VersionPlan {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
versionId: 'ver-1',
|
||||
type: 'product',
|
||||
title: 'Product plan',
|
||||
owner: 'pm-1',
|
||||
startTime: '2026-06-20T01:00:00.000Z',
|
||||
endTime: '2026-06-25T10:00:00.000Z',
|
||||
status: 'in_progress',
|
||||
actualStartAt: '2026-06-20T01:00:00.000Z',
|
||||
createdAt: '2026-06-20T01:00:00.000Z',
|
||||
addedBy: 'pm-1',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function devTask(patch: Partial<DevTask> = {}): DevTask {
|
||||
return {
|
||||
id: 'dev-1',
|
||||
taskNo: 'DEV-001',
|
||||
requirementId: 'req-1',
|
||||
title: 'Build warning panel',
|
||||
categoryId: 'frontend',
|
||||
assigneeId: 'dev-other',
|
||||
priority: 'P1',
|
||||
expectedStartAt: '2026-06-20T01:00:00.000Z',
|
||||
expectedEndAt: '2026-06-26T10:00:00.000Z',
|
||||
status: 'in_progress',
|
||||
isBlocked: false,
|
||||
actualStartAt: '2026-06-20T01:00:00.000Z',
|
||||
createdBy: 'pm-1',
|
||||
createdAt: '2026-06-20T01:00:00.000Z',
|
||||
updatedAt: '2026-06-24T02:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function testCase(patch: Partial<TestCase> = {}): TestCase {
|
||||
return {
|
||||
id: 'tc-1',
|
||||
caseNo: 'TC-001',
|
||||
versionId: 'ver-1',
|
||||
title: 'Verify warning panel',
|
||||
categoryId: 'ui-interaction',
|
||||
priority: 'P2',
|
||||
assigneeId: 'qa-other',
|
||||
status: 'running',
|
||||
startedAt: '2026-06-20T03:00:00.000Z',
|
||||
createdBy: 'qa-other',
|
||||
createdAt: '2026-06-20T01:00:00.000Z',
|
||||
updatedAt: '2026-06-24T02:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function bug(patch: Partial<Bug> = {}): Bug {
|
||||
return {
|
||||
id: 'bug-1',
|
||||
bugNo: 'BUG-001',
|
||||
versionId: 'ver-1',
|
||||
testCaseId: 'tc-1',
|
||||
title: 'Warning count mismatch',
|
||||
description: 'The count is wrong.',
|
||||
severity: 'major',
|
||||
priority: 'P1',
|
||||
reportedBy: 'qa-other',
|
||||
assigneeId: 'dev-other',
|
||||
status: 'open',
|
||||
createdAt: '2026-06-20T01:00:00.000Z',
|
||||
updatedAt: '2026-06-24T02:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildVersionDailyEvidence groups version activity and detects no activity/no update', () => {
|
||||
const workItems = buildXiaobaoWorkItems({
|
||||
plans: [plan()],
|
||||
devTasks: [devTask()],
|
||||
testCases: [testCase()],
|
||||
bugs: [bug()],
|
||||
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
|
||||
requirementVersionMap: new Map([['req-1', 'ver-1']]),
|
||||
});
|
||||
const activities: WorkActivity[] = [
|
||||
{
|
||||
id: 'act-delivery',
|
||||
actorId: 'dev-other',
|
||||
date: '2026-06-29',
|
||||
occurredAt: '2026-06-29T02:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'dev-1',
|
||||
action: 'dev_task_submitted',
|
||||
category: 'delivery',
|
||||
title: 'Build warning panel',
|
||||
summary: 'Submitted development task',
|
||||
},
|
||||
{
|
||||
id: 'act-risk',
|
||||
actorId: 'qa-other',
|
||||
date: '2026-06-29',
|
||||
occurredAt: '2026-06-29T03:00:00.000Z',
|
||||
sourceType: 'bug',
|
||||
sourceId: 'bug-1',
|
||||
action: 'bug_blocked',
|
||||
category: 'risk',
|
||||
title: 'Warning count mismatch',
|
||||
summary: 'Blocked by missing data',
|
||||
},
|
||||
];
|
||||
const worklogs: TaskWorklog[] = [
|
||||
{
|
||||
id: 'wl-note',
|
||||
taskId: 'tc-1',
|
||||
userId: 'qa-other',
|
||||
date: '2026-06-29',
|
||||
hours: 1,
|
||||
workContent: 'Continued regression testing.',
|
||||
createdAt: '2026-06-29T04:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const evidence = buildVersionDailyEvidence({
|
||||
versionId: 'ver-1',
|
||||
workItems,
|
||||
activities,
|
||||
worklogs,
|
||||
now: new Date('2026-06-29T08:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.deepEqual(evidence.todayDeliveries.map((item) => item.id), ['act-delivery']);
|
||||
assert.deepEqual(evidence.todayRisks.map((item) => item.id), ['act-risk']);
|
||||
assert.deepEqual(evidence.progressNotes.map((item) => item.id), ['wl-note']);
|
||||
assert.equal(evidence.recentActivityCount, 3);
|
||||
assert.equal(evidence.lastActivityAt, '2026-06-29T04:00:00.000Z');
|
||||
assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_update' && risk.itemId === 'plan-1'));
|
||||
assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_activity' && risk.itemId === 'plan-1'));
|
||||
});
|
||||
|
||||
test('buildXiaobaoWorkItems keeps all version work without current-user filtering', () => {
|
||||
const items = buildXiaobaoWorkItems({
|
||||
plans: [plan({ owner: 'pm-other' })],
|
||||
devTasks: [devTask({ assigneeId: 'dev-other' })],
|
||||
testCases: [testCase({ assigneeId: 'qa-other' })],
|
||||
bugs: [bug({ assigneeId: 'dev-other' })],
|
||||
versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }],
|
||||
requirementVersionMap: new Map([['req-1', 'ver-1']]),
|
||||
});
|
||||
|
||||
assert.deepEqual(items.map((item) => item.id), ['plan-1', 'dev-1', 'tc-1', 'bug-1']);
|
||||
assert.deepEqual(items.map((item) => item.versionId), ['ver-1', 'ver-1', 'ver-1', 'ver-1']);
|
||||
});
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { SilentRisk } from './xiaobao-risk';
|
||||
import type { Bug } from './bug';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
import type { WorkActivity } from './work-activity';
|
||||
import type { WorkItem } from './workspace-engine';
|
||||
|
||||
export interface EvidenceItem {
|
||||
id: string;
|
||||
@@ -18,3 +25,336 @@ export interface VersionDailyEvidence {
|
||||
lastActivityAt?: string;
|
||||
silentRisks?: SilentRisk[];
|
||||
}
|
||||
|
||||
export interface BuildVersionDailyEvidenceInput {
|
||||
versionId: string;
|
||||
workItems: WorkItem[];
|
||||
activities?: WorkActivity[];
|
||||
worklogs?: TaskWorklog[];
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface XiaobaoVersionContext {
|
||||
id: string;
|
||||
name: string;
|
||||
productName?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export interface BuildXiaobaoWorkItemsInput {
|
||||
plans?: VersionPlan[];
|
||||
devTasks?: DevTask[];
|
||||
testCases?: TestCase[];
|
||||
bugs?: Bug[];
|
||||
versions: XiaobaoVersionContext[];
|
||||
requirementVersionMap: Map<string, string> | Record<string, string>;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']);
|
||||
|
||||
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
|
||||
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
|
||||
const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap);
|
||||
const items: WorkItem[] = [];
|
||||
|
||||
for (const plan of input.plans ?? []) {
|
||||
const version = versionMap.get(plan.versionId);
|
||||
items.push({
|
||||
id: plan.id,
|
||||
type: plan.type === 'research' ? 'plan_research' : plan.type === 'product' ? 'plan_product' : 'plan_ui',
|
||||
title: plan.title,
|
||||
status: plan.status,
|
||||
completed: plan.status === 'completed',
|
||||
productName: version?.productName ?? '-',
|
||||
projectName: version?.projectName ?? '-',
|
||||
versionName: version?.name ?? '-',
|
||||
versionId: plan.versionId,
|
||||
extra: {
|
||||
startTime: plan.startTime,
|
||||
endTime: plan.endTime,
|
||||
actualStartAt: plan.actualStartAt,
|
||||
completedAt: plan.completedAt,
|
||||
},
|
||||
raw: plan,
|
||||
});
|
||||
}
|
||||
|
||||
for (const task of input.devTasks ?? []) {
|
||||
const versionId = requirementVersionMap.get(task.requirementId) ?? '';
|
||||
const version = versionMap.get(versionId);
|
||||
items.push({
|
||||
id: task.id,
|
||||
type: 'devTask',
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
completed: task.status === 'submitted',
|
||||
productName: version?.productName ?? '-',
|
||||
projectName: version?.projectName ?? '-',
|
||||
versionName: version?.name ?? '-',
|
||||
versionId,
|
||||
priority: task.priority,
|
||||
extra: {
|
||||
taskNo: task.taskNo,
|
||||
expectedStartAt: task.expectedStartAt,
|
||||
expectedEndAt: task.expectedEndAt,
|
||||
actualStartAt: task.actualStartAt,
|
||||
actualEndAt: task.actualEndAt,
|
||||
isBlocked: task.isBlocked,
|
||||
blockReason: task.blockReason,
|
||||
},
|
||||
raw: task,
|
||||
});
|
||||
}
|
||||
|
||||
for (const testCase of input.testCases ?? []) {
|
||||
const version = versionMap.get(testCase.versionId);
|
||||
items.push({
|
||||
id: testCase.id,
|
||||
type: 'testCase',
|
||||
title: testCase.title,
|
||||
status: testCase.status,
|
||||
completed: testCase.status === 'passed',
|
||||
productName: version?.productName ?? '-',
|
||||
projectName: version?.projectName ?? '-',
|
||||
versionName: version?.name ?? '-',
|
||||
versionId: testCase.versionId,
|
||||
priority: testCase.priority,
|
||||
extra: {
|
||||
caseNo: testCase.caseNo,
|
||||
requirementId: testCase.requirementId,
|
||||
plannedTestAt: testCase.plannedTestAt,
|
||||
plannedEndAt: testCase.plannedEndAt,
|
||||
startedAt: testCase.startedAt,
|
||||
completedAt: testCase.completedAt,
|
||||
},
|
||||
raw: testCase,
|
||||
});
|
||||
}
|
||||
|
||||
for (const bug of input.bugs ?? []) {
|
||||
const version = versionMap.get(bug.versionId);
|
||||
items.push({
|
||||
id: bug.id,
|
||||
type: 'bug',
|
||||
title: bug.title,
|
||||
status: bug.status,
|
||||
completed: bug.status === 'closed' || bug.status === 'rejected',
|
||||
productName: version?.productName ?? '-',
|
||||
projectName: version?.projectName ?? '-',
|
||||
versionName: version?.name ?? '-',
|
||||
versionId: bug.versionId,
|
||||
priority: bug.priority,
|
||||
extra: {
|
||||
bugNo: bug.bugNo,
|
||||
severity: bug.severity,
|
||||
testCaseId: bug.testCaseId,
|
||||
plannedFixAt: bug.plannedFixAt,
|
||||
},
|
||||
raw: bug,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput): VersionDailyEvidence {
|
||||
const now = input.now ?? new Date();
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
const versionItems = input.workItems.filter((item) => item.versionId === input.versionId);
|
||||
const versionItemIds = new Set(versionItems.map((item) => item.id));
|
||||
const itemMap = new Map(versionItems.map((item) => [item.id, item]));
|
||||
const versionActivities = (input.activities ?? []).filter((activity) => versionItemIds.has(activity.sourceId));
|
||||
const versionWorklogs = (input.worklogs ?? []).filter((worklog) => versionItemIds.has(worklog.taskId));
|
||||
const todayActivities = versionActivities.filter((activity) => activity.date === today);
|
||||
const todayWorklogs = versionWorklogs.filter((worklog) => worklog.date === today);
|
||||
const recentSince = now.getTime() - 3 * 86_400_000;
|
||||
|
||||
const recentActivityCount = [
|
||||
...versionActivities.map((activity) => activity.occurredAt),
|
||||
...versionWorklogs.map((worklog) => worklog.createdAt),
|
||||
].filter((value) => {
|
||||
const date = parseDate(value);
|
||||
return Boolean(date && date.getTime() >= recentSince);
|
||||
}).length;
|
||||
const lastActivityAt = latestIso([
|
||||
...versionActivities.map((activity) => activity.occurredAt),
|
||||
...versionWorklogs.map((worklog) => worklog.createdAt),
|
||||
]);
|
||||
|
||||
const touchedTodayIds = new Set([
|
||||
...todayActivities.map((activity) => activity.sourceId),
|
||||
...todayWorklogs.map((worklog) => worklog.taskId),
|
||||
]);
|
||||
|
||||
return {
|
||||
todayDeliveries: todayActivities.filter((activity) => activity.category === 'delivery').map(activityToEvidenceItem),
|
||||
todayProgress: todayActivities.filter((activity) => activity.category === 'progress').map(activityToEvidenceItem),
|
||||
todayRisks: todayActivities.filter((activity) => activity.category === 'risk').map(activityToEvidenceItem),
|
||||
progressNotes: [
|
||||
...todayActivities.filter((activity) => activity.category === 'note').map(activityToEvidenceItem),
|
||||
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
|
||||
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
|
||||
needsProgressItems: versionItems
|
||||
.filter((item) => shouldNeedProgress(item, touchedTodayIds, versionActivities, versionWorklogs, now))
|
||||
.map(itemToEvidenceItem),
|
||||
recentActivityCount,
|
||||
lastActivityAt,
|
||||
silentRisks: buildSilentRisks(versionItems, versionActivities, versionWorklogs, now),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRequirementVersionMap(input: Map<string, string> | Record<string, string>): Map<string, string> {
|
||||
return input instanceof Map ? input : new Map(Object.entries(input));
|
||||
}
|
||||
|
||||
function activityToEvidenceItem(activity: WorkActivity): EvidenceItem {
|
||||
return {
|
||||
id: activity.id,
|
||||
title: activity.title,
|
||||
summary: activity.summary,
|
||||
occurredAt: activity.occurredAt,
|
||||
actorId: activity.actorId,
|
||||
};
|
||||
}
|
||||
|
||||
function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem {
|
||||
return {
|
||||
id: worklog.id,
|
||||
title: item?.title ?? worklog.taskId,
|
||||
summary: worklog.workContent,
|
||||
occurredAt: worklog.createdAt,
|
||||
actorId: worklog.userId,
|
||||
};
|
||||
}
|
||||
|
||||
function itemToEvidenceItem(item: WorkItem): EvidenceItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
summary: `${item.status}`,
|
||||
occurredAt: getLatestItemTouchAt(item) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildSilentRisks(
|
||||
items: WorkItem[],
|
||||
activities: WorkActivity[],
|
||||
worklogs: TaskWorklog[],
|
||||
now: Date,
|
||||
): SilentRisk[] {
|
||||
const risks: SilentRisk[] = [];
|
||||
for (const item of items) {
|
||||
if (item.completed) continue;
|
||||
const latestUpdateAt = getLatestItemTouchAt(item);
|
||||
const latestEvidenceAt = latestIso([
|
||||
...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt),
|
||||
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
|
||||
]);
|
||||
const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]);
|
||||
const evidenceDays = daysSince(latestEvidenceAt, now);
|
||||
const updateDays = daysSince(latestUpdateAt, now);
|
||||
const touchDays = daysSince(latestTouchAt, now);
|
||||
|
||||
if (updateDays >= 5) {
|
||||
risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`));
|
||||
}
|
||||
if (evidenceDays >= 8) {
|
||||
risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`));
|
||||
}
|
||||
if (evidenceDays >= 4) {
|
||||
risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`));
|
||||
}
|
||||
if (isActiveUnfinished(item) && touchDays >= 3) {
|
||||
risks.push(makeSilentRisk('unhandled', item, `Active work has been untouched for ${Math.floor(touchDays)} days.`));
|
||||
}
|
||||
}
|
||||
return risks;
|
||||
}
|
||||
|
||||
function makeSilentRisk(key: string, item: WorkItem, detail: string): SilentRisk {
|
||||
return {
|
||||
key,
|
||||
title: item.title,
|
||||
detail,
|
||||
itemId: item.id,
|
||||
itemType: workItemToSilentItemType(item),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldNeedProgress(
|
||||
item: WorkItem,
|
||||
touchedTodayIds: Set<string>,
|
||||
activities: WorkActivity[],
|
||||
worklogs: TaskWorklog[],
|
||||
now: Date,
|
||||
): boolean {
|
||||
if (item.completed || touchedTodayIds.has(item.id) || !isActiveUnfinished(item)) return false;
|
||||
const latestTouchAt = latestIso([
|
||||
getLatestItemTouchAt(item),
|
||||
...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt),
|
||||
...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt),
|
||||
]);
|
||||
return daysSince(latestTouchAt, now) >= 3;
|
||||
}
|
||||
|
||||
function isActiveUnfinished(item: WorkItem): boolean {
|
||||
return !item.completed && ACTIVE_STATUSES.has(item.status);
|
||||
}
|
||||
|
||||
function workItemToSilentItemType(item: WorkItem): SilentRisk['itemType'] {
|
||||
if (item.type === 'devTask') return 'dev_task';
|
||||
if (item.type === 'testCase') return 'test_case';
|
||||
if (item.type === 'bug') return 'bug';
|
||||
return 'version';
|
||||
}
|
||||
|
||||
function getLatestItemTouchAt(item: WorkItem): string | undefined {
|
||||
const raw = (item.raw ?? {}) as unknown as Record<string, unknown>;
|
||||
const extra = item.extra ?? {};
|
||||
return latestIso([
|
||||
asString(raw.updatedAt),
|
||||
asString(raw.completedAt),
|
||||
asString(raw.actualEndAt),
|
||||
asString(raw.resolvedAt),
|
||||
asString(raw.closedAt),
|
||||
asString(raw.actualStartAt),
|
||||
asString(raw.startedAt),
|
||||
asString(raw.createdAt),
|
||||
asString(extra.completedAt),
|
||||
asString(extra.actualEndAt),
|
||||
asString(extra.actualStartAt),
|
||||
asString(extra.startedAt),
|
||||
asString(extra.startTime),
|
||||
]);
|
||||
}
|
||||
|
||||
function latestIso(values: Array<string | undefined>): string | undefined {
|
||||
let latest: string | undefined;
|
||||
let latestTime = Number.NEGATIVE_INFINITY;
|
||||
for (const value of values) {
|
||||
const date = parseDate(value);
|
||||
if (!date) continue;
|
||||
const time = date.getTime();
|
||||
if (time > latestTime) {
|
||||
latest = value;
|
||||
latestTime = time;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function daysSince(value: string | undefined, now: Date): number {
|
||||
const date = parseDate(value);
|
||||
if (!date) return Number.POSITIVE_INFINITY;
|
||||
return Math.max(0, (now.getTime() - date.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function parseDate(value: string | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime()) ? date : undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
40
apps/web/lib/xiaobao-risk-trend.test.ts
Normal file
40
apps/web/lib/xiaobao-risk-trend.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend';
|
||||
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||
|
||||
function snapshot(patch: Partial<XiaobaoRiskSnapshot>): XiaobaoRiskSnapshot {
|
||||
return {
|
||||
versionId: 'ver-1',
|
||||
date: '2026-06-27',
|
||||
riskScore: 38,
|
||||
riskLevel: 'attention',
|
||||
openBugCount: 0,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
confidence: 80,
|
||||
createdAt: '2026-06-27T01:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
test('summarizeRiskTrend detects continuous rising risk across three snapshots', () => {
|
||||
const trend = summarizeRiskTrend([
|
||||
snapshot({ riskScore: 71, createdAt: '2026-06-29T01:00:00.000Z', date: '2026-06-29' }),
|
||||
snapshot({ riskScore: 38, createdAt: '2026-06-27T01:00:00.000Z', date: '2026-06-27' }),
|
||||
snapshot({ riskScore: 52, createdAt: '2026-06-28T01:00:00.000Z', date: '2026-06-28' }),
|
||||
]);
|
||||
|
||||
assert.equal(trend.direction, 'up');
|
||||
assert.equal(trend.delta, 33);
|
||||
assert.equal(trend.pattern, 'continuous_rising');
|
||||
});
|
||||
|
||||
test('buildRiskSignature changes when score and bug counts change', () => {
|
||||
const base = buildRiskSignature(snapshot({ riskScore: 52, openBugCount: 1 }));
|
||||
const changed = buildRiskSignature(snapshot({ riskScore: 71, openBugCount: 2 }));
|
||||
|
||||
assert.notEqual(base, changed);
|
||||
});
|
||||
@@ -13,3 +13,81 @@ export interface XiaobaoRiskSnapshot {
|
||||
confidence: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RiskTrendSummary {
|
||||
direction: 'up' | 'down' | 'flat' | 'unknown';
|
||||
delta: number;
|
||||
summary: string;
|
||||
pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
|
||||
}
|
||||
|
||||
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
|
||||
const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b));
|
||||
if (sorted.length < 2) {
|
||||
return { direction: 'unknown', delta: 0, summary: 'No historical risk snapshots yet.', pattern: 'unknown' };
|
||||
}
|
||||
|
||||
const first = sorted[0];
|
||||
const latest = sorted[sorted.length - 1];
|
||||
const delta = clampScore(latest.riskScore) - clampScore(first.riskScore);
|
||||
const recentThree = sorted.slice(-3);
|
||||
|
||||
if (recentThree.length === 3) {
|
||||
const [a, b, c] = recentThree.map((snapshot) => clampScore(snapshot.riskScore));
|
||||
if (a < b && b < c) {
|
||||
return {
|
||||
direction: 'up',
|
||||
delta,
|
||||
summary: `Risk rose continuously from ${a} to ${c}.`,
|
||||
pattern: 'continuous_rising',
|
||||
};
|
||||
}
|
||||
if (a > b && b > c) {
|
||||
return {
|
||||
direction: 'down',
|
||||
delta,
|
||||
summary: `Risk fell continuously from ${a} to ${c}.`,
|
||||
pattern: 'continuous_falling',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (delta >= 10) {
|
||||
return { direction: 'up', delta, summary: `Risk rose by ${delta} points.`, pattern: 'score_delta' };
|
||||
}
|
||||
if (delta <= -10) {
|
||||
return { direction: 'down', delta, summary: `Risk fell by ${Math.abs(delta)} points.`, pattern: 'score_delta' };
|
||||
}
|
||||
return { direction: 'flat', delta, summary: 'Risk is stable.', pattern: 'stable' };
|
||||
}
|
||||
|
||||
export function summarizeRiskTrendWithCurrent(
|
||||
snapshots: XiaobaoRiskSnapshot[] = [],
|
||||
current: XiaobaoRiskSnapshot,
|
||||
): RiskTrendSummary {
|
||||
return summarizeRiskTrend([...snapshots, current]);
|
||||
}
|
||||
|
||||
export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {
|
||||
return [
|
||||
snapshot.versionId,
|
||||
snapshot.date,
|
||||
clampScore(snapshot.riskScore),
|
||||
snapshot.riskLevel,
|
||||
snapshot.forecastReleaseDate ?? '',
|
||||
snapshot.openBugCount,
|
||||
snapshot.failedTestCount,
|
||||
snapshot.blockedCount,
|
||||
snapshot.silentRiskCount,
|
||||
clampScore(snapshot.confidence),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number {
|
||||
const date = new Date(snapshot.createdAt || snapshot.date).getTime();
|
||||
return Number.isFinite(date) ? date : 0;
|
||||
}
|
||||
|
||||
function clampScore(score: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import { getTestCaseEstimateHours } from './test-case';
|
||||
import type { VersionDailyEvidence } from './xiaobao-risk-evidence';
|
||||
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||
import { WORK_HOURS, addWorkHours } from './work-hours';
|
||||
|
||||
export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
|
||||
@@ -72,7 +73,7 @@ export interface CalcXiaobaoVersionRiskInput {
|
||||
testCases?: TestCase[];
|
||||
bugs?: Bug[];
|
||||
now?: Date;
|
||||
snapshots?: Array<{ riskScore: number; createdAt?: string; date?: string }>;
|
||||
snapshots?: XiaobaoRiskSnapshot[];
|
||||
silentRisks?: SilentRisk[];
|
||||
dailyEvidence?: VersionDailyEvidence;
|
||||
recentActivityCount?: number;
|
||||
@@ -197,7 +198,19 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
|
||||
const hasBlockingRisk = criticalBugCount > 0 || blockedCount > 0;
|
||||
const riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk);
|
||||
const confidence = calcConfidence(input, devTasks, testCases, bugs);
|
||||
const trend = calcTrend(riskScore, input.snapshots ?? []);
|
||||
const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], {
|
||||
versionId: input.version.id,
|
||||
date: now.toISOString().slice(0, 10),
|
||||
riskScore,
|
||||
riskLevel,
|
||||
forecastReleaseDate,
|
||||
openBugCount: signals.criticalBugCount,
|
||||
failedTestCount: signals.failedTestCount,
|
||||
blockedCount: signals.blockedCount,
|
||||
silentRiskCount: signals.silentRiskCount,
|
||||
confidence,
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
versionId: input.version.id,
|
||||
@@ -280,27 +293,6 @@ function hasEstimate(item: DevTask | TestCase | Bug): boolean {
|
||||
|| (typeof item.aiEstimateHours === 'number' && item.aiEstimateHours > 0);
|
||||
}
|
||||
|
||||
function calcTrend(
|
||||
currentScore: number,
|
||||
snapshots: Array<{ riskScore: number; createdAt?: string; date?: string }>,
|
||||
): XiaobaoVersionRisk['trend'] {
|
||||
if (snapshots.length === 0) {
|
||||
return { direction: 'unknown', delta: 0, summary: '暂无历史快照。' };
|
||||
}
|
||||
const latest = [...snapshots].sort((a, b) => {
|
||||
const aTime = parseDate(a.createdAt ?? a.date)?.getTime() ?? 0;
|
||||
const bTime = parseDate(b.createdAt ?? b.date)?.getTime() ?? 0;
|
||||
return bTime - aTime;
|
||||
})[0];
|
||||
const delta = clampScore(currentScore) - clampScore(latest.riskScore);
|
||||
const direction = Math.abs(delta) < 5 ? 'flat' : delta > 0 ? 'up' : 'down';
|
||||
const summary = direction === 'flat'
|
||||
? '风险基本持平。'
|
||||
: direction === 'up'
|
||||
? `风险上升 ${delta} 分。`
|
||||
: `风险下降 ${Math.abs(delta)} 分。`;
|
||||
return { direction, delta, summary };
|
||||
}
|
||||
|
||||
function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel {
|
||||
if (confidence >= 75) return 'high';
|
||||
|
||||
Reference in New Issue
Block a user