feat: 初始提交
This commit is contained in:
64
tests/web/App.test.tsx
Normal file
64
tests/web/App.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { APP } from "../../src/shared/app";
|
||||
import { App } from "../../src/web/app";
|
||||
import { renderWithProviders } from "./test-utils";
|
||||
|
||||
describe("App", () => {
|
||||
test("渲染 Layout 骨架和品牌名", () => {
|
||||
window.fetch = (async () => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
expect(screen.getByText(APP.title)).not.toBeNull();
|
||||
expect(screen.getByText("系统")).not.toBeNull();
|
||||
expect(screen.getByText("明亮")).not.toBeNull();
|
||||
expect(screen.getByText("黑暗")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("渲染侧边栏菜单项", () => {
|
||||
window.fetch = (async () => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
expect(screen.getAllByText("仪表盘").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("用户管理").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("系统设置").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Header 不包含侧边栏折叠按钮", () => {
|
||||
window.fetch = (async () => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
const toggleButtons = document.querySelectorAll(".app-sidebar-toggle");
|
||||
expect(toggleButtons.length).toBe(0);
|
||||
});
|
||||
});
|
||||
68
tests/web/components/Sidebar/index.test.tsx
Normal file
68
tests/web/components/Sidebar/index.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { Sidebar } from "../../../../src/web/components/Sidebar";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
describe("Sidebar", () => {
|
||||
test("渲染菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
|
||||
|
||||
expect(screen.getByText("仪表盘")).not.toBeNull();
|
||||
expect(screen.getByText("用户管理")).not.toBeNull();
|
||||
expect(screen.getByText("系统设置")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("折叠状态下仍渲染菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }));
|
||||
|
||||
expect(screen.getByText("仪表盘")).not.toBeNull();
|
||||
expect(screen.getByText("用户管理")).not.toBeNull();
|
||||
expect(screen.getByText("系统设置")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("高亮当前路由对应的菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }), {
|
||||
initialRoute: "/users",
|
||||
});
|
||||
|
||||
const activeItem = document.querySelector(".t-is-active");
|
||||
expect(activeItem).not.toBeNull();
|
||||
expect(activeItem?.textContent).toContain("用户管理");
|
||||
});
|
||||
|
||||
test("展开态底部渲染折叠按钮", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
|
||||
|
||||
const collapseBtn = document.querySelector(".app-sidebar-collapse-btn");
|
||||
expect(collapseBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
test("点击底部按钮调用 onToggleCollapsed", () => {
|
||||
let called = false;
|
||||
const onToggle = () => {
|
||||
called = true;
|
||||
};
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: onToggle }));
|
||||
|
||||
const btn = document.querySelector<HTMLButtonElement>(".app-sidebar-collapse-btn");
|
||||
expect(btn).not.toBeNull();
|
||||
btn!.click();
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
test("折叠态底部按钮仍渲染且菜单项高亮不变", () => {
|
||||
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }), {
|
||||
initialRoute: "/users",
|
||||
});
|
||||
|
||||
const collapseBtn = document.querySelector(".app-sidebar-collapse-btn");
|
||||
expect(collapseBtn).not.toBeNull();
|
||||
|
||||
const activeItem = document.querySelector(".t-is-active");
|
||||
expect(activeItem).not.toBeNull();
|
||||
expect(activeItem?.textContent).toContain("用户管理");
|
||||
});
|
||||
});
|
||||
24
tests/web/routes/404.test.tsx
Normal file
24
tests/web/routes/404.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { NotFoundPage } from "../../../src/web/pages/404";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
describe("NotFoundPage", () => {
|
||||
test("渲染 404 页面", () => {
|
||||
renderWithProviders(createElement(NotFoundPage));
|
||||
|
||||
expect(screen.getByText("404")).not.toBeNull();
|
||||
expect(screen.getByText("您访问的页面不存在")).not.toBeNull();
|
||||
expect(screen.getByText("返回首页")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("返回首页按钮存在且可点击", () => {
|
||||
renderWithProviders(createElement(NotFoundPage));
|
||||
|
||||
const button = screen.getByText("返回首页");
|
||||
expect(button).not.toBeNull();
|
||||
expect(button.closest("button")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
25
tests/web/routes/dashboard.test.tsx
Normal file
25
tests/web/routes/dashboard.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { DashboardPage } from "../../../src/web/pages/dashboard";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
describe("DashboardPage", () => {
|
||||
test("渲染欢迎信息", () => {
|
||||
window.fetch = (async () => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(DashboardPage));
|
||||
|
||||
expect(screen.getByText(/欢迎使用/)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
15
tests/web/routes/settings.test.tsx
Normal file
15
tests/web/routes/settings.test.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { SettingsPage } from "../../../src/web/pages/settings";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
test("渲染系统设置页面", () => {
|
||||
renderWithProviders(createElement(SettingsPage));
|
||||
|
||||
expect(screen.getByText("系统设置")).not.toBeNull();
|
||||
expect(screen.getByText("页面建设中...")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
15
tests/web/routes/users.test.tsx
Normal file
15
tests/web/routes/users.test.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { UsersPage } from "../../../src/web/pages/users";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
describe("UsersPage", () => {
|
||||
test("渲染用户管理页面", () => {
|
||||
renderWithProviders(createElement(UsersPage));
|
||||
|
||||
expect(screen.getByText("用户管理")).not.toBeNull();
|
||||
expect(screen.getByText("页面建设中...")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
90
tests/web/test-utils.tsx
Normal file
90
tests/web/test-utils.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render } from "@testing-library/react";
|
||||
import { mock } from "bun:test";
|
||||
import { createElement, StrictMode } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
|
||||
|
||||
// Mock recharts BEFORE any component imports
|
||||
void mock.module("recharts", () => ({
|
||||
Area: () => null,
|
||||
CartesianGrid: () => null,
|
||||
Line: () => null,
|
||||
LineChart: ({ children }: { children: unknown }) => children,
|
||||
ResponsiveContainer: ({ children }: { children: unknown }) => children,
|
||||
Tooltip: () => null,
|
||||
XAxis: () => null,
|
||||
YAxis: () => null,
|
||||
}));
|
||||
|
||||
export interface RenderWithProvidersOptions {
|
||||
initialRoute?: string;
|
||||
}
|
||||
|
||||
export function renderWithProviders(ui: React.ReactElement, options?: RenderWithProvidersOptions) {
|
||||
const queryClient = createTestQueryClient();
|
||||
const initialRoute = options?.initialRoute ?? "/";
|
||||
|
||||
return render(
|
||||
createElement(
|
||||
StrictMode,
|
||||
null,
|
||||
createElement(
|
||||
ErrorBoundary,
|
||||
null,
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
createElement(MemoryRouter, { initialEntries: [initialRoute] }, ui),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Custom test helpers (替代 jest-dom matchers)
|
||||
export const testHelpers = {
|
||||
toBeInTheDocument: (element: Element | null) => {
|
||||
const pass = element !== null && document.contains(element);
|
||||
return {
|
||||
message: () => (pass ? "Expected element not to be in document" : "Expected element to be in document"),
|
||||
pass,
|
||||
};
|
||||
},
|
||||
toHaveAttribute: (element: Element | null, attr: string, value?: string) => {
|
||||
const pass = value === undefined ? (element?.hasAttribute(attr) ?? false) : element?.getAttribute(attr) === value;
|
||||
return {
|
||||
message: () =>
|
||||
pass ? `Expected element not to have attribute "${attr}"` : `Expected element to have attribute "${attr}"`,
|
||||
pass,
|
||||
};
|
||||
},
|
||||
toHaveClass: (element: Element | null, className: string) => {
|
||||
const pass = element?.classList.contains(className) ?? false;
|
||||
return {
|
||||
message: () =>
|
||||
pass ? `Expected element not to have class "${className}"` : `Expected element to have class "${className}"`,
|
||||
pass,
|
||||
};
|
||||
},
|
||||
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
|
||||
const content = element?.textContent ?? "";
|
||||
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
|
||||
return {
|
||||
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
|
||||
pass,
|
||||
};
|
||||
},
|
||||
};
|
||||
79
tests/web/utils/time.test.ts
Normal file
79
tests/web/utils/time.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
formatCountdown,
|
||||
formatDurationUnit,
|
||||
formatRelativeTime,
|
||||
isOlderThan,
|
||||
subtractHours,
|
||||
} from "../../../src/web/utils/time";
|
||||
|
||||
describe("subtractHours", () => {
|
||||
test("正常扣减小时", () => {
|
||||
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 3);
|
||||
|
||||
expect(result.toISOString()).toBe("2025-01-15T09:00:00.000Z");
|
||||
});
|
||||
|
||||
test("跨天扣减", () => {
|
||||
const result = subtractHours(new Date("2025-01-15T02:00:00.000Z"), 6);
|
||||
|
||||
expect(result.toISOString()).toBe("2025-01-14T20:00:00.000Z");
|
||||
});
|
||||
|
||||
test("跨月扣减", () => {
|
||||
const result = subtractHours(new Date("2025-03-01T01:00:00.000Z"), 2);
|
||||
|
||||
expect(result.toISOString()).toBe("2025-02-28T23:00:00.000Z");
|
||||
});
|
||||
|
||||
test("扣减 0 小时返回相同时间", () => {
|
||||
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 0);
|
||||
|
||||
expect(result.toISOString()).toBe("2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime", () => {
|
||||
const now = new Date("2025-01-01T00:02:00.000Z");
|
||||
|
||||
test("格式化秒和分钟", () => {
|
||||
expect(formatRelativeTime("2025-01-01T00:01:45.000Z", now)).toBe("15秒前");
|
||||
expect(formatRelativeTime("2025-01-01T00:00:00.000Z", now)).toBe("2分钟前");
|
||||
});
|
||||
|
||||
test("无时间返回占位", () => {
|
||||
expect(formatRelativeTime(null, now)).toBe("尚无检查数据");
|
||||
expect(formatRelativeTime("invalid", now)).toBe("尚无检查数据");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDurationUnit", () => {
|
||||
test("按秒、分钟、小时动态格式化", () => {
|
||||
expect(formatDurationUnit(1500)).toEqual({ suffix: "秒", value: 1.5 });
|
||||
expect(formatDurationUnit(120000)).toEqual({ suffix: "分钟", value: 2 });
|
||||
expect(formatDurationUnit(5400000)).toEqual({ suffix: "小时", value: 1.5 });
|
||||
});
|
||||
|
||||
test("空时长返回占位", () => {
|
||||
expect(formatDurationUnit(null)).toEqual({ suffix: "", value: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCountdown", () => {
|
||||
test("格式化秒级和分钟级倒计时", () => {
|
||||
expect(formatCountdown(0)).toBe("0秒");
|
||||
expect(formatCountdown(59)).toBe("59秒");
|
||||
expect(formatCountdown(60)).toBe("1分0秒");
|
||||
expect(formatCountdown(299)).toBe("4分59秒");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOlderThan", () => {
|
||||
test("判断时间是否超过阈值", () => {
|
||||
const now = new Date("2025-01-01T00:02:00.000Z");
|
||||
|
||||
expect(isOlderThan("2025-01-01T00:00:59.000Z", 60000, now)).toBe(true);
|
||||
expect(isOlderThan("2025-01-01T00:01:30.000Z", 60000, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user