feat(版本详情): 完善任务流程与风险预警

This commit is contained in:
Script Generator
2026-06-29 18:14:22 +08:00
parent b48a7e2049
commit b7d48f66aa
24 changed files with 3085 additions and 118 deletions

View File

@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DevTask } from './dev-task';
import { canStartDevTask, hasDevTaskPlan, needsDevTaskClaim, type DevTask } from './dev-task';
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from './dev-task-workflow';
function task(patch: Partial<DevTask> = {}): DevTask {
@@ -47,6 +47,44 @@ test('todo to in_progress writes actualStartAt from manual click time', () => {
assert.equal(result.patch?.actualStartAt, '2026-06-25T03:30:00.000Z');
});
test('AI dev task without an assignee must be claimed with a plan before starting', () => {
const draft = task({
assigneeId: '',
expectedStartAt: '',
expectedEndAt: '',
aiDraft: true,
});
assert.equal(needsDevTaskClaim(draft), true);
assert.equal(hasDevTaskPlan(draft), false);
assert.equal(canStartDevTask(draft), false);
const result = applyDevTaskTransition(draft, 'in_progress', {
now: new Date('2026-06-25T03:30:00.000Z'),
});
assert.equal(result.ok, false);
});
test('recommended assignee dev task still needs a plan before starting', () => {
const draft = task({
assigneeId: 'Alice',
expectedStartAt: '',
expectedEndAt: '',
aiDraft: true,
});
assert.equal(needsDevTaskClaim(draft), false);
assert.equal(hasDevTaskPlan(draft), false);
assert.equal(canStartDevTask(draft), false);
const result = applyDevTaskTransition(draft, 'in_progress', {
now: new Date('2026-06-25T03:30:00.000Z'),
});
assert.equal(result.ok, false);
});
test('in_progress to testing does not write actualEndAt', () => {
const result = applyDevTaskTransition(task({
status: 'in_progress',
@@ -71,6 +109,21 @@ test('testing to submitted writes actualEndAt', () => {
assert.equal(result.patch?.actualEndAt, '2026-06-25T05:00:00.000Z');
});
test('blocked task cannot be submitted to test until unblocked', () => {
const result = applyDevTaskTransition(task({
status: 'testing',
actualStartAt: '2026-06-25T03:30:00.000Z',
isBlocked: true,
blockReason: '等待接口联调',
}), 'submitted', {
now: new Date('2026-06-25T05:00:00.000Z'),
});
assert.equal(result.ok, false);
assert.equal(result.patch, undefined);
assert.match(result.message || '', /\u963b\u585e/);
});
test('invalid transition is rejected', () => {
const result = applyDevTaskTransition(task(), 'submitted', {
now: new Date('2026-06-25T05:00:00.000Z'),

View File

@@ -1,5 +1,5 @@
import type { DevTask, DevTaskStatus } from './dev-task';
import { canTransition } from './dev-task';
import { canStartDevTask, canTransition } from './dev-task';
export interface DevTaskWorkflowResult {
ok: boolean;
@@ -30,7 +30,15 @@ export function applyDevTaskTransition(
return { ok: false, message: `不允许从「${task.status}」流转到「${to}` };
}
if (to === 'submitted' && task.isBlocked) {
return { ok: false, message: '任务仍处于阻塞中,请先解除阻塞后再提测' };
}
const nowIso = (options.now ?? new Date()).toISOString();
if (to === 'in_progress' && !canStartDevTask(task)) {
return { ok: false, message: '开始开发前需要先领取并填写预计开始和预计截止时间' };
}
const patch: Partial<DevTask> = {
status: to,
aiDraft: false,

View File

@@ -112,6 +112,21 @@ function roundEffortHours(hours: number): number {
return Number(hours.toFixed(2));
}
export function needsDevTaskClaim(task: Pick<DevTask, 'assigneeId'>): boolean {
return !task.assigneeId?.trim();
}
export function hasDevTaskPlan(task: Pick<DevTask, 'expectedStartAt' | 'expectedEndAt'>): boolean {
if (!task.expectedStartAt || !task.expectedEndAt) return false;
const start = new Date(task.expectedStartAt).getTime();
const end = new Date(task.expectedEndAt).getTime();
return Number.isFinite(start) && Number.isFinite(end) && end > start;
}
export function canStartDevTask(task: Pick<DevTask, 'assigneeId' | 'expectedStartAt' | 'expectedEndAt'>): boolean {
return !needsDevTaskClaim(task) && hasDevTaskPlan(task);
}
export function getActualHours(task: DevTask, now: Date = new Date()): number {
if (!task.actualStartAt) return 0;
const end = task.actualEndAt ?? (task.status === 'submitted' ? task.updatedAt : now.toISOString());

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import type { TestCase } from './test-case';
import { canStartTestCase, hasTestCasePlan, needsTestCaseClaim } from './test-case';
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from './test-case-workflow';
function tc(patch: Partial<TestCase> = {}): TestCase {
@@ -14,6 +15,9 @@ function tc(patch: Partial<TestCase> = {}): TestCase {
categoryId: 'cat-test-functional',
priority: 'P2',
status: 'pending',
assigneeId: 'QA',
plannedTestAt: '2026-06-25T01:00:00.000Z',
plannedEndAt: '2026-06-25T02:00:00.000Z',
createdBy: 'QA',
createdAt: '2026-06-25T00:00:00.000Z',
updatedAt: '2026-06-25T00:00:00.000Z',
@@ -48,6 +52,44 @@ test('pending to running writes startedAt', () => {
assert.equal(result.patch?.startedAt, '2026-06-25T01:00:00.000Z');
});
test('AI test case without an assignee must be claimed with a plan before running', () => {
const draft = tc({
assigneeId: undefined,
plannedTestAt: undefined,
plannedEndAt: undefined,
aiDraft: true,
});
assert.equal(needsTestCaseClaim(draft), true);
assert.equal(hasTestCasePlan(draft), false);
assert.equal(canStartTestCase(draft), false);
const result = applyTestCaseTransition(draft, 'running', {
now: new Date('2026-06-25T01:00:00.000Z'),
});
assert.equal(result.ok, false);
});
test('recommended assignee test case still needs a plan before running', () => {
const draft = tc({
assigneeId: 'QA',
plannedTestAt: undefined,
plannedEndAt: undefined,
aiDraft: true,
});
assert.equal(needsTestCaseClaim(draft), false);
assert.equal(hasTestCasePlan(draft), false);
assert.equal(canStartTestCase(draft), false);
const result = applyTestCaseTransition(draft, 'running', {
now: new Date('2026-06-25T01:00:00.000Z'),
});
assert.equal(result.ok, false);
});
test('running to passed writes completedAt', () => {
const result = applyTestCaseTransition(tc({
status: 'running',

View File

@@ -1,5 +1,5 @@
import type { TestCase, TestCaseStatus } from './test-case';
import { canTcTransition, getTestCaseRoundNo } from './test-case';
import { canStartTestCase, canTcTransition, getTestCaseRoundNo } from './test-case';
export interface TestCaseWorkflowResult {
ok: boolean;
@@ -36,6 +36,10 @@ export function applyTestCaseTransition(
}
const nowIso = (options.now ?? new Date()).toISOString();
if (to === 'running' && !canStartTestCase(testCase)) {
return { ok: false, message: '开始测试前需要先领取并填写计划开始和计划结束时间' };
}
const patch: Partial<TestCase> = {
status: to,
aiDraft: false,

View File

@@ -23,6 +23,7 @@ export interface TestCase {
estimateHours?: number;
aiEstimateHours?: number;
plannedTestAt?: string;
plannedEndAt?: string;
startedAt?: string;
completedAt?: string;
executedAt?: string;
@@ -98,6 +99,7 @@ export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestC
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : undefined,
aiEstimateHours: typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0 ? testCase.aiEstimateHours : undefined,
plannedTestAt: testCase.plannedTestAt,
plannedEndAt: testCase.plannedEndAt,
startedAt: testCase.startedAt,
completedAt: testCase.completedAt,
executedAt: testCase.executedAt,
@@ -172,6 +174,7 @@ export function copyTestCaseToRound(source: TestCase, roundNo: number, createdBy
estimateHours: source.estimateHours,
aiEstimateHours: source.aiEstimateHours,
plannedTestAt: source.plannedTestAt,
plannedEndAt: source.plannedEndAt,
assigneeId: source.assigneeId,
startedAt: undefined,
completedAt: undefined,
@@ -224,6 +227,23 @@ export function getTestCaseEstimateHours(tc: TestCase): number {
return 0;
}
export function needsTestCaseClaim(testCase: Pick<TestCase, 'assigneeId'>): boolean {
return !testCase.assigneeId?.trim();
}
export function hasTestCasePlan(testCase: Pick<TestCase, 'plannedTestAt' | 'plannedEndAt'>): boolean {
if (!testCase.plannedTestAt || !testCase.plannedEndAt) return false;
const start = new Date(testCase.plannedTestAt).getTime();
const end = new Date(testCase.plannedEndAt).getTime();
return Number.isFinite(start) && Number.isFinite(end) && end > start;
}
export function canStartTestCase(
testCase: Pick<TestCase, 'assigneeId' | 'plannedTestAt' | 'plannedEndAt'>,
): boolean {
return !needsTestCaseClaim(testCase) && hasTestCasePlan(testCase);
}
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
if (!tc.startedAt) return 0;
const isTerminal = tc.status === 'passed' || tc.status === 'failed' || tc.status === 'blocked';

View File

@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Bug } from './bug';
import { calcXiaobaoVersionRisk } from './xiaobao-risk';
import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend';
import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
@@ -38,3 +40,55 @@ test('buildRiskSignature changes when score and bug counts change', () => {
assert.notEqual(base, changed);
});
test('calcXiaobaoVersionRisk uses all open bugs in the current trend snapshot signature', () => {
const now = new Date('2026-07-02T01:00:00.000Z');
const risk = calcXiaobaoVersionRisk({
version: {
id: 'ver-1',
name: 'V1.0',
expectedReleaseDate: '2026-07-03T10:00:00.000Z',
members: [{ name: 'Alice' }],
},
devTasks: [],
testCases: [],
bugs: [bug({ severity: 'major', priority: 'P2' })],
now,
});
const currentSignature = (risk.trend as { currentSignature?: string }).currentSignature;
assert.equal(risk.signals.openBugCount, 1);
assert.equal(risk.signals.criticalBugCount, 0);
assert.equal(currentSignature, buildRiskSignature({
versionId: risk.versionId,
date: now.toISOString().slice(0, 10),
riskScore: risk.riskScore,
riskLevel: risk.riskLevel,
forecastReleaseDate: risk.forecastReleaseDate,
openBugCount: 1,
failedTestCount: 0,
blockedCount: 0,
silentRiskCount: 0,
confidence: risk.confidence,
createdAt: now.toISOString(),
}));
});
function bug(patch: Partial<Bug> = {}): Bug {
return {
id: 'bug-1',
bugNo: 'BUG-001',
versionId: 'ver-1',
testCaseId: 'tc-1',
title: 'Non-critical open bug',
description: 'Open but not critical.',
severity: 'major',
priority: 'P2',
reportedBy: 'qa-1',
assigneeId: 'dev-1',
status: 'open',
createdAt: '2026-07-01T09:00:00.000Z',
updatedAt: '2026-07-01T09:00:00.000Z',
...patch,
};
}

View File

@@ -19,6 +19,7 @@ export interface RiskTrendSummary {
delta: number;
summary: string;
pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown';
currentSignature?: string;
}
export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary {
@@ -65,7 +66,10 @@ export function summarizeRiskTrendWithCurrent(
snapshots: XiaobaoRiskSnapshot[] = [],
current: XiaobaoRiskSnapshot,
): RiskTrendSummary {
return summarizeRiskTrend([...snapshots, current]);
return {
...summarizeRiskTrend([...snapshots, current]),
currentSignature: buildRiskSignature(current),
};
}
export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string {

View File

@@ -204,7 +204,7 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao
riskScore,
riskLevel,
forecastReleaseDate,
openBugCount: signals.criticalBugCount,
openBugCount: signals.openBugCount,
failedTestCount: signals.failedTestCount,
blockedCount: signals.blockedCount,
silentRiskCount: signals.silentRiskCount,