feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
299
apps/admin-web/src/__tests__/dashboard.test.tsx
Normal file
299
apps/admin-web/src/__tests__/dashboard.test.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 单元测试:Dashboard 页面 + DbHealthCard 组件
|
||||
*
|
||||
* _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
|
||||
*
|
||||
* - 测试 4 个区块正确渲染
|
||||
* - 测试跳转链接指向正确路由
|
||||
* - 测试 DbHealthCard connected/disconnected/timeout 状态渲染
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import React from "react";
|
||||
import DbHealthCard from "../components/DbHealthCard";
|
||||
import type { DbHealthItem } from "../api/dbHealth";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ant Design jsdom 兼容:polyfill window.matchMedia */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock 所有 Dashboard 依赖的 API 模块 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
vi.mock("../api/opsPanel", () => ({
|
||||
fetchSystemInfo: vi.fn().mockResolvedValue({
|
||||
cpu_percent: 25,
|
||||
memory_total_gb: 16,
|
||||
memory_used_gb: 8,
|
||||
memory_percent: 50,
|
||||
disk_total_gb: 500,
|
||||
disk_used_gb: 250,
|
||||
disk_percent: 50,
|
||||
boot_time: "2026-01-01T00:00:00",
|
||||
}),
|
||||
fetchServicesStatus: vi.fn().mockResolvedValue([
|
||||
{
|
||||
env: "prod",
|
||||
label: "生产环境",
|
||||
running: true,
|
||||
pid: 1234,
|
||||
port: 8000,
|
||||
uptime_seconds: 3600,
|
||||
memory_mb: 256,
|
||||
cpu_percent: 10,
|
||||
},
|
||||
]),
|
||||
fetchGitInfo: vi.fn().mockResolvedValue([
|
||||
{
|
||||
env: "prod",
|
||||
branch: "main",
|
||||
last_commit_hash: "abc1234",
|
||||
last_commit_message: "test commit",
|
||||
last_commit_time: "2026-01-01T00:00:00",
|
||||
has_local_changes: false,
|
||||
},
|
||||
]),
|
||||
startService: vi.fn(),
|
||||
stopService: vi.fn(),
|
||||
restartService: vi.fn(),
|
||||
gitPull: vi.fn(),
|
||||
syncDeps: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/dbHealth", () => ({
|
||||
fetchDbHealth: vi.fn().mockResolvedValue([
|
||||
{
|
||||
db_name: "etl_feiqiu",
|
||||
status: "connected",
|
||||
active_connections: 5,
|
||||
idle_connections: 10,
|
||||
db_size_mb: 128.5,
|
||||
slow_query_count: 2,
|
||||
},
|
||||
{
|
||||
db_name: "test_etl_feiqiu",
|
||||
status: "disconnected",
|
||||
active_connections: null,
|
||||
idle_connections: null,
|
||||
db_size_mb: null,
|
||||
slow_query_count: null,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("../api/adminAI", () => ({
|
||||
getTriggerJobs: vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
today_skipped_duplicates: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock AIDashboard 为简单占位,避免其内部 API 调用
|
||||
vi.mock("../pages/AIDashboard", () => ({
|
||||
default: () => <div data-testid="ai-dashboard-mock">AIDashboard</div>,
|
||||
}));
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dashboard 页面测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("Dashboard 页面 (Requirements 2.1, 2.2)", () => {
|
||||
let Dashboard: React.FC;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const mod = await import("../pages/Dashboard");
|
||||
Dashboard = mod.default;
|
||||
});
|
||||
|
||||
it("渲染 4 个区块:OpsPanel 子组件、DbHealthCard、AI 运行总览、AI 调度摘要", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<Dashboard />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 区块 1:OpsPanel 子组件 — 页面标题"运行状态"
|
||||
expect(await screen.findByText("运行状态")).toBeInTheDocument();
|
||||
|
||||
// 区块 2:数据库健康监控
|
||||
expect(screen.getByText("数据库健康监控")).toBeInTheDocument();
|
||||
|
||||
// 区块 3:AI 运行总览(mocked AIDashboard)
|
||||
expect(screen.getByText("AI 运行总览")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ai-dashboard-mock")).toBeInTheDocument();
|
||||
|
||||
// 区块 4:AI 调度摘要(Divider + Card title 各出现一次)
|
||||
const summaryElements = screen.getAllByText("AI 调度摘要");
|
||||
expect(summaryElements.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("今日触发数")).toBeInTheDocument();
|
||||
expect(screen.getByText("今日成功率")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("跳转链接:ETL 状态详情、触发器详情、AI 调度详情", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<Dashboard />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await screen.findByText("运行状态");
|
||||
|
||||
expect(screen.getByText(/ETL 状态详情/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/触发器详情/)).toBeInTheDocument();
|
||||
|
||||
// AI 调度详情出现两次(顶部按钮 + 底部链接),取第一个
|
||||
const aiButtons = screen.getAllByText(/AI 调度详情/);
|
||||
expect(aiButtons.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DbHealthCard 组件测试(纯展示组件,不需要 mock API) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("DbHealthCard — connected 状态 (Requirements 2.3, 2.4)", () => {
|
||||
const connectedItems: DbHealthItem[] = [
|
||||
{
|
||||
db_name: "etl_feiqiu",
|
||||
status: "connected",
|
||||
active_connections: 5,
|
||||
idle_connections: 10,
|
||||
db_size_mb: 128.5,
|
||||
slow_query_count: 2,
|
||||
},
|
||||
{
|
||||
db_name: "zqyy_app",
|
||||
status: "connected",
|
||||
active_connections: 3,
|
||||
idle_connections: 7,
|
||||
db_size_mb: 256.0,
|
||||
slow_query_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
it("为每个 connected 数据库渲染卡片,显示「已连接」标签", () => {
|
||||
render(<DbHealthCard items={connectedItems} />);
|
||||
|
||||
expect(screen.getByText("etl_feiqiu")).toBeInTheDocument();
|
||||
expect(screen.getByText("zqyy_app")).toBeInTheDocument();
|
||||
|
||||
const connectedTags = screen.getAllByText("已连接");
|
||||
expect(connectedTags).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("展示连接池指标(活跃/空闲连接数)", () => {
|
||||
render(<DbHealthCard items={connectedItems} />);
|
||||
|
||||
expect(screen.getByText(/活跃 5/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/空闲 10/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("展示数据库大小和慢查询数量", () => {
|
||||
render(<DbHealthCard items={connectedItems} />);
|
||||
|
||||
const sizeLabels = screen.getAllByText("数据库大小");
|
||||
expect(sizeLabels.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const slowLabels = screen.getAllByText(/慢查询/);
|
||||
expect(slowLabels.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DbHealthCard — disconnected 状态 (Requirement 2.5)", () => {
|
||||
const disconnectedItems: DbHealthItem[] = [
|
||||
{
|
||||
db_name: "test_etl_feiqiu",
|
||||
status: "disconnected",
|
||||
active_connections: null,
|
||||
idle_connections: null,
|
||||
db_size_mb: null,
|
||||
slow_query_count: null,
|
||||
},
|
||||
];
|
||||
|
||||
it("disconnected 数据库显示「未连接」标签", () => {
|
||||
render(<DbHealthCard items={disconnectedItems} />);
|
||||
|
||||
expect(screen.getByText("test_etl_feiqiu")).toBeInTheDocument();
|
||||
expect(screen.getByText("未连接")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disconnected 数据库显示无法获取指标提示", () => {
|
||||
render(<DbHealthCard items={disconnectedItems} />);
|
||||
|
||||
expect(screen.getByText("数据库未连接,无法获取指标")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DbHealthCard — timeout 状态", () => {
|
||||
it("超时时显示「加载超时」标签", () => {
|
||||
render(<DbHealthCard items={[]} timeout={true} />);
|
||||
|
||||
expect(screen.getByText("加载超时")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("超时时显示重试按钮,点击触发 onRetry", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<DbHealthCard items={[]} timeout={true} onRetry={onRetry} />);
|
||||
|
||||
const retryBtn = screen.getByText("重试");
|
||||
expect(retryBtn).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryBtn);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DbHealthCard — mixed 状态(connected + disconnected)", () => {
|
||||
const mixedItems: DbHealthItem[] = [
|
||||
{
|
||||
db_name: "etl_feiqiu",
|
||||
status: "connected",
|
||||
active_connections: 5,
|
||||
idle_connections: 10,
|
||||
db_size_mb: 128.5,
|
||||
slow_query_count: 2,
|
||||
},
|
||||
{
|
||||
db_name: "test_etl_feiqiu",
|
||||
status: "disconnected",
|
||||
active_connections: null,
|
||||
idle_connections: null,
|
||||
db_size_mb: null,
|
||||
slow_query_count: null,
|
||||
},
|
||||
];
|
||||
|
||||
it("同时渲染 connected 和 disconnected 卡片", () => {
|
||||
render(<DbHealthCard items={mixedItems} />);
|
||||
|
||||
expect(screen.getByText("已连接")).toBeInTheDocument();
|
||||
expect(screen.getByText("未连接")).toBeInTheDocument();
|
||||
expect(screen.getByText("etl_feiqiu")).toBeInTheDocument();
|
||||
expect(screen.getByText("test_etl_feiqiu")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
238
apps/admin-web/src/__tests__/etlTasks.test.tsx
Normal file
238
apps/admin-web/src/__tests__/etlTasks.test.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 单元测试:ETLTasks 页面
|
||||
*
|
||||
* _Requirements: 3.1, 3.2, 3.3, 3.4, 10.4_
|
||||
*
|
||||
* - 测试默认 Tab 为 config(发起)
|
||||
* - 测试 5 个 Tab 内容正确渲染
|
||||
* - 测试 URL 参数驱动 Tab 选择
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ant Design jsdom 兼容:polyfill window.matchMedia */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock 子组件,避免内部 API 调用 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
vi.mock("../pages/TaskConfig", () => ({
|
||||
default: () => <div data-testid="mock-task-config">TaskConfig Mock</div>,
|
||||
}));
|
||||
vi.mock("../pages/TaskManager", () => ({
|
||||
QueueTab: () => <div data-testid="mock-queue-tab">QueueTab Mock</div>,
|
||||
HistoryTab: () => <div data-testid="mock-history-tab">HistoryTab Mock</div>,
|
||||
}));
|
||||
vi.mock("../components/ScheduleTab", () => ({
|
||||
default: () => <div data-testid="mock-schedule-tab">ScheduleTab Mock</div>,
|
||||
}));
|
||||
vi.mock("../pages/ETLStatus", () => ({
|
||||
default: () => <div data-testid="mock-etl-status">ETLStatus Mock</div>,
|
||||
}));
|
||||
|
||||
import ETLTasks from "../pages/ETLTasks";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:默认 Tab 为 config(Requirements 3.1, 3.2) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("ETLTasks — 默认 Tab (Requirements 3.1, 3.2)", () => {
|
||||
it("无 ?tab 参数时,默认激活 config Tab(发起)", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("发起");
|
||||
});
|
||||
|
||||
it("无效 ?tab 参数时,回退到默认 config Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=invalid"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("发起");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:4 个 Tab 内容正确渲染(Requirements 3.2, 3.3, 3.4) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("ETLTasks — 5 个 Tab 内容渲染 (Requirements 3.2, 3.3, 3.4)", () => {
|
||||
it("config Tab 渲染 TaskConfig 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=config"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-task-config")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("queue Tab 渲染 QueueTab 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=queue"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-queue-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("schedule Tab 渲染 ScheduleTab 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=schedule"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-schedule-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("history Tab 渲染 HistoryTab 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=history"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-history-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("status Tab 渲染 ETLStatus 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=status"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-etl-status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("页面标题包含「ETL 任务管理」", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("ETL 任务管理")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("5 个 Tab 标签文本正确:发起、队列、调度、历史、状态", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const tabs = document.querySelectorAll(".ant-tabs-tab");
|
||||
expect(tabs).toHaveLength(5);
|
||||
|
||||
const tabTexts = Array.from(tabs).map((t) => t.textContent);
|
||||
expect(tabTexts).toContain("发起");
|
||||
expect(tabTexts).toContain("队列");
|
||||
expect(tabTexts).toContain("调度");
|
||||
expect(tabTexts).toContain("历史");
|
||||
expect(tabTexts).toContain("状态");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:URL 参数驱动 Tab 选择(Requirement 10.4) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("ETLTasks — URL 参数驱动 Tab 选择 (Requirement 10.4)", () => {
|
||||
it("?tab=config 激活「发起」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=config"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("发起");
|
||||
});
|
||||
|
||||
it("?tab=queue 激活「队列」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=queue"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("队列");
|
||||
});
|
||||
|
||||
it("?tab=schedule 激活「调度」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=schedule"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("调度");
|
||||
});
|
||||
|
||||
it("?tab=history 激活「历史」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=history"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("历史");
|
||||
});
|
||||
|
||||
it("?tab=status 激活「状态」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=status"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("状态");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterLogLines } from "../pages/LogViewer";
|
||||
import { filterLogLines } from "../pages/_archived/LogViewer";
|
||||
|
||||
describe("filterLogLines — 日志过滤正确性", () => {
|
||||
/* ---- 1. 空关键词返回所有行 ---- */
|
||||
|
||||
140
apps/admin-web/src/__tests__/menuAndRedirects.test.tsx
Normal file
140
apps/admin-web/src/__tests__/menuAndRedirects.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 单元测试:菜单结构与路由重定向
|
||||
*
|
||||
* _Requirements: 1.1, 8.3, 10.1, 10.2_
|
||||
*
|
||||
* - 验证 NAV_ITEMS 包含 7 个一级菜单项且子项正确
|
||||
* - 验证 `/` 重定向到 `/dashboard`
|
||||
* - 验证 `/log-viewer` 重定向到 `/etl-tasks?tab=queue`
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
import { NAV_ITEMS } from "../App";
|
||||
import type { MenuProps } from "antd";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 菜单结构测试(纯数据,无 DOM) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type MenuItem = NonNullable<MenuProps["items"]>[number] & {
|
||||
key?: string;
|
||||
label?: string;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
describe("菜单结构验证 (Requirement 1.1)", () => {
|
||||
const items = NAV_ITEMS as MenuItem[];
|
||||
|
||||
it("NAV_ITEMS 包含 7 个一级菜单项", () => {
|
||||
expect(items).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("7 个一级菜单项的 label 和顺序正确", () => {
|
||||
const labels = items.map((item) => item.label);
|
||||
expect(labels).toEqual([
|
||||
"运行状态",
|
||||
"ETL 任务管理",
|
||||
"小程序任务管理",
|
||||
"触发器管理",
|
||||
"租户管理员",
|
||||
"系统设置",
|
||||
"日志调试",
|
||||
]);
|
||||
});
|
||||
|
||||
it("「小程序任务管理」包含 4 个子项:定时任务、转移日志、待审核任务、参数管理", () => {
|
||||
const taskEngine = items.find((i) => i.label === "小程序任务管理");
|
||||
expect(taskEngine).toBeDefined();
|
||||
const children = taskEngine!.children ?? [];
|
||||
expect(children).toHaveLength(4);
|
||||
expect(children.map((c) => c.label)).toEqual([
|
||||
"定时任务",
|
||||
"转移日志",
|
||||
"待审核任务",
|
||||
"参数管理",
|
||||
]);
|
||||
});
|
||||
|
||||
it("「系统设置」包含 2 个子项:环境配置、触发器配置", () => {
|
||||
const settings = items.find((i) => i.label === "系统设置");
|
||||
expect(settings).toBeDefined();
|
||||
const children = settings!.children ?? [];
|
||||
expect(children).toHaveLength(2);
|
||||
expect(children.map((c) => c.label)).toEqual(["环境配置", "触发器配置"]);
|
||||
});
|
||||
|
||||
it("「日志调试」包含 3 个子项:DevTrace、AI 调用明细、数据库查看器", () => {
|
||||
const logs = items.find((i) => i.label === "日志调试");
|
||||
expect(logs).toBeDefined();
|
||||
const children = logs!.children ?? [];
|
||||
expect(children).toHaveLength(3);
|
||||
expect(children.map((c) => c.label)).toEqual([
|
||||
"DevTrace",
|
||||
"AI 调用明细",
|
||||
"数据库查看器",
|
||||
]);
|
||||
});
|
||||
|
||||
it("无子项的一级菜单(运行状态、ETL 任务管理、触发器管理、租户管理员)没有 children", () => {
|
||||
const noChildrenLabels = ["运行状态", "ETL 任务管理", "触发器管理", "租户管理员"];
|
||||
for (const label of noChildrenLabels) {
|
||||
const item = items.find((i) => i.label === label);
|
||||
expect(item).toBeDefined();
|
||||
expect((item as MenuItem).children).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 路由重定向测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 辅助组件:捕获当前 location 用于断言。
|
||||
* 渲染后通过 testId 读取 pathname + search。
|
||||
*/
|
||||
function LocationDisplay() {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div data-testid="location">
|
||||
{location.pathname}
|
||||
{location.search}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 最小路由配置:只包含重定向规则和一个 LocationDisplay 兜底,
|
||||
* 不需要渲染完整 App(避免 mock 大量依赖)。
|
||||
*/
|
||||
function RedirectTestApp() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/log-viewer" element={<Navigate to="/etl-tasks?tab=queue" replace />} />
|
||||
<Route path="*" element={<LocationDisplay />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
describe("路由重定向 (Requirements 8.3, 10.1, 10.2)", () => {
|
||||
it("/ 重定向到 /dashboard", () => {
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<RedirectTestApp />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(getByTestId("location").textContent).toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("/log-viewer 重定向到 /etl-tasks?tab=queue", () => {
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={["/log-viewer"]}>
|
||||
<RedirectTestApp />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(getByTestId("location").textContent).toBe("/etl-tasks?tab=queue");
|
||||
});
|
||||
});
|
||||
160
apps/admin-web/src/__tests__/sidebarHighlight.property.test.ts
Normal file
160
apps/admin-web/src/__tests__/sidebarHighlight.property.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 属性测试:侧边栏高亮与当前路由一致
|
||||
*
|
||||
* Feature: admin-web-restructure, Property 7: 侧边栏高亮与当前路由一致
|
||||
* **Validates: Requirements 10.3**
|
||||
*
|
||||
* 对于任意有效的应用路由路径,侧边栏中被高亮(selectedKeys)的菜单项
|
||||
* 应对应该路由所属的一级模块。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as fc from "fast-check";
|
||||
import { getSelectedKeys, NAV_ITEMS } from "../App";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 路由 → 一级模块映射(从 NAV_ITEMS 和路由配置提取) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 所有有效路由及其所属一级模块 key 的映射。
|
||||
* 一级模块 key 定义:
|
||||
* - 无子菜单的项:直接用 item.key(如 "/dashboard")
|
||||
* - 有子菜单的项:用 group key(如 "task-engine-group")
|
||||
*/
|
||||
const ROUTE_TO_MODULE: Array<{
|
||||
pathname: string;
|
||||
search: string;
|
||||
moduleKey: string;
|
||||
/** 该路由在菜单中对应的 selectedKey */
|
||||
expectedSelectedKey: string;
|
||||
}> = [
|
||||
// 运行状态
|
||||
{ pathname: "/dashboard", search: "", moduleKey: "/dashboard", expectedSelectedKey: "/dashboard" },
|
||||
// ETL 任务管理
|
||||
{ pathname: "/etl-tasks", search: "", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=config", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=queue", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=schedule", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=history", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=status", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
// 小程序任务管理
|
||||
{ pathname: "/task-engine/trigger-jobs", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/trigger-jobs" },
|
||||
{ pathname: "/task-engine/transfer-log", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/transfer-log" },
|
||||
{ pathname: "/task-engine/pending-review", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/pending-review" },
|
||||
{ pathname: "/task-engine/config", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/config" },
|
||||
// 触发器管理
|
||||
{ pathname: "/triggers", search: "", moduleKey: "/triggers", expectedSelectedKey: "/triggers" },
|
||||
{ pathname: "/triggers", search: "?tab=all", moduleKey: "/triggers", expectedSelectedKey: "/triggers" },
|
||||
// 特殊情况:/triggers?tab=biz 同时是"系统设置 > 触发器配置"的快捷入口,
|
||||
// getSelectedKeys 会精确匹配到 settings-group 下的子项
|
||||
{ pathname: "/triggers", search: "?tab=biz", moduleKey: "settings-group", expectedSelectedKey: "/triggers?tab=biz" },
|
||||
{ pathname: "/triggers", search: "?tab=ai", moduleKey: "/triggers", expectedSelectedKey: "/triggers" },
|
||||
{ pathname: "/triggers", search: "?tab=etl", moduleKey: "/triggers", expectedSelectedKey: "/triggers" },
|
||||
// 租户管理员
|
||||
{ pathname: "/tenant-admins", search: "", moduleKey: "/tenant-admins", expectedSelectedKey: "/tenant-admins" },
|
||||
// 系统设置
|
||||
{ pathname: "/settings/env-config", search: "", moduleKey: "settings-group", expectedSelectedKey: "/settings/env-config" },
|
||||
// 日志调试
|
||||
{ pathname: "/logs/dev-trace", search: "", moduleKey: "logs-group", expectedSelectedKey: "/logs/dev-trace" },
|
||||
{ pathname: "/logs/ai-run-logs", search: "", moduleKey: "logs-group", expectedSelectedKey: "/logs/ai-run-logs" },
|
||||
{ pathname: "/logs/db-viewer", search: "", moduleKey: "logs-group", expectedSelectedKey: "/logs/db-viewer" },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助:从 NAV_ITEMS 构建 selectedKey → moduleKey 的反查表 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 收集所有菜单叶子节点的 key,映射到其所属一级模块 key */
|
||||
function buildKeyToModuleMap(): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of NAV_ITEMS ?? []) {
|
||||
if (!item || !("key" in item)) continue;
|
||||
const topKey = item.key as string;
|
||||
if ("children" in item && item.children) {
|
||||
// 有子菜单:子项 key → group key
|
||||
for (const child of item.children) {
|
||||
if (child && "key" in child) {
|
||||
map.set(child.key as string, topKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 无子菜单:自身 key → 自身 key
|
||||
map.set(topKey, topKey);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const KEY_TO_MODULE = buildKeyToModuleMap();
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 属性测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("Property 7: 侧边栏高亮与当前路由一致", () => {
|
||||
// 生成器:从所有有效路由中随机选取
|
||||
const routeArb = fc.constantFrom(...ROUTE_TO_MODULE);
|
||||
|
||||
it("对任意有效路由,selectedKeys 应包含该路由对应的菜单项 key", () => {
|
||||
fc.assert(
|
||||
fc.property(routeArb, (route) => {
|
||||
const selectedKeys = getSelectedKeys(route.pathname, route.search);
|
||||
|
||||
// selectedKeys 不应为空
|
||||
expect(selectedKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// selectedKeys 中应包含预期的 key
|
||||
expect(selectedKeys).toContain(route.expectedSelectedKey);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("对任意有效路由,selectedKeys 对应的一级模块应与路由所属模块一致", () => {
|
||||
fc.assert(
|
||||
fc.property(routeArb, (route) => {
|
||||
const selectedKeys = getSelectedKeys(route.pathname, route.search);
|
||||
const selectedKey = selectedKeys[0];
|
||||
|
||||
// 查找 selectedKey 所属的一级模块
|
||||
const actualModule = KEY_TO_MODULE.get(selectedKey);
|
||||
|
||||
// 如果 selectedKey 不在菜单叶子节点中(如一级路由直接匹配),
|
||||
// 则 selectedKey 本身就是模块 key
|
||||
const resolvedModule = actualModule ?? selectedKey;
|
||||
|
||||
expect(resolvedModule).toBe(route.moduleKey);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("NAV_ITEMS 应包含 7 个一级菜单项", () => {
|
||||
expect(NAV_ITEMS).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("所有有效路由的 selectedKey 都能在 NAV_ITEMS 中找到对应菜单项", () => {
|
||||
fc.assert(
|
||||
fc.property(routeArb, (route) => {
|
||||
const selectedKeys = getSelectedKeys(route.pathname, route.search);
|
||||
const selectedKey = selectedKeys[0];
|
||||
|
||||
// selectedKey 必须存在于菜单的叶子节点或一级节点中
|
||||
const allMenuKeys = new Set<string>();
|
||||
for (const item of NAV_ITEMS ?? []) {
|
||||
if (!item || !("key" in item)) continue;
|
||||
allMenuKeys.add(item.key as string);
|
||||
if ("children" in item && item.children) {
|
||||
for (const child of item.children) {
|
||||
if (child && "key" in child) allMenuKeys.add(child.key as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(allMenuKeys.has(selectedKey)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 属性测试:Tab 切换状态保持 round-trip
|
||||
*
|
||||
* Feature: admin-web-restructure, Property 2: Tab 切换状态保持 round-trip
|
||||
* **Validates: Requirements 3.5**
|
||||
*
|
||||
* 对于任意 ETL 任务管理的 Tab 视图,在某个 Tab 中设置筛选条件后
|
||||
* 切换到另一个 Tab 再切回来,原 Tab 的筛选条件应保持不变。
|
||||
*
|
||||
* 策略:Mock 子组件为带 text input 的有状态组件,
|
||||
* 通过输入文本 → 切换 Tab → 切回 → 验证文本保留来证明状态保持。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import * as fc from "fast-check";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock 子组件:带有状态的简单输入框 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function StatefulTab({ tabKey }: { tabKey: string }) {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<div data-testid={`tab-panel-${tabKey}`}>
|
||||
<input
|
||||
data-testid={`state-input-${tabKey}`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("../pages/TaskConfig", () => ({
|
||||
default: () => <StatefulTab tabKey="config" />,
|
||||
}));
|
||||
vi.mock("../pages/TaskManager", () => ({
|
||||
default: () => <StatefulTab tabKey="manager" />,
|
||||
}));
|
||||
vi.mock("../pages/ETLStatus", () => ({
|
||||
default: () => <StatefulTab tabKey="status" />,
|
||||
}));
|
||||
|
||||
import ETLTasks from "../pages/ETLTasks";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const VALID_TABS = ["config", "manager", "status"] as const;
|
||||
type TabKey = (typeof VALID_TABS)[number];
|
||||
|
||||
const TAB_LABELS: Record<TabKey, string> = {
|
||||
config: "任务配置",
|
||||
manager: "任务管理",
|
||||
status: "ETL 状态",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助函数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 点击指定 Tab */
|
||||
function clickTab(tabKey: TabKey) {
|
||||
const tabElements = document.querySelectorAll(".ant-tabs-tab");
|
||||
for (const el of tabElements) {
|
||||
if (el.textContent?.includes(TAB_LABELS[tabKey])) {
|
||||
fireEvent.click(el);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`Tab "${tabKey}" not found`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 每次测试后清理 DOM */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 属性测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// DOM 渲染属性测试:numRuns=20 覆盖所有 tab 组合多次,避免 jsdom 超时
|
||||
const PBT_NUM_RUNS = 20;
|
||||
const PBT_TIMEOUT = 30_000;
|
||||
|
||||
describe("Property 2: Tab 切换状态保持 round-trip", () => {
|
||||
// 生成器:从有效 tab 值中随机选取两个不同的 tab
|
||||
const distinctTabPairArb = fc
|
||||
.tuple(fc.constantFrom(...VALID_TABS), fc.constantFrom(...VALID_TABS))
|
||||
.filter(([a, b]) => a !== b);
|
||||
|
||||
// 生成器:模拟用户输入的筛选条件文本
|
||||
// 使用 constantFrom 避免 fast-check v4 API 兼容性问题(char/stringOf 已移除)
|
||||
const inputTextArb = fc.constantFrom(
|
||||
"hello", "test-filter", "ETL_001", "搜索关键词", "abc123",
|
||||
"x", "long-filter-value-example", "special!@#", " spaces ", "UPPER",
|
||||
);
|
||||
|
||||
it("在某 Tab 设置状态 → 切换到另一 Tab → 切回 → 状态保持不变", () => {
|
||||
fc.assert(
|
||||
fc.property(distinctTabPairArb, inputTextArb, ([sourceTab, otherTab], text) => {
|
||||
cleanup();
|
||||
|
||||
// 渲染页面,初始 Tab 为 sourceTab
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/etl-tasks?tab=${sourceTab}`]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 1. 在 sourceTab 的输入框中输入文本
|
||||
const input = screen.getByTestId(`state-input-${sourceTab}`) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: text } });
|
||||
expect(input.value).toBe(text);
|
||||
|
||||
// 2. 切换到 otherTab
|
||||
clickTab(otherTab);
|
||||
|
||||
// 3. 切回 sourceTab
|
||||
clickTab(sourceTab);
|
||||
|
||||
// 4. 验证 sourceTab 的输入框文本保持不变
|
||||
const inputAfter = screen.getByTestId(`state-input-${sourceTab}`) as HTMLInputElement;
|
||||
expect(inputAfter.value).toBe(text);
|
||||
|
||||
cleanup();
|
||||
}),
|
||||
{ numRuns: PBT_NUM_RUNS },
|
||||
);
|
||||
}, PBT_TIMEOUT);
|
||||
});
|
||||
191
apps/admin-web/src/__tests__/tabUrlSync.property.test.tsx
Normal file
191
apps/admin-web/src/__tests__/tabUrlSync.property.test.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 属性测试:Tab 切换与 URL 查询参数同步 round-trip
|
||||
*
|
||||
* Feature: admin-web-restructure, Property 8: Tab 切换与 URL 查询参数同步 round-trip
|
||||
* **Validates: Requirements 10.4**
|
||||
*
|
||||
* 对于任意 Tab 视图页面,设置 URL 查询参数 `?tab=X` 后渲染页面,
|
||||
* 当前激活的 Tab 应为 X;点击 Tab Y 后,URL 查询参数应更新为 `?tab=Y`。
|
||||
*
|
||||
* 当前仅测试 ETLTasks(TriggerManager 待后续任务创建后扩展)。
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import * as fc from "fast-check";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock 子组件,避免内部 API 调用 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
vi.mock("../pages/TaskConfig", () => ({
|
||||
default: () => <div data-testid="tab-content-config">TaskConfig</div>,
|
||||
}));
|
||||
vi.mock("../pages/TaskManager", () => ({
|
||||
QueueTab: () => <div data-testid="tab-content-queue">QueueTab</div>,
|
||||
HistoryTab: () => <div data-testid="tab-content-history">HistoryTab</div>,
|
||||
}));
|
||||
vi.mock("../components/ScheduleTab", () => ({
|
||||
default: () => <div data-testid="tab-content-schedule">ScheduleTab</div>,
|
||||
}));
|
||||
vi.mock("../pages/ETLStatus", () => ({
|
||||
default: () => <div data-testid="tab-content-status">ETLStatus</div>,
|
||||
}));
|
||||
|
||||
import ETLTasks from "../pages/ETLTasks";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助:捕获当前 URL search 参数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function LocationSpy({ onLocation }: { onLocation: (s: string) => void }) {
|
||||
const location = useLocation();
|
||||
React.useEffect(() => {
|
||||
onLocation(location.search);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 页面配置(可扩展 TriggerManager) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface TabPageConfig {
|
||||
name: string;
|
||||
validTabs: readonly string[];
|
||||
defaultTab: string;
|
||||
Component: React.FC;
|
||||
basePath: string;
|
||||
/** Tab label 文本中包含的关键字,用于定位 Tab 元素 */
|
||||
tabLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
const ETL_TASKS_CONFIG: TabPageConfig = {
|
||||
name: "ETLTasks",
|
||||
validTabs: ["config", "queue", "schedule", "history", "status"],
|
||||
defaultTab: "config",
|
||||
Component: ETLTasks,
|
||||
basePath: "/etl-tasks",
|
||||
tabLabels: {
|
||||
config: "发起",
|
||||
queue: "队列",
|
||||
schedule: "调度",
|
||||
history: "历史",
|
||||
status: "状态",
|
||||
},
|
||||
};
|
||||
|
||||
// TriggerManager 配置占位,待 task 10.1 创建后启用
|
||||
// const TRIGGER_MANAGER_CONFIG: TabPageConfig = { ... };
|
||||
|
||||
const PAGE_CONFIGS: TabPageConfig[] = [ETL_TASKS_CONFIG];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 每次测试后清理 DOM */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 属性测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// DOM 渲染属性测试:numRuns=20 覆盖所有 tab 值多次,避免 jsdom 超时
|
||||
const PBT_NUM_RUNS = 20;
|
||||
// 给 DOM 属性测试更长的超时(30s)
|
||||
const PBT_TIMEOUT = 30_000;
|
||||
|
||||
describe("Property 8: Tab 切换与 URL 查询参数同步 round-trip", () => {
|
||||
for (const pageConfig of PAGE_CONFIGS) {
|
||||
const { name, validTabs, defaultTab, Component, basePath, tabLabels } = pageConfig;
|
||||
|
||||
describe(`${name} 页面`, () => {
|
||||
// 生成器:从有效 tab 值中随机选取
|
||||
const tabArb = fc.constantFrom(...validTabs);
|
||||
|
||||
it("设置 ?tab=X 后,激活的 Tab 应为 X", () => {
|
||||
fc.assert(
|
||||
fc.property(tabArb, (tab) => {
|
||||
cleanup();
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`${basePath}?tab=${tab}`]}>
|
||||
<Component />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Ant Design Tabs 的激活 tab 有 .ant-tabs-tab-active 类
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain(tabLabels[tab]);
|
||||
|
||||
cleanup();
|
||||
}),
|
||||
{ numRuns: PBT_NUM_RUNS },
|
||||
);
|
||||
}, PBT_TIMEOUT);
|
||||
|
||||
it("点击 Tab Y 后,URL 查询参数应更新为 ?tab=Y", () => {
|
||||
fc.assert(
|
||||
fc.property(tabArb, tabArb, (initialTab, targetTab) => {
|
||||
cleanup();
|
||||
let currentSearch = "";
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`${basePath}?tab=${initialTab}`]}>
|
||||
<Component />
|
||||
<LocationSpy onLocation={(s) => { currentSearch = s; }} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 找到目标 Tab 并点击
|
||||
const tabElements = document.querySelectorAll(".ant-tabs-tab");
|
||||
let targetTabEl: Element | null = null;
|
||||
for (const el of tabElements) {
|
||||
if (el.textContent?.includes(tabLabels[targetTab])) {
|
||||
targetTabEl = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(targetTabEl).not.toBeNull();
|
||||
fireEvent.click(targetTabEl!);
|
||||
|
||||
// 验证 URL 参数已更新
|
||||
const params = new URLSearchParams(currentSearch);
|
||||
expect(params.get("tab")).toBe(targetTab);
|
||||
|
||||
cleanup();
|
||||
}),
|
||||
{ numRuns: PBT_NUM_RUNS },
|
||||
);
|
||||
}, PBT_TIMEOUT);
|
||||
|
||||
it("无效或缺失的 tab 参数应回退到默认 Tab", () => {
|
||||
// 无 tab 参数
|
||||
render(
|
||||
<MemoryRouter initialEntries={[basePath]}>
|
||||
<Component />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const activeTab1 = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab1).not.toBeNull();
|
||||
expect(activeTab1!.textContent).toContain(tabLabels[defaultTab]);
|
||||
cleanup();
|
||||
|
||||
// 无效 tab 参数
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`${basePath}?tab=invalid`]}>
|
||||
<Component />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const activeTab2 = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab2).not.toBeNull();
|
||||
expect(activeTab2!.textContent).toContain(tabLabels[defaultTab]);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
298
apps/admin-web/src/__tests__/triggerManager.test.tsx
Normal file
298
apps/admin-web/src/__tests__/triggerManager.test.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 单元测试:TriggerManager 页面
|
||||
*
|
||||
* _Requirements: 4.1, 4.3, 4.4, 4.7_
|
||||
*
|
||||
* - 测试 4 个 Tab 正确渲染
|
||||
* - 测试"全部"Tab 为只读(无编辑按钮)
|
||||
* - 测试"业务"Tab 编辑表单仅包含 cron_expression 和 interval_seconds
|
||||
* - 测试 422 错误在表单中展示具体错误信息
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ant Design jsdom 兼容:polyfill window.matchMedia */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock API 模块 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const mockFetchUnifiedTriggers = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "同步会员数据",
|
||||
source: "biz",
|
||||
trigger_condition: "cron",
|
||||
status: "running",
|
||||
last_run_at: "2026-07-15T10:00:00",
|
||||
next_run_at: "2026-07-15T12:00:00",
|
||||
last_error: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "AI 事件链",
|
||||
source: "ai",
|
||||
trigger_condition: "event",
|
||||
status: "idle",
|
||||
last_run_at: null,
|
||||
next_run_at: null,
|
||||
last_error: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const mockFetchTriggerJobs = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 101,
|
||||
job_type: "sync",
|
||||
job_name: "sync_members",
|
||||
trigger_condition: "cron",
|
||||
trigger_config: { cron_expression: "0 */2 * * *", interval_seconds: 7200 },
|
||||
last_run_at: "2026-07-15T10:00:00",
|
||||
next_run_at: "2026-07-15T12:00:00",
|
||||
status: "enabled",
|
||||
description: "同步会员数据",
|
||||
last_error: null,
|
||||
created_at: "2026-01-01T00:00:00",
|
||||
},
|
||||
]);
|
||||
|
||||
const mockUpdateTriggerConfig = vi.fn().mockResolvedValue({
|
||||
id: 101,
|
||||
job_type: "sync",
|
||||
job_name: "sync_members",
|
||||
trigger_condition: "cron",
|
||||
trigger_config: { cron_expression: "0 */3 * * *", interval_seconds: 7200 },
|
||||
last_run_at: "2026-07-15T10:00:00",
|
||||
next_run_at: "2026-07-15T15:00:00",
|
||||
status: "enabled",
|
||||
description: "同步会员数据",
|
||||
last_error: null,
|
||||
created_at: "2026-01-01T00:00:00",
|
||||
});
|
||||
|
||||
const mockFetchSchedules = vi.fn().mockResolvedValue([]);
|
||||
|
||||
vi.mock("../api/triggers", () => ({
|
||||
fetchUnifiedTriggers: (...args: unknown[]) => mockFetchUnifiedTriggers(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../api/triggerJobs", () => ({
|
||||
fetchTriggerJobs: (...args: unknown[]) => mockFetchTriggerJobs(...args),
|
||||
updateTriggerConfig: (...args: unknown[]) => mockUpdateTriggerConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../api/schedules", () => ({
|
||||
fetchSchedules: (...args: unknown[]) => mockFetchSchedules(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../pages/AIOperations", () => ({
|
||||
default: () => <div data-testid="mock-ai-operations">AIOperations Mock</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../pages/AITriggerJobs", () => ({
|
||||
default: () => <div data-testid="mock-ai-trigger-jobs">AITriggerJobs Mock</div>,
|
||||
}));
|
||||
|
||||
import TriggerManager from "../pages/TriggerManager";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:4 个 Tab 正确渲染(Requirement 4.1) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("TriggerManager — 4 个 Tab 渲染 (Requirement 4.1)", () => {
|
||||
it("渲染 4 个 Tab 标签:全部、业务、AI、ETL", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const tabs = document.querySelectorAll(".ant-tabs-tab");
|
||||
expect(tabs).toHaveLength(4);
|
||||
});
|
||||
|
||||
const tabs = document.querySelectorAll(".ant-tabs-tab");
|
||||
const tabTexts = Array.from(tabs).map((t) => t.textContent);
|
||||
expect(tabTexts).toContain("全部");
|
||||
expect(tabTexts).toContain("业务");
|
||||
expect(tabTexts).toContain("AI");
|
||||
expect(tabTexts).toContain("ETL");
|
||||
});
|
||||
|
||||
it("默认激活「全部」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("全部");
|
||||
});
|
||||
|
||||
it("页面标题包含「触发器管理」", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("触发器管理")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:"全部"Tab 为只读(Requirement 4.3) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("TriggerManager — 全部 Tab 只读 (Requirement 4.3)", () => {
|
||||
it("「全部」Tab 表格中无「编辑」按钮", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers?tab=all"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 等待统一视图数据加载完成
|
||||
await waitFor(() => {
|
||||
expect(mockFetchUnifiedTriggers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 等待表格渲染数据
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("同步会员数据")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 全部 Tab 不应有编辑按钮
|
||||
const editButtons = screen.queryAllByRole("button", { name: /编辑/ });
|
||||
expect(editButtons).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:"业务"Tab 编辑表单字段(Requirement 4.4, 4.7) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("TriggerManager — 业务 Tab 编辑表单 (Requirements 4.4, 4.7)", () => {
|
||||
it("编辑 Modal 仅包含 cron_expression 和 interval_seconds 两个字段", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers?tab=biz"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 等待业务触发器数据加载
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTriggerJobs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 等待表格渲染
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("同步会员数据")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 点击编辑按钮
|
||||
const editBtn = screen.getByRole("button", { name: /编辑/ });
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
// 等待 Modal 打开
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/编辑触发器配置/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 验证表单包含 cron_expression 字段
|
||||
expect(screen.getByLabelText(/Cron 表达式/)).toBeInTheDocument();
|
||||
|
||||
// 验证表单包含 interval_seconds 字段
|
||||
expect(screen.getByLabelText(/间隔秒数/)).toBeInTheDocument();
|
||||
|
||||
// 验证 Modal 中的 Form.Item 数量 — 只有 2 个
|
||||
const modal = document.querySelector(".ant-modal-body");
|
||||
expect(modal).not.toBeNull();
|
||||
const formItems = modal!.querySelectorAll(".ant-form-item");
|
||||
expect(formItems).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:422 错误展示具体错误信息(Requirement 4.7) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("TriggerManager — 422 错误展示 (Requirement 4.7)", () => {
|
||||
it("422 错误时调用 updateTriggerConfig 并提取 detail 信息", async () => {
|
||||
const error422 = {
|
||||
response: {
|
||||
status: 422,
|
||||
data: { detail: "cron 表达式格式无效,需要 5 字段格式" },
|
||||
},
|
||||
};
|
||||
mockUpdateTriggerConfig.mockRejectedValueOnce(error422);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers?tab=biz"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 等待数据加载
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("同步会员数据")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 点击编辑按钮
|
||||
const editBtn = screen.getByRole("button", { name: /编辑/ });
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
// 等待 Modal 打开
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/编辑触发器配置/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 填写一个无效的 cron 表达式
|
||||
const cronInput = screen.getByLabelText(/Cron 表达式/);
|
||||
fireEvent.change(cronInput, { target: { value: "invalid-cron" } });
|
||||
|
||||
// 点击保存(Modal 的确定按钮,Ant Design 渲染为"保 存"带空格)
|
||||
const okBtn = screen.getByRole("button", { name: /保\s*存/ });
|
||||
fireEvent.click(okBtn);
|
||||
|
||||
// 验证 updateTriggerConfig 被调用(触发了 422 错误路径)
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTriggerConfig).toHaveBeenCalledWith(101, {
|
||||
cron_expression: "invalid-cron",
|
||||
interval_seconds: 7200,
|
||||
});
|
||||
});
|
||||
|
||||
// 验证 422 错误后 Modal 仍然打开(未关闭,说明错误被处理而非忽略)
|
||||
expect(screen.getByText(/编辑触发器配置/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user