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

+104
View File
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { ErrorBoundary } from "./ErrorBoundary";
const { reloadForNewDeploy } = vi.hoisted(() => ({ reloadForNewDeploy: vi.fn() }));
vi.mock("@/lib/chunk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/chunk")>();
return { ...actual, reloadForNewDeploy };
});
const t = (key: string) => i18next.t(key);
const state = { broken: true, error: new Error("boom") as unknown };
function Page() {
if (state.broken) throw state.error;
return <p>page content</p>;
}
// React and the boundary both log the caught render error, and React's dev
// build replays it as a window error event that jsdom would print too.
let quiet: ReturnType<typeof vi.spyOn>;
const swallow = (e: ErrorEvent) => e.preventDefault();
beforeEach(() => {
state.broken = true;
state.error = new Error("boom");
reloadForNewDeploy.mockReset();
quiet = vi.spyOn(console, "error").mockImplementation(() => {});
window.addEventListener("error", swallow);
});
afterEach(() => {
quiet.mockRestore();
window.removeEventListener("error", swallow);
});
describe("ErrorBoundary", () => {
it("shows the crash page with the error instead of a blank screen", () => {
render(
<ErrorBoundary>
<Page />
</ErrorBoundary>,
);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(t("common:crash_title"))).toBeTruthy();
expect(screen.getByText("Error: boom")).toBeTruthy();
expect(reloadForNewDeploy).not.toHaveBeenCalled();
});
it("try again renders the page once it stops throwing", async () => {
render(
<ErrorBoundary>
<Page />
</ErrorBoundary>,
);
state.broken = false;
await userEvent.click(screen.getByRole("button", { name: t("common:try_again") }));
expect(screen.getByText("page content")).toBeTruthy();
});
it("clears the crash when the route changes", () => {
const view = render(
<ErrorBoundary resetKey="/servers/a">
<Page />
</ErrorBoundary>,
);
expect(screen.getByRole("alert")).toBeTruthy();
state.broken = false;
view.rerender(
<ErrorBoundary resetKey="/servers/b">
<Page />
</ErrorBoundary>,
);
expect(screen.getByText("page content")).toBeTruthy();
});
it("uses the caller's fallback when given one", () => {
render(
<ErrorBoundary fallback={() => <p>flat fleet</p>}>
<Page />
</ErrorBoundary>,
);
expect(screen.getByText("flat fleet")).toBeTruthy();
});
it("reloads once for a chunk from an older deploy and says the panel updated", () => {
state.error = new TypeError("Failed to fetch dynamically imported module: /assets/Files-abc.js");
render(
<ErrorBoundary>
<Page />
</ErrorBoundary>,
);
expect(reloadForNewDeploy).toHaveBeenCalledOnce();
expect(screen.getByText(t("common:crash_updated_title"))).toBeTruthy();
});
});
+6 -5
View File
@@ -3,7 +3,7 @@ import { Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PHASE_KEY, phaseColor } from "@/components/PhaseBadge";
import { cn } from "@/lib/utils";
import type { Phase, ServerInfo } from "@/lib/types";
import type { Phase, MyServerView } from "@/lib/types";
// FleetGrid is the flat stand-in for VoxelFleet when WebGL is unavailable or the
// 3D chunk failed: the same square layout and phase palette seen from above, so
@@ -18,7 +18,7 @@ export function FleetGrid({
servers,
reason,
}: {
servers: Pick<ServerInfo, "name" | "displayName" | "phase">[];
servers: Pick<MyServerView, "name" | "displayName" | "phase">[];
reason: FleetGridReason;
}) {
const { t } = useTranslation(["dashboard", "servers"]);
@@ -36,8 +36,9 @@ export function FleetGrid({
style={{ gridTemplateColumns: `repeat(${cols}, ${tile})`, gridAutoRows: tile }}
>
{servers.map((s) => {
const color = phaseColor(s.phase);
const label = `${s.displayName || s.name} · ${t(PHASE_KEY[s.phase] ?? PHASE_KEY.Unknown)}`;
const phase = s.phase ?? "Unknown";
const color = phaseColor(phase);
const label = `${s.displayName || s.name} · ${t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}`;
return (
<Link
key={s.name}
@@ -46,7 +47,7 @@ export function FleetGrid({
aria-label={label}
className={cn(
"rounded-md ring-1 ring-inset ring-white/10 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
TRANSIENT.has(s.phase) && "animate-pulse",
TRANSIENT.has(phase) && "animate-pulse",
)}
style={{
backgroundColor: color,
+106
View File
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { PowerButton } from "./PowerButton";
const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, wake, stop } };
});
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
beforeEach(() => {
wake.mockReset();
stop.mockReset();
});
describe("PowerButton", () => {
it("wakes a stopped server and tells the parent", async () => {
wake.mockResolvedValue(undefined);
const onChanged = vi.fn();
render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
expect(wake).toHaveBeenCalledWith("lobby");
expect(onChanged).toHaveBeenCalledOnce();
});
it("stays on the page with the reason when the wake is refused", async () => {
wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" });
const onChanged = vi.fn();
render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
expect(await screen.findByRole("alert")).toHaveProperty("textContent", t("errors:quota_exceeded"));
expect(onChanged).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false);
});
it("stops an empty server without asking", async () => {
stop.mockResolvedValue(undefined);
const onChanged = vi.fn();
render(<PowerButton name="lobby" live playersOnline={0} onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(stop).toHaveBeenCalledWith("lobby");
expect(onChanged).toHaveBeenCalledOnce();
});
it("asks before disconnecting players, and cancel sends nothing", async () => {
render(<PowerButton name="lobby" live playersOnline={3} onChanged={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: t("common:cancel") }));
expect(stop).not.toHaveBeenCalled();
expect(screen.queryByText(t("servers:stop_confirm_players", { count: 3 }))).toBeNull();
});
it("asks when the player count cannot be read", async () => {
stop.mockResolvedValue(undefined);
const onChanged = vi.fn();
render(<PowerButton name="lobby" live playersOnline={0} playerCountUnknown onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(stop).not.toHaveBeenCalled();
expect(screen.getByText(t("servers:stop_confirm_unknown"))).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(stop).toHaveBeenCalledWith("lobby");
expect(onChanged).toHaveBeenCalledOnce();
});
it("keeps the confirmation open with the reason when the stop fails", async () => {
stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" });
const onChanged = vi.fn();
render(<PowerButton name="lobby" live playersOnline={2} onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
expect(await screen.findByText(t("errors:cooldown"))).toBeTruthy();
expect(screen.getByText(t("servers:stop_confirm_players", { count: 2 }))).toBeTruthy();
expect(onChanged).not.toHaveBeenCalled();
});
it("sends one call however fast it is clicked", async () => {
let resolve!: () => void;
wake.mockReturnValue(new Promise<void>((r) => (resolve = r)));
render(<PowerButton name="lobby" live={false} onChanged={vi.fn()} />);
const button = screen.getByRole("button", { name: t("servers:wake") });
await userEvent.click(button);
await userEvent.click(screen.getByRole("button", { name: t("servers:waking") }));
resolve();
expect(wake).toHaveBeenCalledOnce();
});
});
+68
View File
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import i18next from "i18next";
import { RequireAuth } from "./RequireAuth";
const tier = vi.hoisted(() => ({ loading: false, unauthenticated: false, sessionEnded: false }));
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
function LoginProbe() {
const location = useLocation();
return (
<p>
login{location.search} ended={String((location.state as { sessionEnded?: boolean } | null)?.sessionEnded ?? false)}
</p>
);
}
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/login" element={<LoginProbe />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<p>dashboard</p>} />
<Route path="/servers/:name" element={<p>console</p>} />
</Route>
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
Object.assign(tier, { loading: false, unauthenticated: false, sessionEnded: false });
});
describe("RequireAuth", () => {
it("shows a spinner while /me is in flight, never the login page", () => {
tier.loading = true;
tier.unauthenticated = true;
renderAt("/servers/lobby");
expect(screen.getByText(i18next.t("common:loading"))).toBeTruthy();
expect(screen.queryByText(/^login/)).toBeNull();
});
it("sends a 401 to /login with the page to come back to", () => {
tier.unauthenticated = true;
renderAt("/servers/lobby?tab=files");
expect(screen.getByText(`login?next=${encodeURIComponent("/servers/lobby?tab=files")} ended=false`)).toBeTruthy();
});
it("drops ?next= for the dashboard and says when the session ended", () => {
tier.unauthenticated = true;
tier.sessionEnded = true;
renderAt("/");
expect(screen.getByText("login ended=true")).toBeTruthy();
});
it("renders the app for a session, including a degraded /me", () => {
renderAt("/servers/lobby");
expect(screen.getByText("console")).toBeTruthy();
});
});
+3 -3
View File
@@ -8,10 +8,10 @@ import { PhaseBadge } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton";
import { api, humanizeError } from "@/lib/api";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import type { ServerInfo } from "@/lib/types";
import type { MyServerView } from "@/lib/types";
interface Props {
server: ServerInfo;
server: MyServerView;
cfg: RuntimeConfig;
/** Called after a successful mutation so the parent can refetch. */
onChanged: () => void;
@@ -49,7 +49,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
<span className="truncate text-sm font-semibold text-foreground">
{server.displayName || server.name}
</span>
<PhaseBadge phase={server.phase} />
<PhaseBadge phase={server.phase ?? "Unknown"} />
</div>
{/* Info Row */}
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { SETUP_REQUIRED_EVENT } from "@/lib/api";
import { SetupRequiredRedirect } from "./SetupRequiredRedirect";
function Tree({ listening }: { listening: boolean }) {
return (
<MemoryRouter initialEntries={["/servers"]}>
{listening && <SetupRequiredRedirect />}
<Routes>
<Route path="/setup" element={<p>setup wizard</p>} />
<Route path="/servers" element={<p>servers</p>} />
</Routes>
</MemoryRouter>
);
}
const announce = () =>
act(() => {
window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT));
});
describe("SetupRequiredRedirect", () => {
it("routes to the wizard when a call answers 403 setup_required", () => {
render(<Tree listening />);
expect(screen.getByText("servers")).toBeTruthy();
announce();
expect(screen.getByText("setup wizard")).toBeTruthy();
});
it("stops redirecting once it is unmounted", () => {
const view = render(<Tree listening />);
view.rerender(<Tree listening={false} />);
announce();
expect(screen.getByText("servers")).toBeTruthy();
});
});
@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { SETUP_REQUIRED_EVENT } from "@/lib/api";
// SetupRequiredRedirect listens for the `403 setup_required` signal api.ts emits
// when a session still owes forced onboarding (#8) and routes it to the wizard.
// It must live inside the Router (it navigates) and outside RequireAuth (/setup
// sits there too); the event fires from any protected call the app makes, so the
// listener is always mounted by the time one arrives.
export function SetupRequiredRedirect() {
const navigate = useNavigate();
useEffect(() => {
const toSetup = () => navigate("/setup", { replace: true });
window.addEventListener(SETUP_REQUIRED_EVENT, toSetup);
return () => window.removeEventListener(SETUP_REQUIRED_EVENT, toSetup);
}, [navigate]);
return null;
}
+5 -5
View File
@@ -3,7 +3,7 @@ import * as THREE from "three";
import { phaseColor } from "@/components/PhaseBadge";
import { FleetGrid } from "@/components/FleetGrid";
import { webglAvailable } from "@/lib/webgl";
import type { ServerInfo } from "@/lib/types";
import type { MyServerView } from "@/lib/types";
// VoxelFleet renders the fleet as a grid of voxels, one per server, colored by
// lifecycle phase — the 3D view of the same state machine the list shows (spec
@@ -16,7 +16,7 @@ import type { ServerInfo } from "@/lib/types";
// cannot be created at all, so the card falls back to the flat FleetGrid.
interface Props {
servers: ServerInfo[];
servers: MyServerView[];
}
const STARTING = "Starting";
@@ -86,7 +86,7 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
voxels = [];
}
function build(list: ServerInfo[]) {
function build(list: MyServerView[]) {
clearVoxels();
const n = Math.max(list.length, 1);
const cols = Math.ceil(Math.sqrt(n));
@@ -96,7 +96,7 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
const offZ = ((rows - 1) * spacing) / 2;
list.forEach((s, i) => {
const color = new THREE.Color(phaseColor(s.phase));
const color = new THREE.Color(phaseColor(s.phase ?? "Unknown"));
const material = new THREE.MeshStandardMaterial({
color,
roughness: 0.45,
@@ -189,6 +189,6 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
/** sig is a cheap fingerprint of the fleet's renderable shape (order-independent
* per index): name+phase pairs. Player counts don't change the voxels, so they
* don't trigger a rebuild. */
function sig(servers: ServerInfo[]): string {
function sig(servers: MyServerView[]): string {
return servers.map((s) => `${s.name}:${s.phase}`).join("|");
}