test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认

This commit is contained in:
Lemon-miaow committed 2026-09-25 10:47:19 +08:00
1 parent ea425cffa4
commit 2f99874d5e
44 files changed
+10578 -216

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { ServerFiles } from "./ServerFiles";
const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
status: () => Promise.resolve({ name: "lobby", subdomain: "lobby", phase: "Stopped", ready: false }),
listServerFiles: () =>
Promise.resolve({
path: "",
truncated: false,
entries: [{ name: "server.properties", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }],
}),
readServerFile: () => Promise.resolve({ path: "server.properties", content: btoa("motd=hi\n"), sha256: "abc" }),
writeServerFile: mocks.writeServerFile,
},
};
});
vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }) }));
const t = (key: string) => i18next.t(key);
async function openEditor() {
render(
<MemoryRouter initialEntries={["/servers/lobby/files"]}>
<Routes>
<Route path="/servers/:name/files" element={<ServerFiles />} />
</Routes>
</MemoryRouter>,
);
await userEvent.click(await screen.findByText("server.properties"));
const dialog = await screen.findByRole("dialog");
return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
}
beforeEach(() => {
mocks.writeServerFile.mockReset();
});
describe("ServerFiles editor", () => {
it("closes an unchanged file at once", async () => {
const { dialog } = await openEditor();
await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") }));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("asks before dropping edits, and keep editing keeps the text", async () => {
const { editor } = await openEditor();
await userEvent.type(editor, "pvp=false");
await userEvent.keyboard("{Escape}");
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(t("files:discard_prompt"))).toBeTruthy();
await userEvent.click(within(dialog).getByRole("button", { name: t("files:keep_editing") }));
expect(within(dialog).queryByText(t("files:discard_prompt"))).toBeNull();
expect((within(dialog).getByRole("textbox") as HTMLTextAreaElement).value).toBe("motd=hi\npvp=false");
});
it("drops the edits only after discard is confirmed", async () => {
const { dialog, editor } = await openEditor();
await userEvent.type(editor, "pvp=false");
await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") }));
await userEvent.click(within(dialog).getByRole("button", { name: t("files:discard") }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(mocks.writeServerFile).not.toHaveBeenCalled();
});
it("guards leaving the page while edits are unsaved", async () => {
const { editor } = await openEditor();
const clean = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(clean);
expect(clean.defaultPrevented).toBe(false);
await userEvent.type(editor, "x");
const dirty = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(dirty);
expect(dirty.defaultPrevented).toBe(true);
});
});