refactor: simplify workbench layout and browser tests

This commit is contained in:
2026-08-18 17:06:42 +08:00
parent bdf4436899
commit bdd5eff776
12 changed files with 448 additions and 201 deletions
+5 -1
View File
@@ -2,12 +2,16 @@ import { defineConfig, devices } from "@playwright/test";
const testPort = process.env.PLAYWRIGHT_PORT || "5191"; const testPort = process.env.PLAYWRIGHT_PORT || "5191";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`; const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`;
const runningInCi = Boolean(process.env.CI);
export default defineConfig({ export default defineConfig({
testDir: ".", testDir: ".",
testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"], testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"],
fullyParallel: true, fullyParallel: true,
reporter: "html", forbidOnly: runningInCi,
retries: runningInCi ? 1 : 0,
workers: runningInCi ? 2 : undefined,
reporter: runningInCi ? [["line"], ["html", { open: "never" }]] : "html",
use: { use: {
baseURL, baseURL,
locale: "zh-CN", locale: "zh-CN",
@@ -168,9 +168,6 @@ export function useWorkbenchMap({
const resizeMap = () => map.resize(); const resizeMap = () => map.resize();
window.addEventListener("resize", resizeMap); window.addEventListener("resize", resizeMap);
window.setTimeout(resizeMap, 0);
window.setTimeout(resizeMap, 300);
window.setTimeout(resizeMap, 1000);
map.on("sourcedata", (event) => { map.on("sourcedata", (event) => {
updateStatusFromSourceEvent(event, "online", setSourceStatuses); updateStatusFromSourceEvent(event, "online", setSourceStatuses);
@@ -0,0 +1,82 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useWorkbenchResponsiveLayout } from "./use-workbench-responsive-layout";
function mockViewport(width: number, largeScreen: boolean) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width
});
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: largeScreen,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
}))
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("workbench responsive layout", () => {
it("coordinates the expanded condition feed with the desktop Agent panel", () => {
mockViewport(1366, true);
const collapseAgentPanel = vi.fn();
const expandAgentPanel = vi.fn();
const { result } = renderHook(() =>
useWorkbenchResponsiveLayout({
activeToolOpen: false,
agentPanelOpen: true,
devPanelOpen: false,
collapseAgentPanel,
expandAgentPanel,
onClearActiveTool: vi.fn()
})
);
expect(result.current.isLargeScreen).toBe(true);
expect(result.current.shouldShowConditionFeed).toBe(true);
act(() => result.current.handleConditionExpandedChange(true));
expect(result.current.conditionFeedExpanded).toBe(true);
expect(collapseAgentPanel).toHaveBeenCalledOnce();
act(() => result.current.handleConditionExpandedChange(false));
expect(result.current.conditionFeedExpanded).toBe(false);
expect(expandAgentPanel).toHaveBeenCalledOnce();
});
it("toggles the condition sheet without leaking desktop panel state on mobile", () => {
mockViewport(390, false);
const onClearActiveTool = vi.fn();
const { result } = renderHook(() =>
useWorkbenchResponsiveLayout({
activeToolOpen: true,
agentPanelOpen: true,
devPanelOpen: false,
collapseAgentPanel: vi.fn(),
expandAgentPanel: vi.fn(),
onClearActiveTool
})
);
act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.mobileSheet).toBe("condition");
expect(result.current.mobileSheetSnap).toBe("half");
expect(onClearActiveTool).toHaveBeenCalledOnce();
act(() => result.current.toggleConditionFeedForViewport());
expect(result.current.mobileSheet).toBeNull();
act(() => result.current.openAgentPanelForViewport());
expect(result.current.mobileSheet).toBe("agent");
});
});
@@ -0,0 +1,175 @@
import { useEffect, useState } from "react";
import type { MobileWorkbenchSheetSnap } from "../components/mobile-workbench-sheet";
import { WORKBENCH_LAYOUT } from "../layout/workbench-layout";
const CONDITION_FEED_EXIT_MS = 170;
const CONDITION_FEED_EXPANDED_MIN_WIDTH = 1440;
const CONDITION_FEED_DEFAULT_MIN_WIDTH = 1280;
const LARGE_SCREEN_QUERY = "(min-width: 1024px)";
type WorkbenchMobileSheet = "agent" | "condition" | null;
type UseWorkbenchResponsiveLayoutOptions = {
activeToolOpen: boolean;
agentPanelOpen: boolean;
devPanelOpen: boolean;
collapseAgentPanel: () => void;
expandAgentPanel: () => void;
onClearActiveTool: () => void;
};
export function useWorkbenchResponsiveLayout({
activeToolOpen,
agentPanelOpen,
devPanelOpen,
collapseAgentPanel,
expandAgentPanel,
onClearActiveTool
}: UseWorkbenchResponsiveLayoutOptions) {
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
WORKBENCH_LAYOUT.desktop.agentWidth
);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [mobileSheet, setMobileSheet] = useState<WorkbenchMobileSheet>(null);
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(LARGE_SCREEN_QUERY);
const handleViewportChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
if (mediaQuery.matches) {
setMobileSheet(null);
}
};
handleViewportChange();
setConditionFeedVisible(window.innerWidth >= CONDITION_FEED_DEFAULT_MIN_WIDTH);
mediaQuery.addEventListener("change", handleViewportChange);
window.addEventListener("resize", handleViewportChange);
return () => {
mediaQuery.removeEventListener("change", handleViewportChange);
window.removeEventListener("resize", handleViewportChange);
};
}, []);
const shouldShowConditionFeed = conditionFeedVisible && !activeToolOpen;
useEffect(() => {
if (shouldShowConditionFeed) {
setConditionFeedMounted(true);
return;
}
const timeoutId = window.setTimeout(() => {
setConditionFeedMounted(false);
}, CONDITION_FEED_EXIT_MS);
return () => window.clearTimeout(timeoutId);
}, [shouldShowConditionFeed]);
function closeMobileSheet() {
setMobileSheet(null);
}
function openAgentPanelForViewport() {
if (isLargeScreen) {
if (conditionFeedExpanded && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
setConditionFeedExpanded(false);
setAgentCollapsedForCondition(false);
}
expandAgentPanel();
return;
}
setMobileSheet("agent");
setMobileSheetSnap("half");
}
function handleConditionExpandedChange(nextExpanded: boolean) {
if (nextExpanded && isLargeScreen && viewportWidth < CONDITION_FEED_EXPANDED_MIN_WIDTH) {
setAgentCollapsedForCondition(agentPanelOpen);
collapseAgentPanel();
} else if (!nextExpanded && agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
expandAgentPanel();
}
setConditionFeedExpanded(nextExpanded);
}
useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
return;
}
setConditionFeedExpanded(false);
if (agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
expandAgentPanel();
}
}, [
agentCollapsedForCondition,
conditionFeedExpanded,
expandAgentPanel,
isLargeScreen,
shouldShowConditionFeed
]);
function toggleConditionFeedForViewport() {
onClearActiveTool();
if (isLargeScreen) {
setConditionFeedVisible((current) => !current);
return;
}
if (mobileSheet === "condition") {
closeMobileSheet();
return;
}
setConditionFeedExpanded(false);
setMobileSheetSnap("half");
setMobileSheet("condition");
}
function openConditionFeedForViewport() {
setConditionFeedVisible(true);
onClearActiveTool();
if (!isLargeScreen) {
setMobileSheet("condition");
setMobileSheetSnap("half");
}
}
const leftPanelOpen = isLargeScreen && agentPanelOpen;
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed);
const rightPanelExpanded =
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen;
return {
agentPanelWidth,
closeMobileSheet,
conditionFeedExpanded,
conditionFeedMounted,
handleConditionExpandedChange,
isLargeScreen,
leftPanelOpen,
mobileSheet,
mobileSheetSnap,
openAgentPanelForViewport,
openConditionFeedForViewport,
rightPanelExpanded,
rightPanelOpen,
setAgentPanelWidth,
setMobileSheetSnap,
shouldShowConditionFeed,
toggleConditionFeedForViewport,
viewportWidth
};
}
+36 -132
View File
@@ -31,10 +31,7 @@ import {
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import { AgentTaskTicker } from "./components/agent-task-ticker"; import { AgentTaskTicker } from "./components/agent-task-ticker";
import { MapDevPanel } from "./components/map-dev-panel"; import { MapDevPanel } from "./components/map-dev-panel";
import { import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
MobileWorkbenchSheet,
type MobileWorkbenchSheetSnap
} from "./components/mobile-workbench-sheet";
import { FeaturePopover } from "./components/feature-popover"; import { FeaturePopover } from "./components/feature-popover";
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels"; import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
@@ -50,6 +47,7 @@ import { useWorkbenchAgent } from "./hooks/use-workbench-agent";
import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing"; import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workbench-drawing";
import { useWorkbenchMap } from "./hooks/use-workbench-map"; import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller"; import { useWorkbenchMapController } from "./hooks/use-workbench-map-controller";
import { useWorkbenchResponsiveLayout } from "./hooks/use-workbench-responsive-layout";
import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data"; import { useWorkbenchRuntimeData } from "./hooks/use-workbench-runtime-data";
import { toMapFeatureReference } from "./hooks/use-map-interactions"; import { toMapFeatureReference } from "./hooks/use-map-interactions";
import { import {
@@ -69,7 +67,6 @@ import {
BASE_LAYER_OPTIONS, BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY, INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS, MAP_LEGEND_ITEMS,
WORKBENCH_LAYER_GROUPS,
applyBaseLayerVisibility, applyBaseLayerVisibility,
createLayerControlItems, createLayerControlItems,
getWorkbenchLayerIds getWorkbenchLayerIds
@@ -96,18 +93,7 @@ export function MapWorkbenchPage() {
const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null); const [detailFeature, setDetailFeature] = useState<DetailFeature | null>(null);
const [devPanelOpen, setDevPanelOpen] = useState(false); const [devPanelOpen, setDevPanelOpen] = useState(false);
const [impactVisible, setImpactVisible] = useState(false); const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [viewportWidth, setViewportWidth] = useState<number>(WORKBENCH_LAYOUT.desktopMinWidth);
const [agentPanelWidth, setAgentPanelWidth] = useState<number>(
WORKBENCH_LAYOUT.desktop.agentWidth
);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null); const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [mobileSheet, setMobileSheet] = useState<"agent" | "condition" | null>(null);
const [mobileSheetSnap, setMobileSheetSnap] = useState<MobileWorkbenchSheetSnap>("half");
const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false);
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const [taskTickerVisible, setTaskTickerVisible] = useState(true); const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null); const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
@@ -141,114 +127,40 @@ export function MapWorkbenchPage() {
setDetailFeature(feature); setDetailFeature(feature);
}, []); }, []);
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)");
const handleChange = () => {
setIsLargeScreen(mediaQuery.matches);
setViewportWidth(window.innerWidth);
if (mediaQuery.matches) {
setMobileSheet(null);
}
};
handleChange();
setConditionFeedVisible(window.innerWidth >= 1280);
mediaQuery.addEventListener("change", handleChange);
window.addEventListener("resize", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
window.removeEventListener("resize", handleChange);
};
}, []);
const shouldShowConditionFeed = conditionFeedVisible && !activeToolId;
useEffect(() => {
if (shouldShowConditionFeed) {
setConditionFeedMounted(true);
return;
}
const timeoutId = window.setTimeout(() => {
setConditionFeedMounted(false);
}, 170);
return () => window.clearTimeout(timeoutId);
}, [shouldShowConditionFeed]);
const agent = useWorkbenchAgent({ const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope, onUiEnvelope: handleAgentUiEnvelope,
onFrontendAction: handleFrontendAction onFrontendAction: handleFrontendAction
}); });
function openAgentPanelForViewport() { const clearActiveTool = useCallback(() => {
if (isLargeScreen) {
if (conditionFeedExpanded && viewportWidth < 1440) {
setConditionFeedExpanded(false);
setAgentCollapsedForCondition(false);
}
agent.expandPanel();
return;
}
setMobileSheet("agent");
setMobileSheetSnap("half");
}
const closeMobileSheet = useCallback(() => {
setMobileSheet(null);
}, []);
function handleConditionExpandedChange(nextExpanded: boolean) {
if (nextExpanded && isLargeScreen && viewportWidth < 1440) {
setAgentCollapsedForCondition(agent.panelOpen);
agent.collapsePanel();
} else if (!nextExpanded && agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
agent.expandPanel();
}
setConditionFeedExpanded(nextExpanded);
}
useEffect(() => {
if (!isLargeScreen || shouldShowConditionFeed || !conditionFeedExpanded) {
return;
}
setConditionFeedExpanded(false);
if (agentCollapsedForCondition) {
setAgentCollapsedForCondition(false);
agent.expandPanel();
}
}, [
agent,
agentCollapsedForCondition,
conditionFeedExpanded,
isLargeScreen,
shouldShowConditionFeed
]);
function toggleConditionFeedForViewport() {
setActiveToolId(null); setActiveToolId(null);
if (isLargeScreen) { }, []);
setConditionFeedVisible((current) => !current); const {
return; agentPanelWidth,
} closeMobileSheet,
conditionFeedExpanded,
if (mobileSheet === "condition") { conditionFeedMounted,
closeMobileSheet(); handleConditionExpandedChange,
return; isLargeScreen,
} leftPanelOpen,
mobileSheet,
setConditionFeedExpanded(false); mobileSheetSnap,
setMobileSheetSnap("half"); openAgentPanelForViewport,
setMobileSheet("condition"); openConditionFeedForViewport,
} rightPanelExpanded,
rightPanelOpen,
const leftPanelOpen = isLargeScreen && agent.panelOpen; setAgentPanelWidth,
const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed); setMobileSheetSnap,
const rightPanelExpanded = shouldShowConditionFeed,
isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen; toggleConditionFeedForViewport,
viewportWidth
} = useWorkbenchResponsiveLayout({
activeToolOpen: activeToolId !== null,
agentPanelOpen: agent.panelOpen,
devPanelOpen,
collapseAgentPanel: agent.collapsePanel,
expandAgentPanel: agent.expandPanel,
onClearActiveTool: clearActiveTool
});
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({ const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
containerRef: mapContainerRef, containerRef: mapContainerRef,
@@ -379,9 +291,7 @@ export function MapWorkbenchPage() {
} }
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => { getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none"); map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
}
}); });
} }
@@ -476,11 +386,10 @@ export function MapWorkbenchPage() {
setImpactVisible(true); setImpactVisible(true);
setLayerVisibility((current) => ({ ...current, simulation: true })); setLayerVisibility((current) => ({ ...current, simulation: true }));
if (mapRef.current && mapReady) { const map = mapRef.current;
WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => { if (map && mapReady) {
if (mapRef.current?.getLayer(layerId)) { getWorkbenchLayerIds(map, "simulation").forEach((layerId) => {
mapRef.current.setLayoutProperty(layerId, "visibility", "visible"); map.setLayoutProperty(layerId, "visibility", "visible");
}
}); });
} }
@@ -559,12 +468,7 @@ export function MapWorkbenchPage() {
return; return;
} }
setConditionFeedVisible(true); openConditionFeedForViewport();
setActiveToolId(null);
if (!isLargeScreen) {
setMobileSheet("condition");
setMobileSheetSnap("half");
}
setConditionFocusRequest((current) => ({ setConditionFocusRequest((current) => ({
conditionId, conditionId,
requestId: (current?.requestId ?? 0) + 1 requestId: (current?.requestId ?? 0) + 1
+19 -12
View File
@@ -1,4 +1,19 @@
import { expect, test, type Locator } from "@playwright/test"; import { expect, test, type Locator } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test.beforeEach(async ({ page }) => {
await mockAgentApi(page, {
sessions: [
{
session_id: "browser-history-session",
title: "城南供水压力复核",
created_at: "2026-07-21T09:30:00+08:00",
updated_at: "2026-07-21T10:35:00+08:00",
run_status: "completed"
}
]
});
});
type BoundingBox = { type BoundingBox = {
x: number; x: number;
@@ -12,9 +27,7 @@ async function waitForAnimations(locator: Locator) {
const animations = element const animations = element
.getAnimations({ subtree: true }) .getAnimations({ subtree: true })
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity); .filter((animation) => animation.effect?.getTiming().iterations !== Infinity);
await Promise.all( await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
animations.map((animation) => animation.finished.catch(() => undefined))
);
}); });
} }
@@ -103,9 +116,7 @@ async function expectIntegratedHeader(header: Locator) {
expect(composition.borderRadius).toBe("0px"); expect(composition.borderRadius).toBe("0px");
} }
test("desktop Agent header expands into one attached acrylic history surface", async ({ test("desktop Agent header expands into one attached acrylic history surface", async ({ page }) => {
page
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
@@ -194,16 +205,12 @@ test("desktop Agent header expands into one attached acrylic history surface", a
await expect(history).toBeHidden(); await expect(history).toBeHidden();
}); });
test("mobile Agent mirrors the desktop floating history surface", async ({ test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => {
page
}) => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开 Agent 面板" }).click(); await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const agentPanel = page const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true });
.locator('aside[aria-label="Agent 命令面板"]')
.filter({ visible: true });
await expect(agentPanel).toBeVisible(); await expect(agentPanel).toBeVisible();
await waitForAnimations(agentPanel); await waitForAnimations(agentPanel);
const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" }); const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
+5 -5
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
test("Agent panel resizes within its responsive 720px workspace limit", async ({ page }) => { test("Agent panel resizes within its responsive 720px workspace limit", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -71,19 +72,18 @@ test.describe("mobile touch layout", () => {
}); });
}); });
// Temporarily disabled: this regression flow would start a real Agent run. test("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
// Agent analysis must be explicitly triggered by a user, not by automated tests. await mockAgentApi(page);
test.skip("mobile alert summary opens the Agent conversation panel", async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00")); await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00"));
await page.setViewportSize({ width: 375, height: 812 }); await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /查看异常处置面板/ }).click(); await page.getByRole("button", { name: /查看异常处置面板/ }).click();
// Agent execution must remain an explicit user action. await page.getByRole("button", { name: "工况汇总" }).click();
// await page.getByRole("button", { name: "工况汇总" }).click();
await expect( await expect(
page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true }) page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true })
).toBeVisible(); ).toBeVisible();
await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible(); await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible();
await expect(page.getByText("已发起工况汇总", { exact: true })).toBeVisible();
}); });
+10 -8
View File
@@ -7,7 +7,10 @@ test("submits a custom answer with the actionable id after a later tool placehol
await mockQuestionApi(page, replies); await mockQuestionApi(page, replies);
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
const prompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first(); const prompt = page
.getByPlaceholder(/输入调度问题/)
.filter({ visible: true })
.first();
await prompt.fill("测试 question 自定义回答"); await prompt.fill("测试 question 自定义回答");
await page await page
.getByRole("button", { name: "发送 Agent 指令" }) .getByRole("button", { name: "发送 Agent 指令" })
@@ -20,7 +23,9 @@ test("submits a custom answer with the actionable id after a later tool placehol
await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力"); await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力");
await panel.getByRole("button", { name: "提交回答" }).click(); await panel.getByRole("button", { name: "提交回答" }).click();
await expect.poll(() => replies).toEqual([ await expect
.poll(() => replies)
.toEqual([
{ {
pathname: "/api/v1/agent/chat/question/question-1/reply", pathname: "/api/v1/agent/chat/question/question-1/reply",
body: { body: {
@@ -54,10 +59,7 @@ test("keeps the custom answer control within a 375px Agent sheet", async ({ page
expect(box!.x + box!.width).toBeLessThanOrEqual(375); expect(box!.x + box!.width).toBeLessThanOrEqual(375);
}); });
async function mockQuestionApi( async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; body: unknown }>) {
page: Page,
replies: Array<{ pathname: string; body: unknown }>
) {
await page.route("**/api/v1/agent/chat/**", async (route) => { await page.route("**/api/v1/agent/chat/**", async (route) => {
const request = route.request(); const request = route.request();
const pathname = new URL(request.url()).pathname; const pathname = new URL(request.url()).pathname;
@@ -119,8 +121,8 @@ function createQuestionStream() {
question: "你想分析哪一部分?", question: "你想分析哪一部分?",
options: [ options: [
{ {
label: "雨污混接分析", label: "供水分区压力分析",
description: "检查混接问题" description: "检查分区压力问题"
} }
] ]
} }
+13 -13
View File
@@ -36,7 +36,7 @@ test.beforeAll(async () => {
phase: "planning", phase: "planning",
status: "running", status: "running",
title: "正在分析运行上下文", title: "正在分析运行上下文",
detail: "核对泵站、液位与降雨过程。", detail: "核对泵站、液位与用水过程。",
started_at: Date.now() started_at: Date.now()
} }
}); });
@@ -47,8 +47,8 @@ test.beforeAll(async () => {
id: "inspect-rainfall", id: "inspect-rainfall",
phase: "evidence", phase: "evidence",
status: "completed", status: "completed",
title: "正在核对降雨过程与汇水区响应关系", title: "正在核对用水过程与供水分区响应关系",
detail: "读取最近十二小时分钟级降雨序列,并检查每个汇水区的响应延迟是否处于合理范围。", detail: "读取最近十二小时分钟级流量序列,并检查每个供水分区的响应延迟是否处于合理范围。",
started_at: Date.now() started_at: Date.now()
} }
}); });
@@ -74,7 +74,7 @@ test.beforeAll(async () => {
todos: [ todos: [
{ id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" }, { id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" },
{ id: "inspect-level", content: "核对液位计连续性", status: "pending" }, { id: "inspect-level", content: "核对液位计连续性", status: "pending" },
{ id: "assess-overflow", content: "判断溢流风险", status: "pending" } { id: "assess-supply-risk", content: "判断供水风险", status: "pending" }
] ]
} }
}); });
@@ -110,7 +110,7 @@ test.beforeAll(async () => {
id: "assess-risk", id: "assess-risk",
phase: "analysis", phase: "analysis",
status: "completed", status: "completed",
title: "溢流风险判断完成", title: "供水风险判断完成",
detail: "已完成风险点位排序。", detail: "已完成风险点位排序。",
ended_at: Date.now() ended_at: Date.now()
} }
@@ -131,7 +131,7 @@ test.beforeAll(async () => {
id: "assess-risk", id: "assess-risk",
phase: "analysis", phase: "analysis",
status: "running", status: "running",
title: "正在判断溢流风险", title: "正在判断供水风险",
detail: "结合流量突变与液位趋势形成判断。", detail: "结合流量突变与液位趋势形成判断。",
started_at: Date.now() started_at: Date.now()
} }
@@ -382,7 +382,7 @@ test("stream completion and the plan card remain positionally stable", async ({
requestAnimationFrame(sample); requestAnimationFrame(sample);
}); });
triggerProgressAppend(); triggerProgressAppend();
await expect(liveProgress.getByText("正在判断溢流风险", { exact: true })).toBeVisible(); await expect(liveProgress.getByText("正在判断供水风险", { exact: true })).toBeVisible();
await expect(liveProgress.locator("li")).toHaveCount(3); await expect(liveProgress.locator("li")).toHaveCount(3);
await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0); await expect(liveProgress.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0);
await expect await expect
@@ -450,7 +450,7 @@ test("stream completion and the plan card remain positionally stable", async ({
); );
await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible(); await expect(expandedLiveProgress.getByText("正在分析运行上下文", { exact: true })).toBeVisible();
await expect( await expect(
expandedLiveProgress.getByText("正在核对降雨过程与汇水区响应关系", { exact: true }) expandedLiveProgress.getByText("正在核对用水过程与供水分区响应关系", { exact: true })
).toHaveCSS("white-space", "normal"); ).toHaveCSS("white-space", "normal");
await progressToggle.click(); await progressToggle.click();
await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3); await expect(page.getByRole("list", { name: "最近执行进度" }).locator("li")).toHaveCount(3);
@@ -468,7 +468,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(progressToggle.locator("../..")).toContainText("步骤4/4"); await expect(progressToggle.locator("../..")).toContainText("步骤4/4");
await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0); await expect(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0);
await expect( await expect(
page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true }) page.getByText("供水风险判断完成", { exact: true }).filter({ visible: true })
).toHaveCount(0); ).toHaveCount(0);
await expect await expect
.poll(async () => .poll(async () =>
@@ -541,7 +541,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect( await expect(
page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first() page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first()
).toBeVisible(); ).toBeVisible();
const expandedLongTitle = expandedProgress.getByText("正在核对降雨过程与汇水区响应关系", { const expandedLongTitle = expandedProgress.getByText("正在核对用水过程与供水分区响应关系", {
exact: true exact: true
}); });
await expect(expandedLongTitle).toHaveCSS("white-space", "normal"); await expect(expandedLongTitle).toHaveCSS("white-space", "normal");
@@ -562,7 +562,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(latestProgressRegion).toContainText("最近3/3"); await expect(latestProgressRegion).toContainText("最近3/3");
triggerProgressAppend(); triggerProgressAppend();
await expect( await expect(
page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true }) page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断供水风险", { exact: true })
).toBeVisible(); ).toBeVisible();
await latestProgressToggle.click(); await latestProgressToggle.click();
await expect(latestProgressRegion).toContainText("全部4/4"); await expect(latestProgressRegion).toContainText("全部4/4");
@@ -570,7 +570,7 @@ test("stream completion and the plan card remain positionally stable", async ({
await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 }); await expect(latestProgressRegion).toContainText("全部4/4", { timeout: 10_000 });
await expect(latestExpandedProgress).toBeVisible(); await expect(latestExpandedProgress).toBeVisible();
await expect(latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })).toBeVisible(); await expect(latestExpandedProgress.getByText("供水风险判断完成", { exact: true })).toBeVisible();
const conversationScroll = page.locator(".agent-conversation-scroll"); const conversationScroll = page.locator(".agent-conversation-scroll");
await conversationScroll.hover(); await conversationScroll.hover();
@@ -673,7 +673,7 @@ function createLongMarkdownResponse() {
"当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n"; "当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n";
content += "- 检查泵站运行状态与实时流量。\n"; content += "- 检查泵站运行状态与实时流量。\n";
content += "- 核对液位计连续性和异常峰值。\n"; content += "- 核对液位计连续性和异常峰值。\n";
content += "- 结合降雨过程判断溢流风险。\n\n"; content += "- 结合用水过程判断供水风险。\n\n";
} }
return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`; return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`;
} }
+78
View File
@@ -0,0 +1,78 @@
import type { Page } from "@playwright/test";
type MockAgentApiOptions = {
sessions?: Array<Record<string, unknown>>;
};
export async function mockAgentApi(page: Page, options: MockAgentApiOptions = {}) {
await page.route("**/api/v1/agent/chat/**", async (route) => {
const pathname = new URL(route.request().url()).pathname;
if (pathname.endsWith("/stream")) {
await route.fulfill({
status: 200,
headers: {
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
"X-Vercel-AI-UI-Message-Stream": "v1"
},
body: [
{ type: "start", messageId: "assistant-browser-test" },
{ type: "text-start", id: "answer" },
{ type: "text-delta", id: "answer", delta: "已收到工况汇总请求。" },
{ type: "text-end", id: "answer" },
{ type: "finish", finishReason: "stop" }
]
.map((part) => `data: ${JSON.stringify(part)}\n\n`)
.join("")
});
return;
}
if (pathname.endsWith("/sessions")) {
await route.fulfill({ json: { sessions: options.sessions ?? [] } });
return;
}
if (pathname.endsWith("/models")) {
await route.fulfill({
json: {
default_model: "test/model",
models: [
{
id: "test/model",
label: "测试模型",
description: "Playwright 浏览器测试",
icon: "bolt"
}
]
}
});
return;
}
if (pathname.endsWith("/ui-registry")) {
await route.fulfill({
json: {
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
}
});
return;
}
if (pathname.endsWith("/frontend-action-registry")) {
await route.fulfill({
json: {
schema_version: "frontend-action-registry@1",
actions: []
}
});
return;
}
await route.fulfill({ json: {} });
});
}
@@ -2,33 +2,30 @@ import { expect, test } from "@playwright/test";
test("opening Agent and condition panels does not move the map camera", async ({ page }) => { test("opening Agent and condition panels does not move the map camera", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => await page.waitForFunction(
Boolean(window.__waterNetworkMap?.getLayer("scada-hit")) () =>
Boolean(window.__waterNetworkMap?.getLayer("scada-hit")) &&
!window.__waterNetworkMap?.isMoving()
); );
await page.waitForTimeout(500);
await page.evaluate(() => { await page.evaluate(() => {
const map = window.__waterNetworkMap; const map = window.__waterNetworkMap;
document.documentElement.dataset.panelCameraMoveCount = "0"; document.documentElement.dataset.panelCameraMoveCount = "0";
map?.on("movestart", () => { map?.on("movestart", () => {
const currentCount = Number( const currentCount = Number(document.documentElement.dataset.panelCameraMoveCount ?? "0");
document.documentElement.dataset.panelCameraMoveCount ?? "0"
);
document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1); document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1);
}); });
}); });
await page.getByRole("button", { name: "折叠 Agent 面板" }).click(); await page.getByRole("button", { name: "折叠 Agent 面板" }).click();
await page.waitForTimeout(250); await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible();
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
await page.waitForTimeout(250); await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await conditionPanel.getByRole("button", { name: "展开工况任务" }).click(); await conditionPanel.getByRole("button", { name: "展开工况任务" }).click();
await page.waitForTimeout(250); await expect(conditionPanel).toHaveCSS("width", "880px");
expect( expect(await page.evaluate(() => document.documentElement.dataset.panelCameraMoveCount)).toBe(
await page.evaluate( "0"
() => document.documentElement.dataset.panelCameraMoveCount );
)
).toBe("0");
}); });
+6 -5
View File
@@ -176,14 +176,17 @@ async function openWorkbench(page: Page) {
await page.evaluate(async () => { await page.evaluate(async () => {
await document.fonts.ready; await document.fonts.ready;
}); });
await page.waitForTimeout(350); await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-hit")));
} }
async function selectBasemap(page: Page, label: "浅色" | "影像") { async function selectBasemap(page: Page, label: "浅色" | "影像") {
await page.getByRole("button", { name: /图层:管理地图图层/ }).click(); await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await page.getByRole("button", { name: label, exact: true }).click(); await page.getByRole("button", { name: label, exact: true }).click();
await page.getByRole("button", { name: /图层:管理地图图层/ }).click(); await page.getByRole("button", { name: /图层:管理地图图层/ }).click();
await page.waitForTimeout(220); await expect(page.locator("main")).toHaveAttribute(
"data-basemap-tone",
label === "影像" ? "satellite" : "light"
);
} }
async function expectDesktopFloatingGeometry(page: Page) { async function expectDesktopFloatingGeometry(page: Page) {
@@ -243,9 +246,7 @@ async function expectAcrylicAlphas(
const alphas = await page.evaluate(() => { const alphas = await page.evaluate(() => {
return { return {
navigation: alphaOf(document.querySelector(".acrylic-navigation")), navigation: alphaOf(document.querySelector(".acrylic-navigation")),
agentPanel: alphaOf( agentPanel: alphaOf(document.querySelector('aside[aria-label="Agent 命令面板"]')),
document.querySelector('aside[aria-label="Agent 命令面板"]')
),
conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")), conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")),
control: alphaOf(document.querySelector(".acrylic-control")) control: alphaOf(document.querySelector(".acrylic-control"))
}; };