feat(测试用例): 状态流转接入单条工作流

This commit is contained in:
Script Generator
2026-06-25 14:13:32 +08:00
parent 52833d81ac
commit c617625a99
5 changed files with 357 additions and 37 deletions

View File

@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { TestCase } from './test-case';
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from './test-case-workflow';
function tc(patch: Partial<TestCase> = {}): TestCase {
return {
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'version-1',
title: '拖拽排序正常',
description: '验证拖拽排序',
categoryId: 'cat-test-functional',
priority: 'P2',
status: 'pending',
createdBy: 'QA',
createdAt: '2026-06-25T00:00:00.000Z',
updatedAt: '2026-06-25T00:00:00.000Z',
...patch,
};
}
test('normalizeTestCaseOnCreate forces pending and clears actual timestamps', () => {
const result = normalizeTestCaseOnCreate(tc({
status: 'running',
startedAt: '2026-06-25T01:00:00.000Z',
completedAt: '2026-06-25T02:00:00.000Z',
}));
assert.equal(result.status, 'pending');
assert.equal(result.startedAt, undefined);
assert.equal(result.completedAt, undefined);
});
test('pending to running writes startedAt', () => {
const result = applyTestCaseTransition(tc(), 'running', {
now: new Date('2026-06-25T01:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.startedAt, '2026-06-25T01:00:00.000Z');
});
test('running to passed writes completedAt', () => {
const result = applyTestCaseTransition(tc({
status: 'running',
startedAt: '2026-06-25T01:00:00.000Z',
}), 'passed', {
now: new Date('2026-06-25T02:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.completedAt, '2026-06-25T02:00:00.000Z');
});
test('failed back to running clears failure and completion reason', () => {
const result = applyTestCaseTransition(tc({
status: 'failed',
startedAt: '2026-06-25T01:00:00.000Z',
completedAt: '2026-06-25T02:00:00.000Z',
failReason: '排序未保存',
}), 'running', {
now: new Date('2026-06-25T03:00:00.000Z'),
});
assert.equal(result.ok, true);
assert.equal(result.patch?.startedAt, undefined);
assert.equal(result.patch?.completedAt, undefined);
assert.equal(result.patch?.failReason, undefined);
});
test('invalid transition is rejected', () => {
const result = applyTestCaseTransition(tc(), 'passed', {
now: new Date('2026-06-25T02:00:00.000Z'),
});
assert.equal(result.ok, false);
});

View File

@@ -0,0 +1,59 @@
import type { TestCase, TestCaseStatus } from './test-case';
import { canTcTransition } from './test-case';
export interface TestCaseWorkflowResult {
ok: boolean;
patch?: Partial<TestCase>;
message?: string;
}
export interface TestCaseTransitionOptions {
now?: Date;
failReason?: string;
blockReason?: string;
}
export function normalizeTestCaseOnCreate(testCase: TestCase): TestCase {
return {
...testCase,
status: 'pending',
startedAt: undefined,
completedAt: undefined,
executedAt: undefined,
failReason: undefined,
blockReason: undefined,
};
}
export function applyTestCaseTransition(
testCase: TestCase,
to: TestCaseStatus,
options: TestCaseTransitionOptions = {},
): TestCaseWorkflowResult {
if (!canTcTransition(testCase.status, to)) {
return { ok: false, message: `不允许从「${testCase.status}」流转到「${to}` };
}
const nowIso = (options.now ?? new Date()).toISOString();
const patch: Partial<TestCase> = {
status: to,
aiDraft: false,
executedAt: nowIso,
};
if (to === 'running') {
if (!testCase.startedAt) patch.startedAt = nowIso;
patch.completedAt = undefined;
patch.failReason = undefined;
patch.blockReason = undefined;
}
if (to === 'passed' || to === 'failed' || to === 'blocked') {
patch.completedAt = nowIso;
}
if (to === 'failed' && options.failReason?.trim()) patch.failReason = options.failReason.trim();
if (to === 'blocked' && options.blockReason?.trim()) patch.blockReason = options.blockReason.trim();
return { ok: true, patch };
}

View File

@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TEST_CASE_STATUS_LABEL,
aggregateTestCaseHours,
calcTestProgress,
getTestCaseEstimateHours,
normalizeTestCase,
} from './test-case';
import { DEFAULT_TEST_CATEGORY_ID } from './task-category';
test('normalizeTestCase backfills missing categoryId', () => {
const tc = normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'v1',
title: '登录正常',
priority: 'P2',
status: 'pending',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any);
assert.equal(tc.categoryId, DEFAULT_TEST_CATEGORY_ID);
});
test('normalizeTestCase keeps existing categoryId', () => {
const tc = normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'v1',
title: '登录正常',
priority: 'P2',
status: 'pending',
categoryId: 'cat-test-api',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any);
assert.equal(tc.categoryId, 'cat-test-api');
});
test('normalizeTestCase backfills missing estimateHours', () => {
const tc = normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'v1',
title: '登录正常',
priority: 'P2',
status: 'pending',
categoryId: 'cat-test-api',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any);
assert.equal(tc.estimateHours, 0.5);
assert.equal(getTestCaseEstimateHours(tc), 0.5);
});
test('test case status labels use waiting and testing wording', () => {
assert.equal(TEST_CASE_STATUS_LABEL.pending, '待测试');
assert.equal(TEST_CASE_STATUS_LABEL.running, '测试中');
assert.equal(TEST_CASE_STATUS_LABEL.failed, '不通过');
});
test('calcTestProgress uses estimate-weighted completion', () => {
const progress = calcTestProgress([
normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'v1',
title: '待测试',
priority: 'P2',
status: 'pending',
categoryId: 'cat-test-functional',
estimateHours: 1,
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any),
normalizeTestCase({
id: 'tc-2',
caseNo: 'TC-002',
versionId: 'v1',
title: '已通过',
priority: 'P2',
status: 'passed',
categoryId: 'cat-test-functional',
estimateHours: 3,
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any),
]);
assert.equal(progress.completionRate, 75);
});
test('aggregateTestCaseHours returns estimate and actual totals', () => {
const hours = aggregateTestCaseHours([
normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'v1',
title: '测试中',
priority: 'P2',
status: 'running',
categoryId: 'cat-test-functional',
estimateHours: 1,
startedAt: '2026-06-25T01:00:00.000Z',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any),
], new Date('2026-06-25T02:00:00.000Z'));
assert.equal(hours.estimate, 1);
assert.equal(hours.actual, 1);
});

View File

@@ -1,5 +1,8 @@
import type { Priority } from './derive'; import type { Priority } from './derive';
import type { Reference } from './dev-task';
import { DEFAULT_TEST_CATEGORY_ID } from './task-category';
import { calcWorkHours, type TimeInterval } from './work-hours'; import { calcWorkHours, type TimeInterval } from './work-hours';
import { aggregateWorkEffort } from './work-effort-engine';
export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked'; export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
@@ -10,25 +13,30 @@ export interface TestCase {
requirementId?: string; requirementId?: string;
title: string; title: string;
description?: string; description?: string;
categoryId: string;
priority: Priority; priority: Priority;
assigneeId?: string; assigneeId?: string;
status: TestCaseStatus; status: TestCaseStatus;
estimateHours?: number;
startedAt?: string; startedAt?: string;
completedAt?: string; completedAt?: string;
executedAt?: string; executedAt?: string;
executedBy?: string; executedBy?: string;
failReason?: string; failReason?: string;
blockReason?: string; blockReason?: string;
references?: Reference[];
aiDraft?: boolean;
aiDraftAt?: string;
createdBy: string; createdBy: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export const TEST_CASE_STATUS_LABEL: Record<TestCaseStatus, string> = { export const TEST_CASE_STATUS_LABEL: Record<TestCaseStatus, string> = {
pending: '待执行', pending: '待测试',
running: '执行中', running: '测试中',
passed: '通过', passed: '通过',
failed: '失败', failed: '不通过',
blocked: '阻塞', blocked: '阻塞',
}; };
@@ -48,6 +56,14 @@ export const TC_ALLOWED_TRANSITIONS: Record<TestCaseStatus, TestCaseStatus[]> =
blocked: ['running'], blocked: ['running'],
}; };
export const TEST_CASE_STATUS_PROGRESS: Record<TestCaseStatus, number> = {
pending: 0,
running: 50,
passed: 100,
failed: 100,
blocked: 100,
};
export function canTcTransition(from: TestCaseStatus, to: TestCaseStatus): boolean { export function canTcTransition(from: TestCaseStatus, to: TestCaseStatus): boolean {
return TC_ALLOWED_TRANSITIONS[from].includes(to); return TC_ALLOWED_TRANSITIONS[from].includes(to);
} }
@@ -60,6 +76,38 @@ export function generateCaseNo(existingCases: TestCase[]): string {
return `TC-${String(maxNum + 1).padStart(3, '0')}`; return `TC-${String(maxNum + 1).padStart(3, '0')}`;
} }
export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestCase {
return {
id: testCase.id || `tc-${index + 1}`,
caseNo: testCase.caseNo || `TC-${String(index + 1).padStart(3, '0')}`,
versionId: testCase.versionId || '',
requirementId: testCase.requirementId,
title: testCase.title || `测试用例 ${index + 1}`,
description: testCase.description,
categoryId: testCase.categoryId || DEFAULT_TEST_CATEGORY_ID,
priority: testCase.priority || 'P2',
assigneeId: testCase.assigneeId,
status: testCase.status || 'pending',
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : 0.5,
startedAt: testCase.startedAt,
completedAt: testCase.completedAt,
executedAt: testCase.executedAt,
executedBy: testCase.executedBy,
failReason: testCase.failReason,
blockReason: testCase.blockReason,
references: testCase.references,
aiDraft: testCase.aiDraft,
aiDraftAt: testCase.aiDraftAt,
createdBy: testCase.createdBy || '系统',
createdAt: testCase.createdAt || new Date().toISOString(),
updatedAt: testCase.updatedAt || new Date().toISOString(),
};
}
export function normalizeTestCases(cases: Partial<TestCase>[] = []): TestCase[] {
return cases.map((tc, index) => normalizeTestCase(tc, index));
}
export function calcTestProgress(cases: TestCase[]): { total: number; executed: number; passed: number; failed: number; blocked: number; passRate: number; completionRate: number } { export function calcTestProgress(cases: TestCase[]): { total: number; executed: number; passed: number; failed: number; blocked: number; passRate: number; completionRate: number } {
const total = cases.length; const total = cases.length;
if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 }; if (total === 0) return { total: 0, executed: 0, passed: 0, failed: 0, blocked: 0, passRate: 0, completionRate: 0 };
@@ -68,16 +116,35 @@ export function calcTestProgress(cases: TestCase[]): { total: number; executed:
const blocked = cases.filter((c) => c.status === 'blocked').length; const blocked = cases.filter((c) => c.status === 'blocked').length;
const executed = passed + failed + blocked; const executed = passed + failed + blocked;
const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0; const passRate = (passed + failed) > 0 ? Math.round((passed / (passed + failed)) * 100) : 0;
const completionRate = Math.round((executed / total) * 100); const completionRate = aggregateWorkEffort(cases.map((c) => ({
estimateHours: getTestCaseEstimateHours(c),
actualHours: getTestCaseActualHours(c),
progress: TEST_CASE_STATUS_PROGRESS[c.status],
}))).progress;
return { total, executed, passed, failed, blocked, passRate, completionRate }; return { total, executed, passed, failed, blocked, passRate, completionRate };
} }
export function getTestCaseEstimateHours(tc: TestCase): number {
return typeof tc.estimateHours === 'number' && tc.estimateHours > 0
? Math.round(tc.estimateHours * 2) / 2
: 0.5;
}
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number { export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
if (!tc.startedAt) return 0; if (!tc.startedAt) return 0;
const end = tc.completedAt ?? now.toISOString(); const end = tc.completedAt ?? now.toISOString();
return calcWorkHours(tc.startedAt, end); return calcWorkHours(tc.startedAt, end);
} }
export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } {
const summary = aggregateWorkEffort(cases.map((c) => ({
estimateHours: getTestCaseEstimateHours(c),
actualHours: getTestCaseActualHours(c, now),
progress: TEST_CASE_STATUS_PROGRESS[c.status],
})));
return { estimate: summary.estimateHours, actual: summary.actualHours };
}
export function aggregateTestCaseActualHours(cases: TestCase[], now: Date = new Date()): number { export function aggregateTestCaseActualHours(cases: TestCase[], now: Date = new Date()): number {
let sum = 0; let sum = 0;
for (const c of cases) sum += getTestCaseActualHours(c, now); for (const c of cases) sum += getTestCaseActualHours(c, now);

View File

@@ -1,25 +1,24 @@
'use client'; 'use client';
import { create } from 'zustand'; import { create } from 'zustand';
import type { TestCase, TestCaseStatus } from '@/lib/test-case'; import type { TestCase, TestCaseStatus } from '@/lib/test-case';
import { canTcTransition, generateCaseNo } from '@/lib/test-case'; import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
import { loadServerData, saveServerData } from '@/lib/server-data';
const STORAGE_KEY = 'ftb_test_cases_v1'; function saveStored(items: TestCase[]) {
saveServerData('test-cases', items).catch(() => {});
function saveLocal(items: TestCase[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
} }
function loadLocal(): TestCase[] | null { async function loadStored(): Promise<TestCase[] | null> {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); return await loadServerData<TestCase[]>('test-cases');
if (raw) return JSON.parse(raw);
} catch {} } catch {}
return null; return null;
} }
interface TestCaseState { interface TestCaseState {
testCases: TestCase[]; testCases: TestCase[];
fetchTestCases: () => void; fetchTestCases: () => Promise<void>;
createTestCase: (data: Omit<TestCase, 'id' | 'caseNo' | 'createdAt' | 'updatedAt' | 'status'>) => TestCase; createTestCase: (data: Omit<TestCase, 'id' | 'caseNo' | 'createdAt' | 'updatedAt' | 'status'>) => TestCase;
updateTestCase: (id: string, data: Partial<TestCase>) => void; updateTestCase: (id: string, data: Partial<TestCase>) => void;
deleteTestCase: (id: string) => void; deleteTestCase: (id: string) => void;
@@ -32,60 +31,53 @@ interface TestCaseState {
export const useTestCaseStore = create<TestCaseState>((set, get) => ({ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
testCases: [], testCases: [],
fetchTestCases: () => { fetchTestCases: async () => {
const cached = loadLocal(); const cached = await loadStored();
if (cached) set({ testCases: cached }); if (cached) set({ testCases: normalizeTestCases(cached) });
}, },
createTestCase: (data) => { createTestCase: (data) => {
const list = get().testCases; const list = get().testCases;
const now = new Date().toISOString(); const now = new Date().toISOString();
const tc: TestCase = { const tc: TestCase = normalizeTestCaseOnCreate({
...data, ...data,
id: `tc-${Date.now()}`, id: `tc-${Date.now()}`,
caseNo: generateCaseNo(list), caseNo: generateCaseNo(list),
status: 'pending', status: 'pending',
estimateHours: data.estimateHours ?? 0.5,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; } as TestCase);
const updated = [...list, tc]; const updated = [...list, tc];
set({ testCases: updated }); set({ testCases: updated });
saveLocal(updated); saveStored(updated);
return tc; return tc;
}, },
updateTestCase: (id, data) => { updateTestCase: (id, data) => {
const updated = get().testCases.map((c) => const updated = get().testCases.map((c) =>
c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c, c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c,
); );
set({ testCases: updated }); set({ testCases: updated });
saveLocal(updated); saveStored(updated);
}, },
deleteTestCase: (id) => { deleteTestCase: (id) => {
const updated = get().testCases.filter((c) => c.id !== id); const updated = get().testCases.filter((c) => c.id !== id);
set({ testCases: updated }); set({ testCases: updated });
saveLocal(updated); saveStored(updated);
}, },
changeStatus: (id, to, extra) => { changeStatus: (id, to, extra) => {
const tc = get().testCases.find((c) => c.id === id); const tc = get().testCases.find((c) => c.id === id);
if (!tc) return { ok: false, message: '用例不存在' }; if (!tc) return { ok: false, message: '用例不存在' };
if (!canTcTransition(tc.status, to)) { const result = applyTestCaseTransition(tc, to, {
return { ok: false, message: `不允许从「${tc.status}」流转到「${to}` }; now: new Date(),
} failReason: extra?.failReason,
const now = new Date().toISOString(); blockReason: extra?.blockReason,
const patch: Partial<TestCase> = { status: to }; });
if (to === 'running' && !tc.startedAt) patch.startedAt = now; if (!result.ok || !result.patch) return { ok: false, message: result.message };
if (to === 'passed' || to === 'failed' || to === 'blocked') patch.completedAt = now; get().updateTestCase(id, result.patch);
if (to === 'running' || to === 'passed' || to === 'failed' || to === 'blocked') {
patch.executedAt = now;
}
// 回退到执行中时清除完成时间
if (to === 'running') { patch.failReason = undefined; patch.blockReason = undefined; patch.completedAt = undefined; }
if (to === 'failed' && extra?.failReason) patch.failReason = extra.failReason;
if (to === 'blocked' && extra?.blockReason) patch.blockReason = extra.blockReason;
get().updateTestCase(id, patch);
return { ok: true }; return { ok: true };
}, },