feat(测试用例): 状态流转接入单条工作流
This commit is contained in:
79
apps/web/lib/test-case-workflow.test.ts
Normal file
79
apps/web/lib/test-case-workflow.test.ts
Normal 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);
|
||||
});
|
||||
59
apps/web/lib/test-case-workflow.ts
Normal file
59
apps/web/lib/test-case-workflow.ts
Normal 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 };
|
||||
}
|
||||
123
apps/web/lib/test-case.test.ts
Normal file
123
apps/web/lib/test-case.test.ts
Normal 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);
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { aggregateWorkEffort } from './work-effort-engine';
|
||||
|
||||
export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
|
||||
|
||||
@@ -10,25 +13,30 @@ export interface TestCase {
|
||||
requirementId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
categoryId: string;
|
||||
priority: Priority;
|
||||
assigneeId?: string;
|
||||
status: TestCaseStatus;
|
||||
estimateHours?: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
executedAt?: string;
|
||||
executedBy?: string;
|
||||
failReason?: string;
|
||||
blockReason?: string;
|
||||
references?: Reference[];
|
||||
aiDraft?: boolean;
|
||||
aiDraftAt?: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const TEST_CASE_STATUS_LABEL: Record<TestCaseStatus, string> = {
|
||||
pending: '待执行',
|
||||
running: '执行中',
|
||||
pending: '待测试',
|
||||
running: '测试中',
|
||||
passed: '通过',
|
||||
failed: '失败',
|
||||
failed: '不通过',
|
||||
blocked: '阻塞',
|
||||
};
|
||||
|
||||
@@ -48,6 +56,14 @@ export const TC_ALLOWED_TRANSITIONS: Record<TestCaseStatus, TestCaseStatus[]> =
|
||||
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 {
|
||||
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')}`;
|
||||
}
|
||||
|
||||
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 } {
|
||||
const total = cases.length;
|
||||
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 executed = passed + failed + blocked;
|
||||
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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!tc.startedAt) return 0;
|
||||
const end = tc.completedAt ?? now.toISOString();
|
||||
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 {
|
||||
let sum = 0;
|
||||
for (const c of cases) sum += getTestCaseActualHours(c, now);
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
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 saveLocal(items: TestCase[]) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); } catch {}
|
||||
function saveStored(items: TestCase[]) {
|
||||
saveServerData('test-cases', items).catch(() => {});
|
||||
}
|
||||
|
||||
function loadLocal(): TestCase[] | null {
|
||||
async function loadStored(): Promise<TestCase[] | null> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
return await loadServerData<TestCase[]>('test-cases');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface TestCaseState {
|
||||
testCases: TestCase[];
|
||||
fetchTestCases: () => void;
|
||||
fetchTestCases: () => Promise<void>;
|
||||
createTestCase: (data: Omit<TestCase, 'id' | 'caseNo' | 'createdAt' | 'updatedAt' | 'status'>) => TestCase;
|
||||
updateTestCase: (id: string, data: Partial<TestCase>) => void;
|
||||
deleteTestCase: (id: string) => void;
|
||||
@@ -32,60 +31,53 @@ interface TestCaseState {
|
||||
export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||
testCases: [],
|
||||
|
||||
fetchTestCases: () => {
|
||||
const cached = loadLocal();
|
||||
if (cached) set({ testCases: cached });
|
||||
fetchTestCases: async () => {
|
||||
const cached = await loadStored();
|
||||
if (cached) set({ testCases: normalizeTestCases(cached) });
|
||||
},
|
||||
|
||||
createTestCase: (data) => {
|
||||
const list = get().testCases;
|
||||
const now = new Date().toISOString();
|
||||
const tc: TestCase = {
|
||||
const tc: TestCase = normalizeTestCaseOnCreate({
|
||||
...data,
|
||||
id: `tc-${Date.now()}`,
|
||||
caseNo: generateCaseNo(list),
|
||||
status: 'pending',
|
||||
estimateHours: data.estimateHours ?? 0.5,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
} as TestCase);
|
||||
const updated = [...list, tc];
|
||||
set({ testCases: updated });
|
||||
saveLocal(updated);
|
||||
saveStored(updated);
|
||||
return tc;
|
||||
},
|
||||
|
||||
updateTestCase: (id, data) => {
|
||||
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 });
|
||||
saveLocal(updated);
|
||||
saveStored(updated);
|
||||
},
|
||||
|
||||
deleteTestCase: (id) => {
|
||||
const updated = get().testCases.filter((c) => c.id !== id);
|
||||
set({ testCases: updated });
|
||||
saveLocal(updated);
|
||||
saveStored(updated);
|
||||
},
|
||||
|
||||
changeStatus: (id, to, extra) => {
|
||||
const tc = get().testCases.find((c) => c.id === id);
|
||||
if (!tc) return { ok: false, message: '用例不存在' };
|
||||
if (!canTcTransition(tc.status, to)) {
|
||||
return { ok: false, message: `不允许从「${tc.status}」流转到「${to}」` };
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const patch: Partial<TestCase> = { status: to };
|
||||
if (to === 'running' && !tc.startedAt) patch.startedAt = now;
|
||||
if (to === 'passed' || to === 'failed' || to === 'blocked') patch.completedAt = now;
|
||||
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);
|
||||
const result = applyTestCaseTransition(tc, to, {
|
||||
now: new Date(),
|
||||
failReason: extra?.failReason,
|
||||
blockReason: extra?.blockReason,
|
||||
});
|
||||
if (!result.ok || !result.patch) return { ok: false, message: result.message };
|
||||
get().updateTestCase(id, result.patch);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user