1
0

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:
2026-05-15 18:31:33 +08:00
parent 2b08f81a0d
commit 8793fbd786
24 changed files with 1392 additions and 143 deletions

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View File

@@ -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)");
});
});

View File

@@ -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
View 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,
};
},
};