diff --git a/platform_web/components/ServerConfigEditor.test.tsx b/platform_web/components/ServerConfigEditor.test.tsx new file mode 100644 index 0000000..80d3a86 --- /dev/null +++ b/platform_web/components/ServerConfigEditor.test.tsx @@ -0,0 +1,83 @@ +/** @vitest-environment jsdom */ + +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ServerFileWorkspaceResponse, ServerInstanceResponse } from "../api/types"; +import type { OperationTracker } from "../stores/operations"; +import { ServerConfigEditor } from "./ServerConfigEditor"; + +const apiMocks = vi.hoisted(() => ({ + getServerFileReadSnapshot: vi.fn(), + getServerFileWorkspace: vi.fn(), + readServerFile: vi.fn() +})); + +vi.mock("../api/client", () => ({ platformApiClient: apiMocks })); + +const instance = { id: "server-1", name: "SCUM Alpha" } as ServerInstanceResponse; +const workspace = { + serverInstanceId: "server-1", + pluginId: "game.scum", + defaultDirectoryKey: "scum-config", + directories: [{ key: "scum-config", label: "服务器配置", scope: "config" }], + files: [{ key: "scum-server-settings", directoryKey: "scum-config", label: "ServerSettings.ini", kind: "config", editable: true }], + configFields: [], + transfer: { channel: "run-file-transfer", uploadChunkSizeBytes: 1, downloadChunkSizeBytes: 1, maxInlineEditBytes: 64, maxBrowserUploadBytes: 64, notes: [] }, + declaredOnly: true +} as ServerFileWorkspaceResponse; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => { + vi.useFakeTimers(); + apiMocks.getServerFileWorkspace.mockResolvedValue(workspace); + let snapshotCalls = 0; + apiMocks.getServerFileReadSnapshot.mockImplementation(async () => { + snapshotCalls += 1; + return snapshotCalls === 1 ? { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "not-read" } : { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "[General]\nscum.MaxPlayers=63\n", version: 1, checksum: "sha256:test" }; + }); + apiMocks.readServerFile.mockResolvedValue({ status: "queued", serverInstanceId: "server-1", pluginId: "game.scum", operation: "read", key: "scum-server-settings", job: { id: "job-read", state: "queued" } }); +}); + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe("ServerConfigEditor", () => { + it("does not restart the same snapshot request when operation state rerenders the parent", async () => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + + function Wrapper() { + const [, rerender] = useState(0); + const operations: OperationTracker = { + operations: [], + begin: () => { rerender((value) => value + 1); return "operation-read"; }, + update: () => undefined, + succeed: () => undefined, + fail: () => undefined, + isPending: () => false + }; + return undefined} />; + } + + await act(async () => root?.render()); + await act(async () => { await Promise.resolve(); }); + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + await act(async () => { await Promise.resolve(); }); + + expect(apiMocks.readServerFile).toHaveBeenCalledTimes(1); + expect(apiMocks.getServerFileReadSnapshot).toHaveBeenCalledTimes(2); + }); +}); diff --git a/platform_web/components/ServerConfigEditor.tsx b/platform_web/components/ServerConfigEditor.tsx index aa266f3..94e59e7 100644 --- a/platform_web/components/ServerConfigEditor.tsx +++ b/platform_web/components/ServerConfigEditor.tsx @@ -1,5 +1,5 @@ import { Code2, FileCog, Save, X } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { platformApiClient } from "../api/client"; import type { DeclaredFileReadSnapshotResponse, PluginConfigFieldResponse, PluginLogicalFileResponse, ServerFileWorkspaceResponse, ServerInstanceResponse } from "../api/types"; @@ -27,21 +27,27 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }: const [saving, setSaving] = useState(false); const [message, setMessage] = useState(""); const [error, setError] = useState(""); + const readInFlightRef = useRef(""); + const operationsRef = useRef(operations); + operationsRef.current = operations; const configFiles = useMemo(() => (workspace?.files ?? []).filter((file) => file.kind === "config" && file.editable !== false), [workspace]); const selectedFile = configFiles.find((file) => file.key === selectedKey) ?? configFiles[0]; const fields = useMemo(() => (workspace?.configFields ?? []).filter((field) => field.fileKey === selectedFile?.key), [selectedFile?.key, workspace?.configFields]); const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse) => { + const readKey = `${instance.id}:${file.key}`; + if (readInFlightRef.current === readKey) return; + readInFlightRef.current = readKey; setLoading(true); setError(""); setMessage("正在通过 Run 读取配置文件…"); try { let next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key); if (next.state !== "ready") { - const operationId = operations.begin({ intent: "读取服务器配置", targetKind: "config", targetId: instance.id, requester }); + const operationId = operationsRef.current.begin({ intent: "读取服务器配置", targetKind: "config", targetId: instance.id, requester }); const dispatch = await platformApiClient.readServerFile(instance.id, { key: file.key, idempotencyKey: configOperationKey("read", instance.id, file.key) }); - operations.succeed(operationId, `配置读取任务 ${dispatch.job.id} 已派发`, dispatch.job); + operationsRef.current.succeed(operationId, `配置读取任务 ${dispatch.job.id} 已派发`, dispatch.job); for (let attempt = 0; attempt < 60 && next.state !== "ready"; attempt += 1) { await new Promise((resolve) => window.setTimeout(resolve, 500)); next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key); @@ -59,8 +65,9 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }: setMessage(""); } finally { setLoading(false); + if (readInFlightRef.current === readKey) readInFlightRef.current = ""; } - }, [fields, instance.id, operations, requester]); + }, [fields, instance.id, requester]); useEffect(() => { if (!workspace) {