import { expect, test, type Locator, type Page } from "@playwright/test"; import { mockFlatWorkOrder } from "./support/mock-hierarchical-work-order"; import { mockWorkbenchContext } from "./support/mock-workbench-context"; test.beforeEach(async ({ page }) => { await mockWorkbenchContext(page); }); test("shows mock scheduled conditions outside the demo project", async ({ page }) => { const project = { project_id: "b962c44e-8433-4a10-81e9-a64b70ac1152", name: "凤阳(新版)", code: "fengyang_v2", gs_workspace: "fengyang_v2", status: "active" }; await mockWorkbenchContext(page, undefined, { metadata: { ...project, map_config: {}, map_extent: null, project_role: "member" }, accessContext: { user_id: "operator", username: "project.operator", system_role: "user", is_system_admin: false, project_id: project.project_id, project_role: "member", permissions: ["webgis.view"] } }); await page.route("https://tjwater-api.invalid/api/v1/projects?**", (route) => route.fulfill({ headers: { "Access-Control-Allow-Origin": "*" }, json: { items: [project], total: 1, limit: 1000, offset: 0 } })); await page.goto("/", { waitUntil: "domcontentloaded" }); await openConditionFeed(page); const panel = page.getByRole("region", { name: "工况任务", exact: true }); await panel.getByRole("button", { name: "展开工况任务" }).focus(); await page.keyboard.press("Enter"); await expect(panel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据"); await expect( panel.getByTestId("scheduled-condition-timeline").getByRole("button", { name: /SCADA 数据诊断/ }).first() ).toBeVisible(); }); test("keeps the open scheduled report when the rolling history refreshes", async ({ page }) => { await page.clock.install({ time: new Date("2026-09-24T09:20:53Z") }); await page.goto("/", { waitUntil: "domcontentloaded" }); await openConditionFeed(page); const panel = page.getByRole("region", { name: "工况任务", exact: true }); await panel.getByRole("button", { name: "展开工况任务" }).click(); const rows = panel.getByTestId("scheduled-condition-timeline").locator("button[aria-pressed]"); const oldestReport = rows.last(); const reportTitle = await oldestReport.textContent(); const reportId = await oldestReport.getAttribute("data-condition-id"); expect(reportTitle).toContain("11:20SCADA 数据诊断"); expect(reportId).not.toBeNull(); await oldestReport.click(); await expect(oldestReport).toHaveAttribute("aria-pressed", "true"); await page.clock.runFor(12_000); await expect( panel.locator(`[data-condition-id="${reportId}"]`) ).toHaveAttribute("aria-pressed", "true"); await expect(panel.getByTestId("scheduled-condition-detail-scroll")) .toContainText("11:20 的SCADA 数据诊断"); await panel.getByRole("button", { name: /预约任务/ }).click(); await panel.getByRole("button", { name: /定时任务/ }).click(); await expect(rows.filter({ hasText: reportTitle ?? "" })).toHaveCount(0); }); test("scheduled conditions keep acrylic blur outside transformed ancestors", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); await openConditionFeed(page); const panel = page.getByRole("region", { name: "工况任务", exact: true }); await expect(panel).toBeVisible(); await expectAcrylicComposition(panel); await expect(panel).toHaveCSS("width", "432px"); const timeline = panel.getByTestId("scheduled-condition-timeline"); const detailViewport = panel.locator(".scheduled-feed-detail-viewport"); const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll"); const collapsedPanelBox = await panel.boundingBox(); const collapsedTimelineBox = await timeline.boundingBox(); expect(collapsedPanelBox).not.toBeNull(); expect(collapsedTimelineBox).not.toBeNull(); await panel.getByRole("button", { name: "展开工况任务" }).click(); await expectAcrylicComposition(panel); await expect(panel).toHaveCSS("width", "880px"); const expandedPanelBox = await panel.boundingBox(); const expandedTimelineBox = await timeline.boundingBox(); const expandedDetailViewportBox = await detailViewport.boundingBox(); const expandedDetailScrollBox = await detailScroll.boundingBox(); expect(expandedPanelBox).not.toBeNull(); expect(expandedTimelineBox).not.toBeNull(); expect(expandedDetailViewportBox).not.toBeNull(); expect(expandedDetailScrollBox).not.toBeNull(); expect(Math.round(expandedPanelBox!.x + expandedPanelBox!.width)).toBe( Math.round(collapsedPanelBox!.x + collapsedPanelBox!.width) ); expect(Math.round(expandedTimelineBox!.width)).toBe( Math.round(collapsedTimelineBox!.width) ); expect(Math.round(expandedTimelineBox!.x + expandedTimelineBox!.width)).toBe( Math.round(collapsedTimelineBox!.x + collapsedTimelineBox!.width) ); expect(expandedTimelineBox!.y + expandedTimelineBox!.height).toBeLessThanOrEqual( expandedPanelBox!.y + expandedPanelBox!.height - 12 ); expect(Math.round(expandedDetailViewportBox!.y + expandedDetailViewportBox!.height)).toBe( Math.round(expandedTimelineBox!.y + expandedTimelineBox!.height) ); expect(expandedDetailScrollBox!.y).toBeGreaterThanOrEqual( expandedDetailViewportBox!.y + 8 ); expect(expandedDetailScrollBox!.y + expandedDetailScrollBox!.height).toBeLessThanOrEqual( expandedDetailViewportBox!.y + expandedDetailViewportBox!.height - 8 ); await expect(detailViewport).toHaveCSS("border-radius", "12px"); const timelineScroll = timeline.locator(".scheduled-feed-scroll.flex-1"); expect(await readScrollbarPresentation(detailScroll)).toEqual( await readScrollbarPresentation(timelineScroll) ); const futureScrollBox = await timeline.locator(".scheduled-feed-scroll").first().boundingBox(); const recentScrollBox = await timelineScroll.boundingBox(); expect(futureScrollBox).not.toBeNull(); expect(recentScrollBox).not.toBeNull(); expect(Math.round(futureScrollBox!.x + futureScrollBox!.width)).toBe( Math.round(recentScrollBox!.x + recentScrollBox!.width) ); await panel.getByRole("button", { name: "收起工况任务" }).click(); await expect(panel).toHaveCSS("width", "432px"); const restoredTimelineBox = await timeline.boundingBox(); expect(restoredTimelineBox).not.toBeNull(); expect(Math.round(restoredTimelineBox!.width)).toBe( Math.round(collapsedTimelineBox!.width) ); }); test("narrow desktop keeps Agent and expanded conditions from overlapping", async ({ page }) => { await page.setViewportSize({ width: 1024, height: 768 }); await page.goto("/", { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await openConditionFeed(page); const panel = page.getByRole("region", { name: "工况任务", exact: true }); const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]'); await expect(agentPanel).toHaveCSS("width", "500px"); const compactAgentBox = await agentPanel.boundingBox(); const compactConditionBox = await panel.boundingBox(); expect(compactAgentBox).not.toBeNull(); expect(compactConditionBox).not.toBeNull(); expect(compactConditionBox!.x - (compactAgentBox!.x + compactAgentBox!.width)).toBeGreaterThanOrEqual( 24 ); await panel.getByRole("button", { name: "展开工况任务" }).focus(); await page.keyboard.press("Enter"); await expect(page.locator('aside[aria-label="Agent 折叠栏"]')).toBeVisible(); await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(800); await expect.poll(async () => (await panel.boundingBox())?.width ?? Infinity).toBeLessThanOrEqual(880); const expandedConditionBox = await panel.boundingBox(); const collapsedAgentBox = await page .locator('aside[aria-label="Agent 折叠栏"]') .boundingBox(); expect(expandedConditionBox).not.toBeNull(); expect(collapsedAgentBox).not.toBeNull(); expect( expandedConditionBox!.x - (collapsedAgentBox!.x + collapsedAgentBox!.width) ).toBeGreaterThanOrEqual(24); await panel.getByRole("button", { name: "收起工况任务" }).click(); await expect(agentPanel).toBeVisible(); }); test("switching conditions keeps the detail viewport fully expanded", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); await openConditionFeed(page); const panel = page.getByRole("region", { name: "工况任务", exact: true }); await panel.getByRole("button", { name: "展开工况任务" }).click(); await expect(panel).toHaveCSS("width", "880px"); const detailScroll = panel.getByTestId("scheduled-condition-detail-scroll"); const baselineHeight = await detailScroll.evaluate( (element) => element.getBoundingClientRect().height ); const targetCondition = panel .getByTestId("scheduled-condition-timeline") .getByRole("button", { name: /SCADA 数据诊断/ }) .nth(1); const sampledHeights = await targetCondition.evaluate(async (element) => { element.click(); const heights: number[] = []; const startedAt = performance.now(); await new Promise((resolve) => { const sample = () => { const detail = document.querySelector( '[data-testid="scheduled-condition-detail-scroll"]' ); if (detail) { heights.push(detail.getBoundingClientRect().height); } if (performance.now() - startedAt >= 650) { resolve(); return; } requestAnimationFrame(sample); }; requestAnimationFrame(sample); }); return heights; }); expect(sampledHeights.length).toBeGreaterThan(1); expect(Math.min(...sampledHeights)).toBeGreaterThanOrEqual(baselineHeight - 1); await panel .getByTestId("scheduled-condition-timeline") .getByRole("button", { name: /SCADA 数据诊断/ }) .first() .click(); const workflowTitle = panel.getByText("演示执行流程 · SCADA 诊断流程", { exact: true }); await expect(workflowTitle).toBeVisible(); await expect(workflowTitle.locator("..").locator("svg")).toHaveCount(0); }); test("keeps execution spinners without breathing badges after selecting timeline tasks", async ({ page }) => { await mockFlatWorkOrder(page, "execution"); await page.goto("/", { waitUntil: "domcontentloaded" }); await openConditionFeed(page); const panel = page.getByRole("region", { name: "工况任务", exact: true }); await panel.getByRole("button", { name: "展开工况任务" }).click(); const timeline = panel.getByTestId("scheduled-condition-timeline"); const runningCondition = timeline .locator('button[aria-pressed]') .filter({ hasText: "SCADA 数据诊断" }) .filter({ hasText: "执行中" }) .first(); await expectRunningStatusAnimations(runningCondition); await runningCondition.click(); await expectRunningStatusAnimations(runningCondition); await expect( panel .getByText("演示执行流程 · SCADA 诊断流程", { exact: true }) .locator("..") .locator(".status-badge .status-dot") ).toHaveCount(0); await panel.getByRole("button", { name: /预约任务/ }).click(); const executingWorkOrder = timeline .locator('button[aria-pressed]') .filter({ hasText: "演示工单:北辰分区阀门开度调整" }) .last(); await expectRunningStatusAnimations(executingWorkOrder); await expect(executingWorkOrder.getByText("执行中", { exact: true })).toBeVisible(); await executingWorkOrder.click(); await expectRunningStatusAnimations(executingWorkOrder); const detail = panel.getByTestId("scheduled-condition-detail-scroll"); await expect(detail.getByText("预约工单", { exact: true }).last()).toBeVisible(); await expect(detail.getByText("复令要求", { exact: true })).toBeVisible(); await expect(detail.getByText(/上传阀门操作前后照片/).first()).toBeVisible(); await expect(detail.getByText("工单报告", { exact: true })).toBeVisible(); await expect(detail.getByText("活动记录", { exact: true })).toHaveCount(0); await expect(detail.locator("details")).toHaveCount(0); }); test("shows completed field work as a static waiting-for-reply state", async ({ page }) => { await mockFlatWorkOrder(page, "reply"); await page.goto("/", { waitUntil: "domcontentloaded" }); await openConditionFeed(page); const panel = page.getByRole("region", { name: "工况任务", exact: true }); await panel.getByRole("button", { name: "展开工况任务" }).click(); await panel.getByRole("button", { name: /预约任务/ }).click(); const awaitingReplyWorkOrder = panel .getByTestId("scheduled-condition-timeline") .locator('button[aria-pressed]') .filter({ hasText: "演示工单:泵站边界复核复令" }) .last(); await expect(awaitingReplyWorkOrder.getByText("待复令", { exact: true })).toBeVisible(); await expect(awaitingReplyWorkOrder.locator(".status-icon")).toHaveAttribute( "data-status-tone", "warning" ); await expect(awaitingReplyWorkOrder.locator(".status-glyph")).toHaveAttribute( "data-activity", "static" ); await expect.poll(() => getAnimationName(awaitingReplyWorkOrder.locator(".status-glyph"))).toBe( "none" ); await awaitingReplyWorkOrder.click(); await expect(panel.getByText("确认模型边界已同步更新", { exact: true })).toBeVisible(); await expect(panel.getByText("工单报告", { exact: true })).toBeVisible(); await expect(panel.getByText("活动记录", { exact: true })).toHaveCount(0); await expect( panel .getByTestId("scheduled-condition-detail-scroll") .locator('.status-badge[data-status-tone="warning"]') .filter({ hasText: "待复令" }) .first() ).toHaveAttribute("data-status-tone", "warning"); }); async function readScrollbarPresentation(locator: Locator) { return locator.evaluate((element) => { const style = getComputedStyle(element); const scrollbarStyle = getComputedStyle(element, "::-webkit-scrollbar"); const thumbStyle = getComputedStyle(element, "::-webkit-scrollbar-thumb"); return { gutter: style.scrollbarGutter, overflowY: style.overflowY, scrollbarColor: style.scrollbarColor, scrollbarWidth: style.scrollbarWidth, thumbBackground: thumbStyle.backgroundColor, thumbRadius: thumbStyle.borderRadius, webkitWidth: scrollbarStyle.width }; }); } async function openConditionFeed(page: Page) { const button = page.getByRole("button", { name: "工况任务", exact: true }); await button.focus(); await page.keyboard.press("Enter"); } async function expectAcrylicComposition(panel: Locator) { await expect.poll(() => panel.evaluate((element) => getComputedStyle(element).transform)).toBe( "none" ); await expect.poll(() => panel.evaluate((element) => getComputedStyle(element).willChange)).toBe( "auto" ); const composition = await panel.evaluate((element) => { const rootStyle = getComputedStyle(element); const blockingAncestors: string[] = []; const filteredDescendants: string[] = []; let ancestor = element.parentElement; while (ancestor && ancestor.tagName !== "MAIN") { const style = getComputedStyle(ancestor); if ( style.transform !== "none" || style.filter !== "none" || style.opacity !== "1" || style.isolation !== "auto" || style.willChange.includes("transform") ) { blockingAncestors.push(ancestor.className); } ancestor = ancestor.parentElement; } for (const descendant of element.querySelectorAll("*")) { if (getComputedStyle(descendant).backdropFilter !== "none") { filteredDescendants.push(descendant.className); } } return { backdropFilter: rootStyle.backdropFilter, blockingAncestors, filteredDescendants, transform: rootStyle.transform, willChange: rootStyle.willChange }; }); expect(composition.backdropFilter).toContain("blur("); expect(composition.blockingAncestors).toEqual([]); expect(composition.filteredDescendants).toEqual([]); expect(composition.transform).toBe("none"); expect(composition.willChange).toBe("auto"); } async function expectRunningStatusAnimations(row: Locator) { const timelineGlyph = row.locator(".status-icon .status-glyph"); await expect(timelineGlyph).toHaveAttribute("data-activity", "loading"); await expect(row.locator(".status-badge .status-dot")).toHaveCount(0); await expect.poll(() => getAnimationName(timelineGlyph)).toBe("status-spin"); } async function getAnimationName(locator: Locator) { return locator.evaluate((element) => getComputedStyle(element).animationName); }