test: 重构测试体系 — 建立组件测试层、补充后端测试、清理低质量测试
- 新增 jsdom + @testing-library/react 组件测试环境 - 新增 12 个组件测试,覆盖所有前端组件 - 补充后端 middleware 和 helpers 单元测试 - 删除伪测试 use-target-detail-logic.test.ts - 精简过度枚举的 color-threshold.test.ts - 新增 bunfig.toml 配置测试 preload - 更新 DEVELOPMENT.md 测试章节 - 安装 @types/jsdom 修复类型声明
This commit is contained in:
109
tests/server/helpers.test.ts
Normal file
109
tests/server/helpers.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createApiError, createHeaders, formatDuration, jsonResponse } from "../../src/server/helpers";
|
||||
|
||||
describe("createApiError", () => {
|
||||
test("创建错误响应对象", () => {
|
||||
const result = createApiError("Not found", 404);
|
||||
expect(result).toEqual({ error: "Not found", status: 404 });
|
||||
});
|
||||
|
||||
test("支持不同的错误消息和状态码", () => {
|
||||
const badRequest = createApiError("Bad request", 400);
|
||||
const internalError = createApiError("Internal error", 500);
|
||||
|
||||
expect(badRequest).toEqual({ error: "Bad request", status: 400 });
|
||||
expect(internalError).toEqual({ error: "Internal error", status: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHeaders", () => {
|
||||
test("生产模式添加安全 headers", () => {
|
||||
const headers = createHeaders("production", { "Content-Type": "application/json" });
|
||||
|
||||
expect(headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
test("非生产模式不添加安全 headers", () => {
|
||||
const headers = createHeaders("test", { "Content-Type": "application/json" });
|
||||
|
||||
expect(headers.get("X-Content-Type-Options")).toBeNull();
|
||||
expect(headers.get("Referrer-Policy")).toBeNull();
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
test("保留传入的自定义 headers", () => {
|
||||
const headers = createHeaders("production", { "X-Custom-Header": "custom-value" });
|
||||
|
||||
expect(headers.get("X-Custom-Header")).toBe("custom-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonResponse", () => {
|
||||
test("创建 JSON 响应", () => {
|
||||
const body = { message: "Hello" };
|
||||
const response = jsonResponse(body, { mode: "test" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
|
||||
});
|
||||
|
||||
test("生产模式响应包含安全 headers", () => {
|
||||
const response = jsonResponse({ data: "test" }, { mode: "production" });
|
||||
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
|
||||
});
|
||||
|
||||
test("支持自定义状态码", () => {
|
||||
const response = jsonResponse({ error: "Not found" }, { mode: "test", status: 404 });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
test("支持自定义 headers", () => {
|
||||
const response = jsonResponse(
|
||||
{ data: "test" },
|
||||
{
|
||||
headers: { "X-Custom": "value" },
|
||||
mode: "test",
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.headers.get("X-Custom")).toBe("value");
|
||||
});
|
||||
|
||||
test("响应 body 可以被解析为 JSON", async () => {
|
||||
const body = { count: 42, message: "Hello" };
|
||||
const response = jsonResponse(body, { mode: "test" });
|
||||
|
||||
const parsed = (await response.json()) as { count: number; message: string };
|
||||
expect(parsed).toEqual(body);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration", () => {
|
||||
test("毫秒格式化", () => {
|
||||
expect(formatDuration(100)).toBe("100ms");
|
||||
expect(formatDuration(999)).toBe("999ms");
|
||||
});
|
||||
|
||||
test("秒格式化(整秒)", () => {
|
||||
expect(formatDuration(1000)).toBe("1s");
|
||||
expect(formatDuration(5000)).toBe("5s");
|
||||
expect(formatDuration(59000)).toBe("59s");
|
||||
});
|
||||
|
||||
test("分钟格式化(整分钟)", () => {
|
||||
expect(formatDuration(60000)).toBe("1m");
|
||||
expect(formatDuration(120000)).toBe("2m");
|
||||
expect(formatDuration(300000)).toBe("5m");
|
||||
});
|
||||
|
||||
test("非整秒/整分钟保持毫秒", () => {
|
||||
expect(formatDuration(1500)).toBe("1500ms");
|
||||
expect(formatDuration(61123)).toBe("61123ms");
|
||||
});
|
||||
});
|
||||
171
tests/server/middleware.test.ts
Normal file
171
tests/server/middleware.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
validateDashboardWindow,
|
||||
validateMetricsBucket,
|
||||
validatePagination,
|
||||
validateRecentLimit,
|
||||
validateTargetId,
|
||||
validateTimeRange,
|
||||
} from "../../src/server/middleware";
|
||||
|
||||
describe("validateTargetId", () => {
|
||||
test("有效的 target ID 返回数字", () => {
|
||||
const result = validateTargetId("123", "production");
|
||||
expect(result).not.toHaveProperty("status");
|
||||
expect((result as { id: number }).id).toBe(123);
|
||||
});
|
||||
|
||||
test("无效的 target ID 返回 400", () => {
|
||||
const invalid = ["0", "-1", "abc", "1.5", ""];
|
||||
|
||||
for (const id of invalid) {
|
||||
const result = validateTargetId(id, "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTimeRange", () => {
|
||||
test("有效的 from/to 返回 ISO 字符串", () => {
|
||||
const result = validateTimeRange("2024-01-01T00:00:00.000Z", "2024-01-02T00:00:00.000Z", "production");
|
||||
expect(result).not.toHaveProperty("status");
|
||||
expect((result as { from: string; to: string }).from).toBe("2024-01-01T00:00:00.000Z");
|
||||
expect((result as { from: string; to: string }).to).toBe("2024-01-02T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("缺失 from 或 to 返回 400", () => {
|
||||
const missingFrom = validateTimeRange(null, "2024-01-02T00:00:00.000Z", "production");
|
||||
const missingTo = validateTimeRange("2024-01-01T00:00:00.000Z", null, "production");
|
||||
const missingBoth = validateTimeRange(null, null, "production");
|
||||
|
||||
expect(missingFrom).toHaveProperty("status", 400);
|
||||
expect(missingTo).toHaveProperty("status", 400);
|
||||
expect(missingBoth).toHaveProperty("status", 400);
|
||||
});
|
||||
|
||||
test("空字符串 from 或 to 返回 400", () => {
|
||||
const emptyFrom = validateTimeRange("", "2024-01-02T00:00:00.000Z", "production");
|
||||
const emptyTo = validateTimeRange("2024-01-01T00:00:00.000Z", "", "production");
|
||||
|
||||
expect(emptyFrom).toHaveProperty("status", 400);
|
||||
expect(emptyTo).toHaveProperty("status", 400);
|
||||
});
|
||||
|
||||
test("无效的日期格式返回 400", () => {
|
||||
const result = validateTimeRange("invalid-date", "2024-01-02T00:00:00.000Z", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
});
|
||||
|
||||
test("from 晚于 to 返回 400", () => {
|
||||
const result = validateTimeRange("2024-01-02T00:00:00.000Z", "2024-01-01T00:00:00.000Z", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePagination", () => {
|
||||
test("默认值:page=1, pageSize=20", () => {
|
||||
const result = validatePagination(null, null, "production");
|
||||
expect(result).toEqual({ page: 1, pageSize: 20 });
|
||||
});
|
||||
|
||||
test("有效的 page 和 pageSize 参数", () => {
|
||||
const result = validatePagination("2", "50", "production");
|
||||
expect(result).toEqual({ page: 2, pageSize: 50 });
|
||||
});
|
||||
|
||||
test("无效的 page 参数返回 400", () => {
|
||||
const invalidPage = ["0", "-1", "abc", "1.5"];
|
||||
|
||||
for (const page of invalidPage) {
|
||||
const result = validatePagination(page, "20", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
}
|
||||
});
|
||||
|
||||
test("无效的 pageSize 参数返回 400", () => {
|
||||
const invalidPageSize = ["0", "-1", "abc", "1.5"];
|
||||
|
||||
for (const pageSize of invalidPageSize) {
|
||||
const result = validatePagination("1", pageSize, "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
}
|
||||
});
|
||||
|
||||
test("pageSize 超过上限返回 400", () => {
|
||||
const result = validatePagination("1", "201", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
});
|
||||
|
||||
test("pageSize 等于上限 200 返回成功", () => {
|
||||
const result = validatePagination("1", "200", "production");
|
||||
expect(result).toEqual({ page: 1, pageSize: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateRecentLimit", () => {
|
||||
test("默认值:recentLimit=30", () => {
|
||||
const result = validateRecentLimit(null, "production");
|
||||
expect(result).toEqual({ recentLimit: 30 });
|
||||
});
|
||||
|
||||
test("有效的 recentLimit 参数", () => {
|
||||
const result = validateRecentLimit("50", "production");
|
||||
expect(result).toEqual({ recentLimit: 50 });
|
||||
});
|
||||
|
||||
test("无效的 recentLimit 参数返回 400", () => {
|
||||
const invalid = ["0", "-1", "abc", "1.5"];
|
||||
|
||||
for (const limit of invalid) {
|
||||
const result = validateRecentLimit(limit, "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
}
|
||||
});
|
||||
|
||||
test("recentLimit 超过上限返回 400", () => {
|
||||
const result = validateRecentLimit("201", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
});
|
||||
|
||||
test("recentLimit 等于上限 200 返回成功", () => {
|
||||
const result = validateRecentLimit("200", "production");
|
||||
expect(result).toEqual({ recentLimit: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDashboardWindow", () => {
|
||||
test("默认值:window=24h", () => {
|
||||
const result = validateDashboardWindow(null, "production");
|
||||
expect(result).not.toHaveProperty("status");
|
||||
expect((result as { label: string }).label).toBe("24h");
|
||||
});
|
||||
|
||||
test("window=24h 返回成功", () => {
|
||||
const result = validateDashboardWindow("24h", "production");
|
||||
expect(result).not.toHaveProperty("status");
|
||||
expect((result as { label: string }).label).toBe("24h");
|
||||
});
|
||||
|
||||
test("不支持的 window 参数返回 400", () => {
|
||||
const result = validateDashboardWindow("7d", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMetricsBucket", () => {
|
||||
test("默认值:bucket=1h", () => {
|
||||
const result = validateMetricsBucket(null, "production");
|
||||
expect(result).toEqual({ bucket: "1h" });
|
||||
});
|
||||
|
||||
test("bucket=1h 返回成功", () => {
|
||||
const result = validateMetricsBucket("1h", "production");
|
||||
expect(result).toEqual({ bucket: "1h" });
|
||||
});
|
||||
|
||||
test("不支持的 bucket 参数返回 400", () => {
|
||||
const result = validateMetricsBucket("5m", "production");
|
||||
expect(result).toHaveProperty("status", 400);
|
||||
});
|
||||
});
|
||||
81
tests/setup.ts
Normal file
81
tests/setup.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 全局测试配置
|
||||
* 主要为后端测试提供基础环境
|
||||
* 组件测试使用各自的 test-utils.tsx
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
// Set up jsdom for ALL tests (both backend and frontend)
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.window = dom.window as unknown as typeof globalThis & Window;
|
||||
globalThis.navigator = dom.window.navigator;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle;
|
||||
|
||||
// Ensure document.body exists
|
||||
if (!globalThis.document.body) {
|
||||
const body = globalThis.document.createElement("body");
|
||||
globalThis.document.documentElement.appendChild(body);
|
||||
}
|
||||
|
||||
// CRITICAL: Set up polyfills BEFORE any other imports
|
||||
// This ensures @testing-library/react sees these when it loads
|
||||
|
||||
// IE-style event handling polyfill (React fallback)
|
||||
const nodeProto = dom.window.Node.prototype;
|
||||
const elementProto = dom.window.Element.prototype;
|
||||
const htmlElementProto = dom.window.HTMLElement.prototype;
|
||||
|
||||
const attachEventFn = () => {};
|
||||
const detachEventFn = () => {};
|
||||
|
||||
Object.defineProperty(nodeProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
|
||||
Object.defineProperty(nodeProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
|
||||
Object.defineProperty(elementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
|
||||
Object.defineProperty(elementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
|
||||
Object.defineProperty(htmlElementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
|
||||
Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
|
||||
|
||||
// Other polyfills
|
||||
globalThis.ResizeObserver = class {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
};
|
||||
|
||||
globalThis.IntersectionObserver = class {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 16);
|
||||
globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
|
||||
|
||||
Object.defineProperty(dom.window, "matchMedia", {
|
||||
value: (query: string) => ({
|
||||
addEventListener: () => {},
|
||||
addListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
removeEventListener: () => {},
|
||||
removeListener: () => {},
|
||||
}),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
dom.window.Element.prototype.scrollTo = () => {};
|
||||
dom.window.Element.prototype.scrollIntoView = () => {};
|
||||
127
tests/web/components/App.test.tsx
Normal file
127
tests/web/components/App.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
|
||||
import { App } from "../../../src/web/app";
|
||||
|
||||
// Mock hooks
|
||||
void vi.mock("../../../src/web/hooks/use-queries", () => ({
|
||||
useDashboard: vi.fn(() => ({
|
||||
data: {
|
||||
summary: {
|
||||
down: 0,
|
||||
incidents: 0,
|
||||
lastCheckTime: "2025-01-15T10:00:00.000Z",
|
||||
total: 0,
|
||||
up: 0,
|
||||
window: {
|
||||
from: "2025-01-14T10:00:00.000Z",
|
||||
label: "24h",
|
||||
to: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
targets: [],
|
||||
},
|
||||
dataUpdatedAt: Date.now(),
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useMeta: vi.fn(() => ({
|
||||
data: { checkerTypes: ["http", "cmd"] },
|
||||
})),
|
||||
}));
|
||||
|
||||
void vi.mock("../../../src/web/hooks/use-target-detail", () => ({
|
||||
useTargetDetail: vi.fn(() => ({
|
||||
activeTab: "overview",
|
||||
closeDrawer: vi.fn(),
|
||||
handlePageChange: vi.fn(),
|
||||
handleTabChange: vi.fn(),
|
||||
handleTimeChange: vi.fn(),
|
||||
historyData: {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
},
|
||||
historyLoading: false,
|
||||
metricsData: null,
|
||||
metricsLoading: false,
|
||||
openDrawer: vi.fn(),
|
||||
selectedTarget: null,
|
||||
timeFrom: "",
|
||||
timeTo: "",
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("App", () => {
|
||||
test("渲染不崩溃", () => {
|
||||
const { container } = render(<App />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("loading 状态不崩溃", () => {
|
||||
const { useDashboard } = require("../../../src/web/hooks/use-queries");
|
||||
useDashboard.mockReturnValue({
|
||||
data: null,
|
||||
dataUpdatedAt: 0,
|
||||
error: null,
|
||||
isFetching: true,
|
||||
isLoading: true,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(<App />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("错误状态不崩溃", () => {
|
||||
const { useDashboard } = require("../../../src/web/hooks/use-queries");
|
||||
useDashboard.mockReturnValue({
|
||||
data: null,
|
||||
dataUpdatedAt: 0,
|
||||
error: { message: "Network error" },
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(<App />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("有数据状态不崩溃", () => {
|
||||
const { useDashboard } = require("../../../src/web/hooks/use-queries");
|
||||
useDashboard.mockReturnValue({
|
||||
data: {
|
||||
summary: {
|
||||
down: 1,
|
||||
incidents: 0,
|
||||
lastCheckTime: "2025-01-15T10:00:00.000Z",
|
||||
total: 2,
|
||||
up: 1,
|
||||
window: {
|
||||
from: "2025-01-14T10:00:00.000Z",
|
||||
label: "24h",
|
||||
to: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
},
|
||||
targets: [],
|
||||
},
|
||||
dataUpdatedAt: Date.now(),
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
|
||||
const { container } = render(<App />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
67
tests/web/components/ErrorBoundary.test.tsx
Normal file
67
tests/web/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
|
||||
import { ErrorBoundary } from "../../../src/web/components/ErrorBoundary";
|
||||
|
||||
// 一个正常组件
|
||||
function NormalComponent() {
|
||||
return <div>Normal content</div>;
|
||||
}
|
||||
|
||||
// 一个会抛错的组件
|
||||
function ThrowError() {
|
||||
throw new Error("Test error");
|
||||
// TypeScript 需要返回值,虽然这里永远不会执行
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
// Mock console.error to suppress error output during tests
|
||||
});
|
||||
});
|
||||
|
||||
test("捕获子组件渲染错误并显示 fallback", () => {
|
||||
const { container } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("正常渲染子组件", () => {
|
||||
const { container } = render(
|
||||
<ErrorBoundary>
|
||||
<NormalComponent />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("刷新按钮不崩溃", () => {
|
||||
const { container } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("错误时调用 console.error", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
42
tests/web/components/HistoryTab.test.tsx
Normal file
42
tests/web/components/HistoryTab.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
|
||||
import type { HistoryResponse } from "../../../src/shared/api";
|
||||
|
||||
import { HistoryTab } from "../../../src/web/components/HistoryTab";
|
||||
|
||||
describe("HistoryTab", () => {
|
||||
const historyData: HistoryResponse = {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const onPageChange = vi.fn();
|
||||
|
||||
test("渲染不崩溃", () => {
|
||||
const { container } = render(
|
||||
<HistoryTab historyData={historyData} historyLoading={false} onPageChange={onPageChange} />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("loading 状态不崩溃", () => {
|
||||
const { container } = render(
|
||||
<HistoryTab historyData={historyData} historyLoading={true} onPageChange={onPageChange} />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("空数据不崩溃", () => {
|
||||
const { container } = render(
|
||||
<HistoryTab historyData={historyData} historyLoading={false} onPageChange={onPageChange} />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
67
tests/web/components/OverviewTab.test.tsx
Normal file
67
tests/web/components/OverviewTab.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { TargetMetricsResponse, TargetStatus } from "../../../src/shared/api";
|
||||
|
||||
import { OverviewTab } from "../../../src/web/components/OverviewTab";
|
||||
|
||||
describe("OverviewTab", () => {
|
||||
const target: TargetStatus = {
|
||||
currentStreak: null,
|
||||
group: "default",
|
||||
id: 1,
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "test-target",
|
||||
recentSamples: [],
|
||||
stats: { availability: 100, downChecks: 0, totalChecks: 10, upChecks: 10 },
|
||||
target: "https://example.com",
|
||||
type: "http",
|
||||
};
|
||||
|
||||
const metricsData: TargetMetricsResponse = {
|
||||
stats: {
|
||||
availability: 95,
|
||||
avgDurationMs: 150,
|
||||
currentStreak: { count: 5, up: true },
|
||||
downChecks: 1,
|
||||
incidentCount: 1,
|
||||
longestOutage: 60000,
|
||||
mttr: 30000,
|
||||
p95DurationMs: 200,
|
||||
p99DurationMs: 250,
|
||||
totalChecks: 20,
|
||||
upChecks: 19,
|
||||
},
|
||||
targetId: 1,
|
||||
trend: [],
|
||||
window: { bucket: "1h", from: "", to: "" },
|
||||
};
|
||||
|
||||
test("有数据不崩溃", () => {
|
||||
const { container } = render(<OverviewTab metricsData={metricsData} metricsLoading={false} target={target} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("loading 状态不崩溃", () => {
|
||||
const { container } = render(<OverviewTab metricsData={null} metricsLoading={true} target={target} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("无指标数据不崩溃", () => {
|
||||
const { container } = render(<OverviewTab metricsData={null} metricsLoading={false} target={target} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("显示趋势图表不崩溃", () => {
|
||||
const { container } = render(<OverviewTab metricsData={metricsData} metricsLoading={false} target={target} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
64
tests/web/components/RefreshCountdown.test.tsx
Normal file
64
tests/web/components/RefreshCountdown.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
|
||||
import { RefreshCountdown } from "../../../src/web/components/RefreshCountdown";
|
||||
|
||||
describe("RefreshCountdown", () => {
|
||||
test("手动模式不崩溃", () => {
|
||||
const { container } = render(
|
||||
<RefreshCountdown
|
||||
dashboardUpdatedAt={0}
|
||||
isFetching={false}
|
||||
isManualRefresh={true}
|
||||
onRefresh={vi.fn()}
|
||||
refreshInterval={30000}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("自动模式不崩溃", () => {
|
||||
const now = Date.now();
|
||||
const { container } = render(
|
||||
<RefreshCountdown
|
||||
dashboardUpdatedAt={now - 10000}
|
||||
isFetching={false}
|
||||
isManualRefresh={false}
|
||||
onRefresh={vi.fn()}
|
||||
refreshInterval={30000}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("fetching 状态不崩溃", () => {
|
||||
const { container } = render(
|
||||
<RefreshCountdown
|
||||
dashboardUpdatedAt={1000}
|
||||
isFetching={true}
|
||||
isManualRefresh={false}
|
||||
onRefresh={vi.fn()}
|
||||
refreshInterval={30000}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("未刷新状态不崩溃", () => {
|
||||
const { container } = render(
|
||||
<RefreshCountdown
|
||||
dashboardUpdatedAt={0}
|
||||
isFetching={false}
|
||||
isManualRefresh={false}
|
||||
onRefresh={vi.fn()}
|
||||
refreshInterval={30000}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
31
tests/web/components/StatusBar.test.tsx
Normal file
31
tests/web/components/StatusBar.test.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { RecentSample } from "../../../src/shared/api";
|
||||
|
||||
import { StatusBar } from "../../../src/web/components/StatusBar";
|
||||
|
||||
describe("StatusBar", () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const samples: RecentSample[] = [
|
||||
{ durationMs: 100, timestamp: now, up: true },
|
||||
{ durationMs: 150, timestamp: new Date(Date.now() - 60000).toISOString(), up: false },
|
||||
];
|
||||
|
||||
test("渲染不崩溃", () => {
|
||||
const { container } = render(<StatusBar maxSlots={5} samples={samples} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("默认 maxSlots 不崩溃", () => {
|
||||
const { container } = render(<StatusBar samples={samples} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("空 samples 不崩溃", () => {
|
||||
const { container } = render(<StatusBar maxSlots={3} samples={[]} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
17
tests/web/components/StatusDot.test.tsx
Normal file
17
tests/web/components/StatusDot.test.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { StatusDot } from "../../../src/web/components/StatusDot";
|
||||
|
||||
describe("StatusDot", () => {
|
||||
test("up=true 不崩溃", () => {
|
||||
const { container } = render(<StatusDot up={true} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("up=false 不崩溃", () => {
|
||||
const { container } = render(<StatusDot up={false} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
33
tests/web/components/SummaryCards.test.tsx
Normal file
33
tests/web/components/SummaryCards.test.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { DashboardResponse } from "../../../src/shared/api";
|
||||
|
||||
import { SummaryCards } from "../../../src/web/components/SummaryCards";
|
||||
|
||||
describe("SummaryCards", () => {
|
||||
const summary: DashboardResponse["summary"] = {
|
||||
down: 2,
|
||||
incidents: 1,
|
||||
lastCheckTime: "2025-01-15T10:00:00.000Z",
|
||||
total: 10,
|
||||
up: 8,
|
||||
window: {
|
||||
from: "2025-01-14T10:00:00.000Z",
|
||||
label: "24h",
|
||||
to: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
test("summary 为 null 时不渲染", () => {
|
||||
const { container } = render(<SummaryCards summary={null} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
test("有数据不崩溃", () => {
|
||||
const { container } = render(<SummaryCards summary={summary} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
55
tests/web/components/TargetBoard.test.tsx
Normal file
55
tests/web/components/TargetBoard.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
|
||||
import type { TargetStatus } from "../../../src/shared/api";
|
||||
|
||||
import { TargetBoard } from "../../../src/web/components/TargetBoard";
|
||||
|
||||
// Mock useMeta hook
|
||||
void vi.mock("../../../src/web/hooks/use-queries", () => ({
|
||||
useMeta: () => ({
|
||||
data: { checkerTypes: ["http", "cmd"] },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("TargetBoard", () => {
|
||||
const onTargetClick = vi.fn();
|
||||
|
||||
const targets: TargetStatus[] = [
|
||||
{
|
||||
currentStreak: null,
|
||||
group: "default",
|
||||
id: 1,
|
||||
interval: "30s",
|
||||
latestCheck: null,
|
||||
name: "target-1",
|
||||
recentSamples: [],
|
||||
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
|
||||
target: "https://example.com",
|
||||
type: "http",
|
||||
},
|
||||
{
|
||||
currentStreak: null,
|
||||
group: "production",
|
||||
id: 2,
|
||||
interval: "30s",
|
||||
latestCheck: null,
|
||||
name: "target-2",
|
||||
recentSamples: [],
|
||||
stats: { availability: 100, downChecks: 0, totalChecks: 0, upChecks: 0 },
|
||||
target: "https://example.org",
|
||||
type: "http",
|
||||
},
|
||||
];
|
||||
|
||||
test("有 targets 时不崩溃", () => {
|
||||
const { container } = render(<TargetBoard onTargetClick={onTargetClick} targets={targets} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("空 targets 列表不崩溃", () => {
|
||||
const { container } = render(<TargetBoard onTargetClick={onTargetClick} targets={[]} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
88
tests/web/components/TargetDetailDrawer.test.tsx
Normal file
88
tests/web/components/TargetDetailDrawer.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
|
||||
import type { HistoryResponse, TargetMetricsResponse, TargetStatus } from "../../../src/shared/api";
|
||||
|
||||
import { TargetDetailDrawer } from "../../../src/web/components/TargetDetailDrawer";
|
||||
|
||||
describe("TargetDetailDrawer", () => {
|
||||
const target: TargetStatus = {
|
||||
currentStreak: null,
|
||||
group: "default",
|
||||
id: 1,
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "test-target",
|
||||
recentSamples: [],
|
||||
stats: { availability: 100, downChecks: 0, totalChecks: 10, upChecks: 10 },
|
||||
target: "https://example.com",
|
||||
type: "http",
|
||||
};
|
||||
|
||||
const metricsData: TargetMetricsResponse = {
|
||||
stats: {
|
||||
availability: 95,
|
||||
avgDurationMs: 150,
|
||||
currentStreak: null,
|
||||
downChecks: 1,
|
||||
incidentCount: 1,
|
||||
longestOutage: null,
|
||||
mttr: null,
|
||||
p95DurationMs: 200,
|
||||
p99DurationMs: 250,
|
||||
totalChecks: 20,
|
||||
upChecks: 19,
|
||||
},
|
||||
targetId: 1,
|
||||
trend: [],
|
||||
window: { bucket: "1h", from: "", to: "" },
|
||||
};
|
||||
|
||||
const historyData: HistoryResponse = {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
activeTab: "overview",
|
||||
historyData,
|
||||
historyLoading: false,
|
||||
metricsData,
|
||||
metricsLoading: false,
|
||||
onClose: vi.fn(),
|
||||
onPageChange: vi.fn(),
|
||||
onTabChange: vi.fn(),
|
||||
onTimeChange: vi.fn(),
|
||||
target,
|
||||
timeFrom: "2025-01-15T00:00:00.000Z",
|
||||
timeTo: "2025-01-15T23:59:59.999Z",
|
||||
};
|
||||
|
||||
test("target 为 null 时不崩溃", () => {
|
||||
const { container } = render(<TargetDetailDrawer {...defaultProps} target={null} />);
|
||||
// When target is null, the drawer might not render, which is expected behavior
|
||||
expect(container).not.toBeNull();
|
||||
});
|
||||
|
||||
test("target 存在时不崩溃", () => {
|
||||
const { asFragment } = render(<TargetDetailDrawer {...defaultProps} />);
|
||||
// Just verify rendering doesn't throw
|
||||
expect(asFragment()).not.toBeNull();
|
||||
});
|
||||
|
||||
test("关闭按钮不崩溃", () => {
|
||||
const onClose = vi.fn();
|
||||
const { asFragment } = render(<TargetDetailDrawer {...defaultProps} onClose={onClose} />);
|
||||
// Just verify rendering doesn't throw
|
||||
expect(asFragment()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
76
tests/web/components/TargetGroup.test.tsx
Normal file
76
tests/web/components/TargetGroup.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
|
||||
import type { TargetStatus } from "../../../src/shared/api";
|
||||
|
||||
import { TargetGroup } from "../../../src/web/components/TargetGroup";
|
||||
|
||||
describe("TargetGroup", () => {
|
||||
const columns = [
|
||||
{ colKey: "name", title: "名称" },
|
||||
{ colKey: "target", title: "目标" },
|
||||
];
|
||||
|
||||
const targets: TargetStatus[] = [
|
||||
{
|
||||
currentStreak: null,
|
||||
group: "default",
|
||||
id: 1,
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "target-1",
|
||||
recentSamples: [],
|
||||
stats: { availability: 100, downChecks: 0, totalChecks: 10, upChecks: 10 },
|
||||
target: "https://example.com",
|
||||
type: "http",
|
||||
},
|
||||
{
|
||||
currentStreak: null,
|
||||
group: "default",
|
||||
id: 2,
|
||||
interval: "30s",
|
||||
latestCheck: {
|
||||
durationMs: 100,
|
||||
failure: { kind: "error", message: "Failed", path: "$", phase: "status" },
|
||||
matched: false,
|
||||
statusDetail: "500 Internal Server Error",
|
||||
timestamp: "2025-01-15T10:00:00.000Z",
|
||||
},
|
||||
name: "target-2",
|
||||
recentSamples: [],
|
||||
stats: { availability: 50, downChecks: 1, totalChecks: 2, upChecks: 1 },
|
||||
target: "https://example.org",
|
||||
type: "http",
|
||||
},
|
||||
];
|
||||
|
||||
const onTargetClick = vi.fn();
|
||||
|
||||
test("default 分组不崩溃", () => {
|
||||
const { container } = render(
|
||||
<TargetGroup columns={columns} name="default" onTargetClick={onTargetClick} targets={targets} />,
|
||||
);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("非 default 分组不崩溃", () => {
|
||||
const { container } = render(
|
||||
<TargetGroup columns={columns} name="production" onTargetClick={onTargetClick} targets={targets} />,
|
||||
);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("空 targets 不崩溃", () => {
|
||||
const { container } = render(
|
||||
<TargetGroup columns={columns} name="default" onTargetClick={onTargetClick} targets={[]} />,
|
||||
);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
53
tests/web/components/TrendChart.test.tsx
Normal file
53
tests/web/components/TrendChart.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import "../../../tests/web/test-utils";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { TrendPoint } from "../../../src/shared/api";
|
||||
|
||||
import { TrendChart } from "../../../src/web/components/TrendChart";
|
||||
|
||||
describe("TrendChart", () => {
|
||||
const data: TrendPoint[] = [
|
||||
{
|
||||
availability: 100,
|
||||
avgDurationMs: 100,
|
||||
bucketStart: "2025-01-15T10:00:00.000Z",
|
||||
downChecks: 0,
|
||||
maxDurationMs: 150,
|
||||
minDurationMs: 50,
|
||||
totalChecks: 10,
|
||||
upChecks: 10,
|
||||
},
|
||||
{
|
||||
availability: 95,
|
||||
avgDurationMs: 120,
|
||||
bucketStart: "2025-01-15T11:00:00.000Z",
|
||||
downChecks: 1,
|
||||
maxDurationMs: 200,
|
||||
minDurationMs: 80,
|
||||
totalChecks: 20,
|
||||
upChecks: 19,
|
||||
},
|
||||
];
|
||||
|
||||
test("有数据时不崩溃", () => {
|
||||
const { container } = render(<TrendChart data={data} />);
|
||||
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
test("空数据显示占位", () => {
|
||||
const { container } = render(<TrendChart data={[]} />);
|
||||
|
||||
// 应该显示占位文本
|
||||
const element = container.querySelector(".trend-empty");
|
||||
expect(element).not.toBeNull();
|
||||
});
|
||||
|
||||
test("包含 trend-chart className", () => {
|
||||
const { container } = render(<TrendChart data={data} />);
|
||||
|
||||
const element = container.querySelector(".trend-chart");
|
||||
expect(element).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,66 +4,32 @@ import { getAvailabilityProgressColor } from "../../../src/web/constants/color-t
|
||||
|
||||
describe("color-threshold", () => {
|
||||
describe("getAvailabilityProgressColor", () => {
|
||||
test("0-10% 返回第一档 CSS 变量", () => {
|
||||
test("首档(0-10%)和末档(90-100%)", () => {
|
||||
expect(getAvailabilityProgressColor(0)).toBe("var(--avail-0)");
|
||||
expect(getAvailabilityProgressColor(5)).toBe("var(--avail-0)");
|
||||
expect(getAvailabilityProgressColor(9.99)).toBe("var(--avail-0)");
|
||||
});
|
||||
|
||||
test("10-20% 返回第二档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(10)).toBe("var(--avail-1)");
|
||||
expect(getAvailabilityProgressColor(15)).toBe("var(--avail-1)");
|
||||
expect(getAvailabilityProgressColor(19.99)).toBe("var(--avail-1)");
|
||||
});
|
||||
|
||||
test("20-30% 返回第三档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(20)).toBe("var(--avail-2)");
|
||||
expect(getAvailabilityProgressColor(25)).toBe("var(--avail-2)");
|
||||
});
|
||||
|
||||
test("30-40% 返回第四档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(30)).toBe("var(--avail-3)");
|
||||
expect(getAvailabilityProgressColor(35)).toBe("var(--avail-3)");
|
||||
});
|
||||
|
||||
test("40-50% 返回第五档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(40)).toBe("var(--avail-4)");
|
||||
expect(getAvailabilityProgressColor(45)).toBe("var(--avail-4)");
|
||||
});
|
||||
|
||||
test("50-60% 返回第六档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(50)).toBe("var(--avail-5)");
|
||||
expect(getAvailabilityProgressColor(55)).toBe("var(--avail-5)");
|
||||
});
|
||||
|
||||
test("60-70% 返回第七档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(60)).toBe("var(--avail-6)");
|
||||
expect(getAvailabilityProgressColor(65)).toBe("var(--avail-6)");
|
||||
});
|
||||
|
||||
test("70-80% 返回第八档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(70)).toBe("var(--avail-7)");
|
||||
expect(getAvailabilityProgressColor(75)).toBe("var(--avail-7)");
|
||||
});
|
||||
|
||||
test("80-90% 返回第九档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(80)).toBe("var(--avail-8)");
|
||||
expect(getAvailabilityProgressColor(85)).toBe("var(--avail-8)");
|
||||
});
|
||||
|
||||
test("90-100% 返回第十档 CSS 变量", () => {
|
||||
expect(getAvailabilityProgressColor(90)).toBe("var(--avail-9)");
|
||||
expect(getAvailabilityProgressColor(95)).toBe("var(--avail-9)");
|
||||
expect(getAvailabilityProgressColor(99.9)).toBe("var(--avail-9)");
|
||||
expect(getAvailabilityProgressColor(100)).toBe("var(--avail-9)");
|
||||
});
|
||||
|
||||
test("边界值", () => {
|
||||
expect(getAvailabilityProgressColor(9.999)).toBe("var(--avail-0)");
|
||||
test("所有边界值(每档切换点)", () => {
|
||||
expect(getAvailabilityProgressColor(9.99)).toBe("var(--avail-0)");
|
||||
expect(getAvailabilityProgressColor(10)).toBe("var(--avail-1)");
|
||||
expect(getAvailabilityProgressColor(19.999)).toBe("var(--avail-1)");
|
||||
expect(getAvailabilityProgressColor(19.99)).toBe("var(--avail-1)");
|
||||
expect(getAvailabilityProgressColor(20)).toBe("var(--avail-2)");
|
||||
expect(getAvailabilityProgressColor(89.999)).toBe("var(--avail-8)");
|
||||
expect(getAvailabilityProgressColor(29.99)).toBe("var(--avail-2)");
|
||||
expect(getAvailabilityProgressColor(30)).toBe("var(--avail-3)");
|
||||
expect(getAvailabilityProgressColor(39.99)).toBe("var(--avail-3)");
|
||||
expect(getAvailabilityProgressColor(40)).toBe("var(--avail-4)");
|
||||
expect(getAvailabilityProgressColor(49.99)).toBe("var(--avail-4)");
|
||||
expect(getAvailabilityProgressColor(50)).toBe("var(--avail-5)");
|
||||
expect(getAvailabilityProgressColor(59.99)).toBe("var(--avail-5)");
|
||||
expect(getAvailabilityProgressColor(60)).toBe("var(--avail-6)");
|
||||
expect(getAvailabilityProgressColor(69.99)).toBe("var(--avail-6)");
|
||||
expect(getAvailabilityProgressColor(70)).toBe("var(--avail-7)");
|
||||
expect(getAvailabilityProgressColor(79.99)).toBe("var(--avail-7)");
|
||||
expect(getAvailabilityProgressColor(80)).toBe("var(--avail-8)");
|
||||
expect(getAvailabilityProgressColor(89.99)).toBe("var(--avail-8)");
|
||||
expect(getAvailabilityProgressColor(90)).toBe("var(--avail-9)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
function shouldEnableHistory(
|
||||
selectedTargetId: null | number,
|
||||
timeFrom: string,
|
||||
timeTo: string,
|
||||
activeTab: string,
|
||||
): boolean {
|
||||
return selectedTargetId !== null && !!timeFrom && !!timeTo && activeTab === "history";
|
||||
}
|
||||
|
||||
function shouldEnableMetrics(selectedTargetId: null | number, timeFrom: string, timeTo: string): boolean {
|
||||
return selectedTargetId !== null && !!timeFrom && !!timeTo;
|
||||
}
|
||||
|
||||
describe("metrics enabled 条件", () => {
|
||||
test("未选中目标时不启用", () => {
|
||||
expect(shouldEnableMetrics(null, "", "")).toBe(false);
|
||||
});
|
||||
|
||||
test("选中目标但无时间范围时不启用", () => {
|
||||
expect(shouldEnableMetrics(1, "", "")).toBe(false);
|
||||
});
|
||||
|
||||
test("选中目标且有时间范围时启用", () => {
|
||||
expect(shouldEnableMetrics(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("history enabled 条件", () => {
|
||||
test("未选中目标时不启用", () => {
|
||||
expect(shouldEnableHistory(null, "from", "to", "history")).toBe(false);
|
||||
});
|
||||
|
||||
test("选中目标但概览 Tab 时不启用", () => {
|
||||
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "overview")).toBe(false);
|
||||
});
|
||||
|
||||
test("选中目标且记录 Tab 激活但无时间范围时不启用", () => {
|
||||
expect(shouldEnableHistory(1, "", "", "history")).toBe(false);
|
||||
});
|
||||
|
||||
test("选中目标、有时间范围且记录 Tab 激活时启用", () => {
|
||||
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "history")).toBe(true);
|
||||
});
|
||||
|
||||
test("打开 Drawer 默认概览 Tab 时不启用 history", () => {
|
||||
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "overview")).toBe(false);
|
||||
});
|
||||
|
||||
test("概览 Tab 时间变化时不启用 history", () => {
|
||||
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "overview")).toBe(false);
|
||||
});
|
||||
|
||||
test("记录 Tab 时间变化时启用 history", () => {
|
||||
expect(shouldEnableHistory(1, "2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z", "history")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("默认概览 Tab 行为", () => {
|
||||
test("打开 Drawer 时 activeTab 应为 overview", () => {
|
||||
const resetTab = "overview";
|
||||
expect(resetTab).toBe("overview");
|
||||
});
|
||||
|
||||
test("切换目标时 activeTab 应重置为 overview", () => {
|
||||
const previousTab = "history";
|
||||
const resetTab = "overview";
|
||||
expect(previousTab).not.toBe(resetTab);
|
||||
expect(resetTab).toBe("overview");
|
||||
});
|
||||
});
|
||||
|
||||
describe("history 页码重置", () => {
|
||||
test("时间变化时 historyPage 应重置为 1", () => {
|
||||
const previousPage = 3;
|
||||
const resetPage = 1;
|
||||
expect(previousPage).not.toBe(resetPage);
|
||||
expect(resetPage).toBe(1);
|
||||
});
|
||||
});
|
||||
52
tests/web/test-utils.tsx
Normal file
52
tests/web/test-utils.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mock } from "bun:test";
|
||||
|
||||
// Note: jsdom and polyfills are now set up in tests/setup.ts
|
||||
// This file only contains component-specific mocks
|
||||
|
||||
// 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,
|
||||
}));
|
||||
|
||||
// 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 pass =
|
||||
element?.textContent !== null &&
|
||||
(typeof text === "string" ? element.textContent.includes(text) : text.test(element.textContent));
|
||||
return {
|
||||
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
|
||||
pass,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user