refactor: simplify workbench layout and browser tests
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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<DetailFeature | null>(null);
|
||||
const [devPanelOpen, setDevPanelOpen] = 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 [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 [taskTickerVisible, setTaskTickerVisible] = useState(true);
|
||||
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(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
|
||||
|
||||
@@ -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 工作台抽屉" });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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: "检查分区压力问题"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}建议优先处置高风险点位,并持续跟踪后续变化。`;
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"))
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user