From ce93fd8d675e8c7b940b84924df0376f16fbae6f Mon Sep 17 00:00:00 2001 From: bun913 Date: Fri, 27 Feb 2026 18:45:26 +0900 Subject: [PATCH] feat: add sync a case --- src/commands/snapshot/sync.ts | 37 +++++++++++++++-- src/play/tui/App.tsx | 46 +++++++++++++++------ src/play/tui/StatusBar.tsx | 2 +- src/play/tui/hooks/useApiActions.ts | 18 +++++++-- src/snapshot/fetch.ts | 62 +++++++++++++++++++++++++++++ src/snapshot/merge.ts | 39 +++++++++++++++++- 6 files changed, 184 insertions(+), 20 deletions(-) diff --git a/src/commands/snapshot/sync.ts b/src/commands/snapshot/sync.ts index aa8ce59..c84c0a1 100644 --- a/src/commands/snapshot/sync.ts +++ b/src/commands/snapshot/sync.ts @@ -1,22 +1,24 @@ import type { Command } from "commander"; import { getProfile, loadConfig } from "../../config/manager"; import type { GlobalOptions } from "../../config/types"; -import { fetchCycleData } from "../../snapshot/fetch"; +import { fetchCycleData, fetchSingleTestCaseData } from "../../snapshot/fetch"; import { readSnapshot, writeSnapshot } from "../../snapshot/file"; -import { buildSnapshot, mergeSnapshot } from "../../snapshot/merge"; +import { buildSnapshot, mergeSnapshot, reloadTestCase } from "../../snapshot/merge"; import { createClient } from "../../utils/client"; import { formatError } from "../../utils/error"; import { logger, setLoggerVerbose } from "../../utils/logger"; import { createSpinner } from "../../utils/spinner"; const TEST_CYCLE_KEY_PATTERN = /^[A-Z]+-R\d+$/; +const TEST_CASE_KEY_PATTERN = /^[A-Z]+-T\d+$/; export function registerSyncCommand(parent: Command): void { parent .command("sync ") .description("Sync test cycle data to a snapshot JSON file") .option("-o, --output ", "Output file path (required for initial sync)") - .action(async (target: string, options: { output?: string }, command) => { + .option("-t, --test-case ", "Reload a single test case (e.g. CPD-T123)") + .action(async (target: string, options: { output?: string; testCase?: string }, command) => { const spinner = createSpinner("Starting sync..."); try { const globalOptions = command.parent?.parent?.opts() as GlobalOptions; @@ -45,6 +47,35 @@ export function registerSyncCommand(parent: Command): void { spinner.stop( `Snapshot saved to ${options.output} (${snapshot.testCases.length} test cases)`, ); + } else if (options.testCase) { + // Single test case reload + const testCaseKey = options.testCase; + if (!TEST_CASE_KEY_PATTERN.test(testCaseKey)) { + spinner.stop(); + logger.error(`Invalid test case key: ${testCaseKey}`); + process.exit(1); + } + + const filePath = target; + const local = readSnapshot(filePath); + const tc = local.testCases.find((t) => t.key === testCaseKey); + if (!tc) { + spinner.stop(); + logger.error(`Test case ${testCaseKey} not found in snapshot`); + process.exit(1); + } + + spinner.update(`Reloading ${testCaseKey}...`); + const data = await fetchSingleTestCaseData( + client, + profile.projectKey, + testCaseKey, + tc.execution.id, + ); + const updated = reloadTestCase(local, testCaseKey, data); + writeSnapshot(filePath, updated); + + spinner.stop(`Reloaded: ${testCaseKey}`); } else { const filePath = target; const local = readSnapshot(filePath); diff --git a/src/play/tui/App.tsx b/src/play/tui/App.tsx index 65d577c..d9e2908 100644 --- a/src/play/tui/App.tsx +++ b/src/play/tui/App.tsx @@ -68,15 +68,16 @@ function App({ return indices; }, [searchQuery, state.flatListItems]); - const { pushStep, pushAllSteps, pushExecution, syncSnapshot } = useApiActions({ - client, - projectKey, - filePath, - getSnapshot: () => snapshotRef.current, - updateSnapshot: actions.updateSnapshot, - setLoading: actions.setLoading, - setError: actions.setError, - }); + const { pushStep, pushAllSteps, pushExecution, syncSnapshot, reloadSingleTestCase } = + useApiActions({ + client, + projectKey, + filePath, + getSnapshot: () => snapshotRef.current, + updateSnapshot: actions.updateSnapshot, + setLoading: actions.setLoading, + setError: actions.setError, + }); const [pendingInput, setPendingInput] = useState(null); const [syncPhase, setSyncPhase] = useState(null); @@ -336,10 +337,31 @@ function App({ return; } - // Sync: Shift+S - if (input === "S") { + // Reload single test case: r + if (input === "r" && selectedTestCase) { + const tc = selectedTestCase.testCase; + setSyncMessage(`Reloading ${tc.key}...`); + reloadSingleTestCase(tc.key, tc.execution.id) + .then((updated) => { + const newIndices = applyFilter(updated.testCases, filter); + setCurrentFilteredIndices(newIndices); + setSyncMessage(`Reloaded: ${tc.key}`); + if (syncMessageTimerRef.current) clearTimeout(syncMessageTimerRef.current); + syncMessageTimerRef.current = setTimeout(() => setSyncMessage(undefined), 3000); + }) + .catch((error) => { + setSyncMessage(undefined); + actions.setError( + `Reload failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + }); + return; + } + + // Reload all (full sync): R + if (input === "R") { setSyncPhase("confirm"); - setSyncMessage("Sync now? (y/n)"); + setSyncMessage("Reload all? (y/n)"); return; } diff --git a/src/play/tui/StatusBar.tsx b/src/play/tui/StatusBar.tsx index ef6d412..67c7466 100644 --- a/src/play/tui/StatusBar.tsx +++ b/src/play/tui/StatusBar.tsx @@ -70,7 +70,7 @@ export function StatusBar({ ) : ( {activePanel === "right" ? "p/f/b:step P/F/B:case" : "p/f/b:all"} o:open player e:edit - case S:sync + case r:reload R:reload all )} diff --git a/src/play/tui/hooks/useApiActions.ts b/src/play/tui/hooks/useApiActions.ts index f002fdc..f886eea 100644 --- a/src/play/tui/hooks/useApiActions.ts +++ b/src/play/tui/hooks/useApiActions.ts @@ -1,8 +1,8 @@ import { useCallback, useRef } from "react"; import type { ZephyrV2Client } from "zephyr-api-client"; -import { fetchCycleData } from "../../../snapshot/fetch"; +import { fetchCycleData, fetchSingleTestCaseData } from "../../../snapshot/fetch"; import { readSnapshot, writeSnapshot } from "../../../snapshot/file"; -import { type MergeResult, mergeSnapshot } from "../../../snapshot/merge"; +import { type MergeResult, mergeSnapshot, reloadTestCase } from "../../../snapshot/merge"; import { pushExecutionStatus, pushStepResults } from "../../../snapshot/push"; import type { Snapshot, SnapshotStep } from "../../../snapshot/types"; @@ -243,5 +243,17 @@ export function useApiActions(options: UseApiActionsOptions) { [client, projectKey, filePath, updateSnapshot], ); - return { pushStep, pushAllSteps, pushExecution, syncSnapshot }; + const reloadSingleTestCase = useCallback( + async (testCaseKey: string, executionId: number | null): Promise => { + const snapshot = readSnapshot(filePath); + const data = await fetchSingleTestCaseData(client, projectKey, testCaseKey, executionId); + const updated = reloadTestCase(snapshot, testCaseKey, data); + writeSnapshot(filePath, updated); + updateSnapshot(updated); + return updated; + }, + [client, projectKey, filePath, updateSnapshot], + ); + + return { pushStep, pushAllSteps, pushExecution, syncSnapshot, reloadSingleTestCase }; } diff --git a/src/snapshot/fetch.ts b/src/snapshot/fetch.ts index efeb413..046a81c 100644 --- a/src/snapshot/fetch.ts +++ b/src/snapshot/fetch.ts @@ -348,3 +348,65 @@ export async function fetchCycleData( statusMap, }; } + +export interface FetchedSingleTestCaseData { + testCase: FetchedTestCase; + testCaseSteps: FetchedTestStep[]; + executionSteps: FetchedExecutionStep[]; + folders: Map; + statusMap: Map; +} + +/** + * Fetch data for a single test case (for partial reload). + */ +export async function fetchSingleTestCaseData( + client: ZephyrV2Client, + projectKey: string, + testCaseKey: string, + executionId: number | null, +): Promise { + const statusMap = await fetchAllStatuses(client, projectKey); + + const tcResponse = await client.testcases.getTestCase(testCaseKey); + const tc = tcResponse.data; + const testCase: FetchedTestCase = { + key: tc.key, + name: tc.name, + objective: tc.objective ?? "", + precondition: tc.precondition ?? "", + estimatedTime: tc.estimatedTime ?? null, + labels: (tc.labels as string[]) ?? [], + component: (tc.component as { name?: string } | null)?.name ?? null, + customFields: (tc.customFields as Record) ?? {}, + folderId: tc.folder?.id ?? null, + createdOn: tc.createdOn ?? "", + }; + + const testCaseSteps = await fetchTestCaseSteps(client, testCaseKey); + + let executionSteps: FetchedExecutionStep[] = []; + if (executionId !== null) { + executionSteps = await fetchExecutionSteps(client, executionId); + } + + // Fetch folder chain + const folders = new Map(); + const foldersToFetch = testCase.folderId !== null ? [testCase.folderId] : []; + while (foldersToFetch.length > 0) { + const folderId = foldersToFetch.pop(); + if (folderId === undefined || folders.has(folderId)) continue; + const folderResponse = await client.folders.getFolder(folderId); + const folder = folderResponse.data; + folders.set(folderId, { + id: folder.id, + name: folder.name, + parentId: folder.parentId ?? null, + }); + if (folder.parentId && !folders.has(folder.parentId)) { + foldersToFetch.push(folder.parentId); + } + } + + return { testCase, testCaseSteps, executionSteps, folders, statusMap }; +} diff --git a/src/snapshot/merge.ts b/src/snapshot/merge.ts index 9b72e8c..4dc02ce 100644 --- a/src/snapshot/merge.ts +++ b/src/snapshot/merge.ts @@ -1,4 +1,9 @@ -import type { FetchedCycleData, FetchedExecutionStep, FetchedTestStep } from "./fetch"; +import type { + FetchedCycleData, + FetchedExecutionStep, + FetchedSingleTestCaseData, + FetchedTestStep, +} from "./fetch"; import { calcOriginalIndex } from "./fractional-index"; import type { Snapshot, SnapshotExecution, SnapshotStep, SnapshotTestCase } from "./types"; @@ -163,3 +168,35 @@ export function mergeSnapshot(local: Snapshot, data: FetchedCycleData): MergeRes return { snapshot, added, removed, updated }; } + +/** + * Reload a single test case in the snapshot from fetched data. + * Updates test case details, steps, and folder path. Preserves execution, originalIndex, and excluded. + */ +export function reloadTestCase( + snapshot: Snapshot, + testCaseKey: string, + data: FetchedSingleTestCaseData, +): Snapshot { + const newTestCases = snapshot.testCases.map((tc) => { + if (tc.key !== testCaseKey) return tc; + + const { testCase, testCaseSteps, executionSteps, folders, statusMap } = data; + return { + ...tc, + name: testCase.name, + objective: testCase.objective, + precondition: testCase.precondition, + estimatedTime: testCase.estimatedTime, + labels: testCase.labels, + component: testCase.component, + customFields: testCase.customFields, + folderId: testCase.folderId, + folderPath: buildFolderPath(testCase.folderId, folders), + createdAt: testCase.createdOn, + steps: buildSteps(testCaseSteps, executionSteps, statusMap), + }; + }); + + return { ...snapshot, exportedAt: new Date().toISOString(), testCases: newTestCases }; +}