Stop configuration snapshot request loop
This commit is contained in:
@@ -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 <ServerConfigEditor instance={instance} operations={operations} requester="Operator" onClose={() => undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => root?.render(<Wrapper />));
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Code2, FileCog, Save, X } from "lucide-react";
|
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 { platformApiClient } from "../api/client";
|
||||||
import type { DeclaredFileReadSnapshotResponse, PluginConfigFieldResponse, PluginLogicalFileResponse, ServerFileWorkspaceResponse, ServerInstanceResponse } from "../api/types";
|
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 [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [error, setError] = 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 configFiles = useMemo(() => (workspace?.files ?? []).filter((file) => file.kind === "config" && file.editable !== false), [workspace]);
|
||||||
const selectedFile = configFiles.find((file) => file.key === selectedKey) ?? configFiles[0];
|
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 fields = useMemo(() => (workspace?.configFields ?? []).filter((field) => field.fileKey === selectedFile?.key), [selectedFile?.key, workspace?.configFields]);
|
||||||
|
|
||||||
const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse) => {
|
const readSelectedFile = useCallback(async (file: PluginLogicalFileResponse) => {
|
||||||
|
const readKey = `${instance.id}:${file.key}`;
|
||||||
|
if (readInFlightRef.current === readKey) return;
|
||||||
|
readInFlightRef.current = readKey;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
setMessage("正在通过 Run 读取配置文件…");
|
setMessage("正在通过 Run 读取配置文件…");
|
||||||
try {
|
try {
|
||||||
let next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
|
let next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
|
||||||
if (next.state !== "ready") {
|
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) });
|
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) {
|
for (let attempt = 0; attempt < 60 && next.state !== "ready"; attempt += 1) {
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
||||||
next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
|
next = await platformApiClient.getServerFileReadSnapshot(instance.id, file.key);
|
||||||
@@ -59,8 +65,9 @@ export function ServerConfigEditor({ instance, operations, requester, onClose }:
|
|||||||
setMessage("");
|
setMessage("");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
if (readInFlightRef.current === readKey) readInFlightRef.current = "";
|
||||||
}
|
}
|
||||||
}, [fields, instance.id, operations, requester]);
|
}, [fields, instance.id, requester]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
|
|||||||
Reference in New Issue
Block a user