From bdd5eff7766827c72ceb01ab38199e0de9573dc9 Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 18 Aug 2026 17:06:42 +0800 Subject: [PATCH] refactor: simplify workbench layout and browser tests --- playwright.config.ts | 6 +- .../workbench/hooks/use-workbench-map.ts | 3 - .../use-workbench-responsive-layout.test.ts | 82 ++++++++ .../hooks/use-workbench-responsive-layout.ts | 175 ++++++++++++++++++ src/features/workbench/map-workbench-page.tsx | 170 ++++------------- tests/browser/agent-acrylic.e2e.ts | 31 ++-- tests/browser/agent-panel-resize.e2e.ts | 10 +- tests/browser/agent-question.e2e.ts | 32 ++-- .../browser/agent-streaming-stability.e2e.ts | 26 +-- tests/browser/support/mock-agent-api.ts | 78 ++++++++ .../workbench-panel-map-stability.e2e.ts | 25 ++- tests/browser/workbench-visual.e2e.ts | 11 +- 12 files changed, 448 insertions(+), 201 deletions(-) create mode 100644 src/features/workbench/hooks/use-workbench-responsive-layout.test.ts create mode 100644 src/features/workbench/hooks/use-workbench-responsive-layout.ts create mode 100644 tests/browser/support/mock-agent-api.ts diff --git a/playwright.config.ts b/playwright.config.ts index b5d83bc..6bad60e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,12 +2,16 @@ import { defineConfig, devices } from "@playwright/test"; const testPort = process.env.PLAYWRIGHT_PORT || "5191"; const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${testPort}`; +const runningInCi = Boolean(process.env.CI); export default defineConfig({ testDir: ".", testMatch: ["src/**/*.e2e.ts", "tests/browser/**/*.e2e.ts"], fullyParallel: true, - reporter: "html", + forbidOnly: runningInCi, + retries: runningInCi ? 1 : 0, + workers: runningInCi ? 2 : undefined, + reporter: runningInCi ? [["line"], ["html", { open: "never" }]] : "html", use: { baseURL, locale: "zh-CN", diff --git a/src/features/workbench/hooks/use-workbench-map.ts b/src/features/workbench/hooks/use-workbench-map.ts index ceec2b2..3f5de30 100644 --- a/src/features/workbench/hooks/use-workbench-map.ts +++ b/src/features/workbench/hooks/use-workbench-map.ts @@ -168,9 +168,6 @@ export function useWorkbenchMap({ const resizeMap = () => map.resize(); window.addEventListener("resize", resizeMap); - window.setTimeout(resizeMap, 0); - window.setTimeout(resizeMap, 300); - window.setTimeout(resizeMap, 1000); map.on("sourcedata", (event) => { updateStatusFromSourceEvent(event, "online", setSourceStatuses); diff --git a/src/features/workbench/hooks/use-workbench-responsive-layout.test.ts b/src/features/workbench/hooks/use-workbench-responsive-layout.test.ts new file mode 100644 index 0000000..7262860 --- /dev/null +++ b/src/features/workbench/hooks/use-workbench-responsive-layout.test.ts @@ -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"); + }); +}); diff --git a/src/features/workbench/hooks/use-workbench-responsive-layout.ts b/src/features/workbench/hooks/use-workbench-responsive-layout.ts new file mode 100644 index 0000000..e83d8ef --- /dev/null +++ b/src/features/workbench/hooks/use-workbench-responsive-layout.ts @@ -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(WORKBENCH_LAYOUT.desktopMinWidth); + const [agentPanelWidth, setAgentPanelWidth] = useState( + WORKBENCH_LAYOUT.desktop.agentWidth + ); + const [conditionFeedVisible, setConditionFeedVisible] = useState(false); + const [conditionFeedMounted, setConditionFeedMounted] = useState(true); + const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false); + const [mobileSheet, setMobileSheet] = useState(null); + const [mobileSheetSnap, setMobileSheetSnap] = useState("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 + }; +} diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index a1710d7..15852c9 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -31,10 +31,7 @@ import { import { env } from "@/shared/config/env"; import { AgentTaskTicker } from "./components/agent-task-ticker"; import { MapDevPanel } from "./components/map-dev-panel"; -import { - MobileWorkbenchSheet, - type MobileWorkbenchSheetSnap -} from "./components/mobile-workbench-sheet"; +import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet"; import { FeaturePopover } from "./components/feature-popover"; import { ScheduledConditionFeed } from "./components/scheduled-condition-feed"; 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 { useWorkbenchMap } from "./hooks/use-workbench-map"; 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 { toMapFeatureReference } from "./hooks/use-map-interactions"; import { @@ -69,7 +67,6 @@ import { BASE_LAYER_OPTIONS, INITIAL_LAYER_VISIBILITY, MAP_LEGEND_ITEMS, - WORKBENCH_LAYER_GROUPS, applyBaseLayerVisibility, createLayerControlItems, getWorkbenchLayerIds @@ -96,18 +93,7 @@ export function MapWorkbenchPage() { const [detailFeature, setDetailFeature] = useState(null); const [devPanelOpen, setDevPanelOpen] = useState(false); const [impactVisible, setImpactVisible] = useState(false); - const [isLargeScreen, setIsLargeScreen] = useState(false); - const [viewportWidth, setViewportWidth] = useState(WORKBENCH_LAYOUT.desktopMinWidth); - const [agentPanelWidth, setAgentPanelWidth] = useState( - WORKBENCH_LAYOUT.desktop.agentWidth - ); const [activeToolId, setActiveToolId] = useState(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("half"); - const [agentCollapsedForCondition, setAgentCollapsedForCondition] = useState(false); const prefersReducedMotion = useReducedMotion(); const [taskTickerVisible, setTaskTickerVisible] = useState(true); const [selectedConditionId, setSelectedConditionId] = useState(null); @@ -141,114 +127,40 @@ export function MapWorkbenchPage() { 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({ onUiEnvelope: handleAgentUiEnvelope, onFrontendAction: handleFrontendAction }); - function openAgentPanelForViewport() { - 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() { + const clearActiveTool = useCallback(() => { setActiveToolId(null); - if (isLargeScreen) { - setConditionFeedVisible((current) => !current); - return; - } - - if (mobileSheet === "condition") { - closeMobileSheet(); - return; - } - - setConditionFeedExpanded(false); - setMobileSheetSnap("half"); - setMobileSheet("condition"); - } - - const leftPanelOpen = isLargeScreen && agent.panelOpen; - const rightPanelOpen = isLargeScreen && (devPanelOpen || shouldShowConditionFeed); - const rightPanelExpanded = - isLargeScreen && shouldShowConditionFeed && conditionFeedExpanded && !devPanelOpen; + }, []); + const { + agentPanelWidth, + closeMobileSheet, + conditionFeedExpanded, + conditionFeedMounted, + handleConditionExpandedChange, + isLargeScreen, + leftPanelOpen, + mobileSheet, + mobileSheetSnap, + openAgentPanelForViewport, + openConditionFeedForViewport, + rightPanelExpanded, + rightPanelOpen, + setAgentPanelWidth, + setMobileSheetSnap, + shouldShowConditionFeed, + 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({ containerRef: mapContainerRef, @@ -379,9 +291,7 @@ export function MapWorkbenchPage() { } 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); setLayerVisibility((current) => ({ ...current, simulation: true })); - if (mapRef.current && mapReady) { - WORKBENCH_LAYER_GROUPS.simulation.forEach((layerId) => { - if (mapRef.current?.getLayer(layerId)) { - mapRef.current.setLayoutProperty(layerId, "visibility", "visible"); - } + const map = mapRef.current; + if (map && mapReady) { + getWorkbenchLayerIds(map, "simulation").forEach((layerId) => { + map.setLayoutProperty(layerId, "visibility", "visible"); }); } @@ -559,12 +468,7 @@ export function MapWorkbenchPage() { return; } - setConditionFeedVisible(true); - setActiveToolId(null); - if (!isLargeScreen) { - setMobileSheet("condition"); - setMobileSheetSnap("half"); - } + openConditionFeedForViewport(); setConditionFocusRequest((current) => ({ conditionId, requestId: (current?.requestId ?? 0) + 1 diff --git a/tests/browser/agent-acrylic.e2e.ts b/tests/browser/agent-acrylic.e2e.ts index 1d23772..2f2558d 100644 --- a/tests/browser/agent-acrylic.e2e.ts +++ b/tests/browser/agent-acrylic.e2e.ts @@ -1,4 +1,19 @@ 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 = { x: number; @@ -12,9 +27,7 @@ async function waitForAnimations(locator: Locator) { const animations = element .getAnimations({ subtree: true }) .filter((animation) => animation.effect?.getTiming().iterations !== Infinity); - await Promise.all( - animations.map((animation) => animation.finished.catch(() => undefined)) - ); + await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined))); }); } @@ -103,9 +116,7 @@ async function expectIntegratedHeader(header: Locator) { expect(composition.borderRadius).toBe("0px"); } -test("desktop Agent header expands into one attached acrylic history surface", async ({ - page -}) => { +test("desktop Agent header expands into one attached acrylic history surface", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); 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(); }); -test("mobile Agent mirrors the desktop floating history surface", async ({ - page -}) => { +test("mobile Agent mirrors the desktop floating history surface", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: "打开 Agent 面板" }).click(); - const agentPanel = page - .locator('aside[aria-label="Agent 命令面板"]') - .filter({ visible: true }); + const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true }); await expect(agentPanel).toBeVisible(); await waitForAnimations(agentPanel); const mobileSheet = page.getByRole("region", { name: "Agent 工作台抽屉" }); diff --git a/tests/browser/agent-panel-resize.e2e.ts b/tests/browser/agent-panel-resize.e2e.ts index ad09fd8..083d162 100644 --- a/tests/browser/agent-panel-resize.e2e.ts +++ b/tests/browser/agent-panel-resize.e2e.ts @@ -1,4 +1,5 @@ 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 }) => { 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. -// Agent analysis must be explicitly triggered by a user, not by automated tests. -test.skip("mobile alert summary opens the Agent conversation panel", async ({ page }) => { +test("mobile alert summary opens the Agent conversation panel", async ({ page }) => { + await mockAgentApi(page); await page.clock.setFixedTime(new Date("2026-07-21T10:40:00+08:00")); await page.setViewportSize({ width: 375, height: 812 }); await page.goto("/", { waitUntil: "domcontentloaded" }); 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( page.locator('aside[aria-label="Agent 命令面板"]').filter({ visible: true }) ).toBeVisible(); await expect(page.getByRole("button", { name: "关闭 Agent 面板" })).toBeVisible(); + await expect(page.getByText("已发起工况汇总", { exact: true })).toBeVisible(); }); diff --git a/tests/browser/agent-question.e2e.ts b/tests/browser/agent-question.e2e.ts index 5e8c26a..dce619f 100644 --- a/tests/browser/agent-question.e2e.ts +++ b/tests/browser/agent-question.e2e.ts @@ -7,7 +7,10 @@ test("submits a custom answer with the actionable id after a later tool placehol await mockQuestionApi(page, replies); 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 page .getByRole("button", { name: "发送 Agent 指令" }) @@ -20,15 +23,17 @@ test("submits a custom answer with the actionable id after a later tool placehol await panel.getByPlaceholder("输入自定义回答").fill("分析高峰时段压力"); await panel.getByRole("button", { name: "提交回答" }).click(); - await expect.poll(() => replies).toEqual([ - { - pathname: "/api/v1/agent/chat/question/question-1/reply", - body: { - session_id: "question-session", - answers: [["分析高峰时段压力"]] + await expect + .poll(() => replies) + .toEqual([ + { + pathname: "/api/v1/agent/chat/question/question-1/reply", + body: { + session_id: "question-session", + answers: [["分析高峰时段压力"]] + } } - } - ]); + ]); await expect(panel.getByText("已回答", { exact: true })).toBeVisible(); await expect(panel.getByRole("button", { name: "提交回答" })).toHaveCount(0); }); @@ -54,10 +59,7 @@ test("keeps the custom answer control within a 375px Agent sheet", async ({ page expect(box!.x + box!.width).toBeLessThanOrEqual(375); }); -async function mockQuestionApi( - page: Page, - replies: Array<{ pathname: string; body: unknown }> -) { +async function mockQuestionApi(page: Page, replies: Array<{ pathname: string; body: unknown }>) { await page.route("**/api/v1/agent/chat/**", async (route) => { const request = route.request(); const pathname = new URL(request.url()).pathname; @@ -119,8 +121,8 @@ function createQuestionStream() { question: "你想分析哪一部分?", options: [ { - label: "雨污混接分析", - description: "检查混接问题" + label: "供水分区压力分析", + description: "检查分区压力问题" } ] } diff --git a/tests/browser/agent-streaming-stability.e2e.ts b/tests/browser/agent-streaming-stability.e2e.ts index f5dba05..98b4efe 100644 --- a/tests/browser/agent-streaming-stability.e2e.ts +++ b/tests/browser/agent-streaming-stability.e2e.ts @@ -36,7 +36,7 @@ test.beforeAll(async () => { phase: "planning", status: "running", title: "正在分析运行上下文", - detail: "核对泵站、液位与降雨过程。", + detail: "核对泵站、液位与用水过程。", started_at: Date.now() } }); @@ -47,8 +47,8 @@ test.beforeAll(async () => { id: "inspect-rainfall", phase: "evidence", status: "completed", - title: "正在核对降雨过程与汇水区响应关系", - detail: "读取最近十二小时分钟级降雨序列,并检查每个汇水区的响应延迟是否处于合理范围。", + title: "正在核对用水过程与供水分区响应关系", + detail: "读取最近十二小时分钟级流量序列,并检查每个供水分区的响应延迟是否处于合理范围。", started_at: Date.now() } }); @@ -74,7 +74,7 @@ test.beforeAll(async () => { todos: [ { id: "inspect-pump", content: "检查泵站运行状态", status: "in_progress" }, { 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", phase: "analysis", status: "completed", - title: "溢流风险判断完成", + title: "供水风险判断完成", detail: "已完成风险点位排序。", ended_at: Date.now() } @@ -131,7 +131,7 @@ test.beforeAll(async () => { id: "assess-risk", phase: "analysis", status: "running", - title: "正在判断溢流风险", + title: "正在判断供水风险", detail: "结合流量突变与液位趋势形成判断。", started_at: Date.now() } @@ -382,7 +382,7 @@ test("stream completion and the plan card remain positionally stable", async ({ requestAnimationFrame(sample); }); 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.getByText("正在分析运行上下文", { exact: true })).toHaveCount(0); 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 }) + expandedLiveProgress.getByText("正在核对用水过程与供水分区响应关系", { exact: true }) ).toHaveCSS("white-space", "normal"); await progressToggle.click(); 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(page.getByRole("list", { name: "最近执行进度" })).toHaveCount(0); await expect( - page.getByText("溢流风险判断完成", { exact: true }).filter({ visible: true }) + page.getByText("供水风险判断完成", { exact: true }).filter({ visible: true }) ).toHaveCount(0); await expect .poll(async () => @@ -541,7 +541,7 @@ test("stream completion and the plan card remain positionally stable", async ({ await expect( page.getByText("正在分析运行上下文", { exact: true }).filter({ visible: true }).first() ).toBeVisible(); - const expandedLongTitle = expandedProgress.getByText("正在核对降雨过程与汇水区响应关系", { + const expandedLongTitle = expandedProgress.getByText("正在核对用水过程与供水分区响应关系", { exact: true }); 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"); triggerProgressAppend(); await expect( - page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断溢流风险", { exact: true }) + page.getByRole("list", { name: "最近执行进度" }).getByText("正在判断供水风险", { exact: true }) ).toBeVisible(); await latestProgressToggle.click(); 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(latestExpandedProgress).toBeVisible(); - await expect(latestExpandedProgress.getByText("溢流风险判断完成", { exact: true })).toBeVisible(); + await expect(latestExpandedProgress.getByText("供水风险判断完成", { exact: true })).toBeVisible(); const conversationScroll = page.locator(".agent-conversation-scroll"); await conversationScroll.hover(); @@ -673,7 +673,7 @@ function createLongMarkdownResponse() { "当前管网运行状态总体稳定,但部分区域存在水位持续升高、流量突变和监测数据缺失。需要结合实时工况核对监测点连续性。\n\n"; content += "- 检查泵站运行状态与实时流量。\n"; content += "- 核对液位计连续性和异常峰值。\n"; - content += "- 结合降雨过程判断溢流风险。\n\n"; + content += "- 结合用水过程判断供水风险。\n\n"; } return `${content}建议优先处置高风险点位,并持续跟踪后续变化。`; } diff --git a/tests/browser/support/mock-agent-api.ts b/tests/browser/support/mock-agent-api.ts new file mode 100644 index 0000000..1acdbc0 --- /dev/null +++ b/tests/browser/support/mock-agent-api.ts @@ -0,0 +1,78 @@ +import type { Page } from "@playwright/test"; + +type MockAgentApiOptions = { + sessions?: Array>; +}; + +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: {} }); + }); +} diff --git a/tests/browser/workbench-panel-map-stability.e2e.ts b/tests/browser/workbench-panel-map-stability.e2e.ts index 267f202..24c9b76 100644 --- a/tests/browser/workbench-panel-map-stability.e2e.ts +++ b/tests/browser/workbench-panel-map-stability.e2e.ts @@ -2,33 +2,30 @@ import { expect, test } from "@playwright/test"; test("opening Agent and condition panels does not move the map camera", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); - await page.waitForFunction(() => - Boolean(window.__waterNetworkMap?.getLayer("scada-hit")) + await page.waitForFunction( + () => + Boolean(window.__waterNetworkMap?.getLayer("scada-hit")) && + !window.__waterNetworkMap?.isMoving() ); - await page.waitForTimeout(500); await page.evaluate(() => { const map = window.__waterNetworkMap; document.documentElement.dataset.panelCameraMoveCount = "0"; map?.on("movestart", () => { - const currentCount = Number( - document.documentElement.dataset.panelCameraMoveCount ?? "0" - ); + const currentCount = Number(document.documentElement.dataset.panelCameraMoveCount ?? "0"); document.documentElement.dataset.panelCameraMoveCount = String(currentCount + 1); }); }); 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.waitForTimeout(250); + await expect(page.locator('aside[aria-label="Agent 命令面板"]')).toBeVisible(); const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); await conditionPanel.getByRole("button", { name: "展开工况任务" }).click(); - await page.waitForTimeout(250); + await expect(conditionPanel).toHaveCSS("width", "880px"); - expect( - await page.evaluate( - () => document.documentElement.dataset.panelCameraMoveCount - ) - ).toBe("0"); + expect(await page.evaluate(() => document.documentElement.dataset.panelCameraMoveCount)).toBe( + "0" + ); }); diff --git a/tests/browser/workbench-visual.e2e.ts b/tests/browser/workbench-visual.e2e.ts index 48a62a0..5e802e1 100644 --- a/tests/browser/workbench-visual.e2e.ts +++ b/tests/browser/workbench-visual.e2e.ts @@ -176,14 +176,17 @@ async function openWorkbench(page: Page) { await page.evaluate(async () => { await document.fonts.ready; }); - await page.waitForTimeout(350); + await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-hit"))); } async function selectBasemap(page: Page, label: "浅色" | "影像") { await page.getByRole("button", { name: /图层:管理地图图层/ }).click(); await page.getByRole("button", { name: label, exact: true }).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) { @@ -243,9 +246,7 @@ async function expectAcrylicAlphas( const alphas = await page.evaluate(() => { return { navigation: alphaOf(document.querySelector(".acrylic-navigation")), - agentPanel: alphaOf( - document.querySelector('aside[aria-label="Agent 命令面板"]') - ), + agentPanel: alphaOf(document.querySelector('aside[aria-label="Agent 命令面板"]')), conditionPanel: alphaOf(document.querySelector(".scheduled-feed-panel-shell")), control: alphaOf(document.querySelector(".acrylic-control")) };