mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 09:40:14 +00:00
360 lines
10 KiB
TypeScript
360 lines
10 KiB
TypeScript
import { act, render, renderHook, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { ReactNode } from "react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { CommandProvider } from "@/context/command-provider";
|
|
import { SortProvider } from "@/context/sort-provider";
|
|
import { StatusProvider } from "@/context/status-provider";
|
|
import { TooltipProvider } from "@/context/tooltip-provider";
|
|
import { WebSocketProvider } from "@/context/websocket-provider";
|
|
import { useCommand } from "@/hooks/use-command";
|
|
import { useSort } from "@/hooks/use-sort";
|
|
import { useStatus } from "@/hooks/use-status";
|
|
import { useTooltip } from "@/hooks/use-tooltip";
|
|
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
|
import { createServer } from "@/test/fixtures";
|
|
|
|
class FakeWebSocket {
|
|
static readonly CONNECTING = 0;
|
|
static readonly OPEN = 1;
|
|
static readonly CLOSING = 2;
|
|
static readonly CLOSED = 3;
|
|
static instances: FakeWebSocket[] = [];
|
|
|
|
readonly url: string;
|
|
readyState = FakeWebSocket.CONNECTING;
|
|
onopen: ((event: Event) => void) | null = null;
|
|
onclose: ((event: CloseEvent) => void) | null = null;
|
|
onmessage: ((event: MessageEvent<string>) => void) | null = null;
|
|
onerror: ((event: Event) => void) | null = null;
|
|
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
FakeWebSocket.instances.push(this);
|
|
}
|
|
|
|
open() {
|
|
this.readyState = FakeWebSocket.OPEN;
|
|
this.onopen?.(new Event("open"));
|
|
}
|
|
|
|
message(data: string) {
|
|
this.onmessage?.(new MessageEvent("message", { data }));
|
|
}
|
|
|
|
close() {
|
|
this.readyState = FakeWebSocket.CLOSED;
|
|
this.onclose?.(new CloseEvent("close"));
|
|
}
|
|
}
|
|
|
|
function CommandProbe() {
|
|
const { closeCommand, isOpen, openCommand, toggleCommand } = useCommand();
|
|
|
|
return (
|
|
<div>
|
|
<p data-testid="command-state">{isOpen ? "open" : "closed"}</p>
|
|
<button type="button" onClick={openCommand}>
|
|
open
|
|
</button>
|
|
<button type="button" onClick={closeCommand}>
|
|
close
|
|
</button>
|
|
<button type="button" onClick={toggleCommand}>
|
|
toggle
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SortProbe() {
|
|
const { setSortOrder, setSortType, sortOrder, sortType } = useSort();
|
|
|
|
return (
|
|
<div>
|
|
<p>{`${sortType}:${sortOrder}`}</p>
|
|
<button type="button" onClick={() => setSortType("cpu")}>
|
|
cpu
|
|
</button>
|
|
<button type="button" onClick={() => setSortOrder("asc")}>
|
|
asc
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusProbe() {
|
|
const { setStatus, status } = useStatus();
|
|
|
|
return (
|
|
<div>
|
|
<p data-testid="status-state">{status}</p>
|
|
<button type="button" onClick={() => setStatus("online")}>
|
|
online
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TooltipProbe() {
|
|
const { setTooltipData, tooltipData } = useTooltip();
|
|
|
|
return (
|
|
<div>
|
|
<p>{tooltipData?.country ?? "empty"}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setTooltipData({
|
|
centroid: [10, 20],
|
|
country: "China",
|
|
count: 1,
|
|
servers: [{ id: 1, name: "edge", status: true }],
|
|
})
|
|
}
|
|
>
|
|
show
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WebSocketProbe() {
|
|
const {
|
|
connected,
|
|
lastData,
|
|
messageHistory,
|
|
needReconnect,
|
|
setNeedReconnect,
|
|
} = useWebSocketContext();
|
|
|
|
return (
|
|
<div>
|
|
<p>{connected ? "connected" : "disconnected"}</p>
|
|
<p>{lastData?.servers[0]?.name ?? "none"}</p>
|
|
<p data-testid="message-count">{messageHistory.length}</p>
|
|
<p data-testid="history-server-count">
|
|
{messageHistory.reduce((total, item) => total + item.servers.length, 0)}
|
|
</p>
|
|
<p>{needReconnect ? "needs-reconnect" : "stable"}</p>
|
|
<button type="button" onClick={() => setNeedReconnect(true)}>
|
|
mark
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("state providers", () => {
|
|
it("manages command palette open state", async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<CommandProvider>
|
|
<CommandProbe />
|
|
</CommandProvider>,
|
|
);
|
|
|
|
expect(screen.getByTestId("command-state")).toHaveTextContent("closed");
|
|
await user.click(screen.getByRole("button", { name: "open" }));
|
|
expect(screen.getByTestId("command-state")).toHaveTextContent("open");
|
|
await user.click(screen.getByRole("button", { name: "toggle" }));
|
|
expect(screen.getByTestId("command-state")).toHaveTextContent("closed");
|
|
await user.click(screen.getByRole("button", { name: "open" }));
|
|
await user.click(screen.getByRole("button", { name: "close" }));
|
|
expect(screen.getByTestId("command-state")).toHaveTextContent("closed");
|
|
});
|
|
|
|
it("uses forced sort globals when valid and still allows local updates", async () => {
|
|
window.ForceSortType = "mem";
|
|
window.ForceSortOrder = "asc";
|
|
const user = userEvent.setup();
|
|
render(
|
|
<SortProvider>
|
|
<SortProbe />
|
|
</SortProvider>,
|
|
);
|
|
|
|
expect(screen.getByText("mem:asc")).toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: "cpu" }));
|
|
expect(screen.getByText("cpu:asc")).toBeInTheDocument();
|
|
});
|
|
|
|
it("falls back to default sort values for invalid forced globals", () => {
|
|
window.ForceSortType = "invalid";
|
|
window.ForceSortOrder = "up";
|
|
|
|
render(
|
|
<SortProvider>
|
|
<SortProbe />
|
|
</SortProvider>,
|
|
);
|
|
|
|
expect(screen.getByText("default:desc")).toBeInTheDocument();
|
|
});
|
|
|
|
it("manages server status filters", async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<StatusProvider>
|
|
<StatusProbe />
|
|
</StatusProvider>,
|
|
);
|
|
|
|
expect(screen.getByTestId("status-state")).toHaveTextContent("all");
|
|
await user.click(screen.getByRole("button", { name: "online" }));
|
|
expect(screen.getByTestId("status-state")).toHaveTextContent("online");
|
|
});
|
|
|
|
it("stores map tooltip data", async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<TooltipProvider>
|
|
<TooltipProbe />
|
|
</TooltipProvider>,
|
|
);
|
|
|
|
expect(screen.getByText("empty")).toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: "show" }));
|
|
expect(screen.getByText("China")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("context hooks", () => {
|
|
it("throws helpful errors when strict hooks miss their providers", () => {
|
|
expect(() => renderHook(() => useCommand())).toThrow(
|
|
"useCommand must be used within a CommandProvider",
|
|
);
|
|
expect(() => renderHook(() => useSort())).toThrow(
|
|
"useStatus must be used within a SortProvider",
|
|
);
|
|
expect(() => renderHook(() => useStatus())).toThrow(
|
|
"useStatus must be used within a StatusProvider",
|
|
);
|
|
expect(() => renderHook(() => useTooltip())).toThrow(
|
|
"useTooltip must be used within a TooltipProvider",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("WebSocketProvider", () => {
|
|
beforeEach(() => {
|
|
FakeWebSocket.instances = [];
|
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
});
|
|
|
|
function renderWebSocketProvider(children: ReactNode) {
|
|
return render(
|
|
<WebSocketProvider url="/api/v1/ws/server">{children}</WebSocketProvider>,
|
|
);
|
|
}
|
|
|
|
function websocketPayload(serverName: string) {
|
|
return JSON.stringify({
|
|
now: Date.parse("2025-01-01T00:00:20.000Z"),
|
|
servers: [createServer({ name: serverName })],
|
|
});
|
|
}
|
|
|
|
it("connects with a ws URL and records incoming messages", () => {
|
|
renderWebSocketProvider(<WebSocketProbe />);
|
|
|
|
const socket = FakeWebSocket.instances[0];
|
|
expect(socket.url).toBe("wss://localhost/api/v1/ws/server");
|
|
|
|
act(() => {
|
|
socket.open();
|
|
});
|
|
expect(screen.getByText("connected")).toBeInTheDocument();
|
|
|
|
act(() => {
|
|
socket.message(websocketPayload("first"));
|
|
socket.message(websocketPayload("second"));
|
|
});
|
|
|
|
expect(screen.getByText("second")).toBeInTheDocument();
|
|
expect(screen.getByTestId("message-count")).toHaveTextContent("2");
|
|
});
|
|
|
|
it("normalizes missing and non-array server collections", () => {
|
|
renderWebSocketProvider(<WebSocketProbe />);
|
|
const socket = FakeWebSocket.instances[0];
|
|
const now = Date.parse("2025-01-01T00:00:20.000Z");
|
|
|
|
act(() => {
|
|
socket.open();
|
|
socket.message(JSON.stringify({ now }));
|
|
socket.message(JSON.stringify({ now, servers: null }));
|
|
socket.message(JSON.stringify({ now, servers: { invalid: true } }));
|
|
});
|
|
|
|
expect(screen.getByText("none")).toBeInTheDocument();
|
|
expect(screen.getByTestId("message-count")).toHaveTextContent("3");
|
|
expect(screen.getByTestId("history-server-count")).toHaveTextContent("0");
|
|
});
|
|
|
|
it("keeps only the latest thirty websocket messages", () => {
|
|
renderWebSocketProvider(<WebSocketProbe />);
|
|
const socket = FakeWebSocket.instances[0];
|
|
|
|
act(() => {
|
|
socket.open();
|
|
for (let index = 0; index < 31; index += 1) {
|
|
socket.message(websocketPayload(`message-${index}`));
|
|
}
|
|
});
|
|
|
|
expect(screen.getByText("message-30")).toBeInTheDocument();
|
|
expect(screen.getByTestId("message-count")).toHaveTextContent("30");
|
|
});
|
|
|
|
it("ignores malformed websocket messages without disconnecting", () => {
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => undefined);
|
|
renderWebSocketProvider(<WebSocketProbe />);
|
|
const socket = FakeWebSocket.instances[0];
|
|
|
|
act(() => {
|
|
socket.open();
|
|
socket.message("not-json");
|
|
});
|
|
|
|
expect(screen.getByText("connected")).toBeInTheDocument();
|
|
expect(screen.getByText("none")).toBeInTheDocument();
|
|
expect(screen.getByTestId("message-count")).toHaveTextContent("0");
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
"Failed to parse WebSocket message:",
|
|
expect.any(SyntaxError),
|
|
);
|
|
});
|
|
|
|
it("ignores websocket messages without a valid response shape", () => {
|
|
const consoleError = vi
|
|
.spyOn(console, "error")
|
|
.mockImplementation(() => undefined);
|
|
renderWebSocketProvider(<WebSocketProbe />);
|
|
const socket = FakeWebSocket.instances[0];
|
|
|
|
act(() => {
|
|
socket.open();
|
|
socket.message("null");
|
|
socket.message(JSON.stringify({ servers: [] }));
|
|
});
|
|
|
|
expect(screen.getByTestId("message-count")).toHaveTextContent("0");
|
|
expect(consoleError).toHaveBeenCalledTimes(2);
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
"Failed to parse WebSocket message:",
|
|
expect.any(TypeError),
|
|
);
|
|
});
|
|
|
|
it("exposes manual reconnect state separately from socket state", async () => {
|
|
const user = userEvent.setup();
|
|
renderWebSocketProvider(<WebSocketProbe />);
|
|
|
|
expect(screen.getByText("stable")).toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: "mark" }));
|
|
expect(screen.getByText("needs-reconnect")).toBeInTheDocument();
|
|
});
|
|
});
|