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 { 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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user