feat(版本详情): 完善任务流程与风险预警
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user