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:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View 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>,
);
// 区块 1OpsPanel 子组件 — 页面标题"运行状态"
expect(await screen.findByText("运行状态")).toBeInTheDocument();
// 区块 2数据库健康监控
expect(screen.getByText("数据库健康监控")).toBeInTheDocument();
// 区块 3AI 运行总览mocked AIDashboard
expect(screen.getByText("AI 运行总览")).toBeInTheDocument();
expect(screen.getByTestId("ai-dashboard-mock")).toBeInTheDocument();
// 区块 4AI 调度摘要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();
});
});

View 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 为 configRequirements 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("状态");
});
});

View File

@@ -8,7 +8,7 @@
*/
import { describe, it, expect } from "vitest";
import { filterLogLines } from "../pages/LogViewer";
import { filterLogLines } from "../pages/_archived/LogViewer";
describe("filterLogLines — 日志过滤正确性", () => {
/* ---- 1. 空关键词返回所有行 ---- */

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

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

View File

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

View 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`。
*
* 当前仅测试 ETLTasksTriggerManager 待后续任务创建后扩展)。
*/
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();
});
});
}
});

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