From c617625a9961fb381b3100b1ab050c0c6b8d3235 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Thu, 25 Jun 2026 14:13:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B):=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=B5=81=E8=BD=AC=E6=8E=A5=E5=85=A5=E5=8D=95?= =?UTF-8?q?=E6=9D=A1=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/test-case-workflow.test.ts | 79 +++++++++++++++ apps/web/lib/test-case-workflow.ts | 59 ++++++++++++ apps/web/lib/test-case.test.ts | 123 ++++++++++++++++++++++++ apps/web/lib/test-case.ts | 75 ++++++++++++++- apps/web/stores/useTestCaseStore.ts | 58 +++++------ 5 files changed, 357 insertions(+), 37 deletions(-) create mode 100644 apps/web/lib/test-case-workflow.test.ts create mode 100644 apps/web/lib/test-case-workflow.ts create mode 100644 apps/web/lib/test-case.test.ts diff --git a/apps/web/lib/test-case-workflow.test.ts b/apps/web/lib/test-case-workflow.test.ts new file mode 100644 index 0000000..5661594 --- /dev/null +++ b/apps/web/lib/test-case-workflow.test.ts @@ -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 { + 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); +}); diff --git a/apps/web/lib/test-case-workflow.ts b/apps/web/lib/test-case-workflow.ts new file mode 100644 index 0000000..20e3131 --- /dev/null +++ b/apps/web/lib/test-case-workflow.ts @@ -0,0 +1,59 @@ +import type { TestCase, TestCaseStatus } from './test-case'; +import { canTcTransition } from './test-case'; + +export interface TestCaseWorkflowResult { + ok: boolean; + patch?: Partial; + 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 = { + 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 }; +} diff --git a/apps/web/lib/test-case.test.ts b/apps/web/lib/test-case.test.ts new file mode 100644 index 0000000..ea46c74 --- /dev/null +++ b/apps/web/lib/test-case.test.ts @@ -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); +}); diff --git a/apps/web/lib/test-case.ts b/apps/web/lib/test-case.ts index 0484bb8..aaf5f24 100644 --- a/apps/web/lib/test-case.ts +++ b/apps/web/lib/test-case.ts @@ -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 = { - pending: '待执行', - running: '执行中', + pending: '待测试', + running: '测试中', passed: '通过', - failed: '失败', + failed: '不通过', blocked: '阻塞', }; @@ -48,6 +56,14 @@ export const TC_ALLOWED_TRANSITIONS: Record = blocked: ['running'], }; +export const TEST_CASE_STATUS_PROGRESS: Record = { + 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, 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[] { + 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); diff --git a/apps/web/stores/useTestCaseStore.ts b/apps/web/stores/useTestCaseStore.ts index 0948528..80fba2c 100644 --- a/apps/web/stores/useTestCaseStore.ts +++ b/apps/web/stores/useTestCaseStore.ts @@ -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 { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + return await loadServerData('test-cases'); } catch {} return null; } interface TestCaseState { testCases: TestCase[]; - fetchTestCases: () => void; + fetchTestCases: () => Promise; createTestCase: (data: Omit) => TestCase; updateTestCase: (id: string, data: Partial) => void; deleteTestCase: (id: string) => void; @@ -32,60 +31,53 @@ interface TestCaseState { export const useTestCaseStore = create((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 = { 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 }; },