test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认
This commit is contained in:
44 files changed
+10578
-216
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("|");
|
||||
}
|
||||
Reference in new issue
Block a user