Compare commits

..
Author SHA1 Message Date
jiang ad1f7ca7a9 fix(charts): move floating windows without per-event renders
Generic Container CI/CD / test-build-publish (push) Successful in 1m20s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m20s
The earlier placement fix exposed chart headings but retained react-rnd state updates on every mousemove, so dragging still stalled with several charts open. Move the active window through pointer capture and a frame-scheduled DOM translation, then commit its bounds once on release.

Cover cancellation, resize and restore controls, and eight-window dragging. The regression records 40 React transform writes on the old code and one on the fix while other windows and chart SVGs stay unchanged.
2026-11-08 04:16:42 +08:00
jiang aa9b12662e fix(tasks): accept execution scope in task responses
Generic Container CI/CD / test-build-publish (push) Successful in 1m10s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m10s
Server responses now require execution_scope, but the strict frontend schema rejected it and prevented investigation and appointment loading. Declare it in the shared response schema and align task fixtures; regression coverage exercises summaries, details and both task categories.
2026-11-08 03:44:25 +08:00
jiang b948551c1f fix(map): stabilize notifications during map navigation
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
Ignore obsolete tile cancellations and update source notices without dismissing their existing toast. Keep notice input above the task ticker and disable swipe removal.

The remaining vertical jitter came from Sonner exit offsets being applied on top of flow positioning. Let flow own geometry and retain opacity transitions; add frame-sampled pan and recovery regression coverage.
2026-11-08 03:23:00 +08:00
19 changed files with 532 additions and 24 deletions
+3 -1
View File
@@ -56,7 +56,9 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。
## 2. Tailwind 定制方式
@@ -0,0 +1,52 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import { MapErrorNotice, MapLoadingNotice, MapSourceStatusNotice } from "./notice";
vi.mock("./notice-actions", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
it("keeps initial healthy sources quiet and announces recovery once", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice sourceName="管网" status="online" message="已就绪" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="瓦片已加载" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="offline" message="连接失败" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error", message: "连接失败" }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "success", duration: 2600 }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="全部瓦片已加载" />);
expect(showMapNotice).toHaveBeenCalledTimes(2);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledOnce();
});
it("updates a degraded source in place and only removes it when unmounted", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="部分不可用" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="正在重试" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenCalledTimes(3);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("source");
});
it("updates map errors without cancelling the updated notice and cleans up a replaced id", () => {
const { rerender, unmount } = render(<MapErrorNotice id="old" message="连接失败" />);
rerender(<MapErrorNotice id="old" message="正在重新连接" />);
expect(dismissMapNotice).not.toHaveBeenCalled();
rerender(<MapErrorNotice id="new" message="另一项错误" />);
expect(dismissMapNotice).toHaveBeenCalledExactlyOnceWith("old");
unmount();
expect(dismissMapNotice).toHaveBeenLastCalledWith("new");
});
it("keeps loading visible while its title or message changes", () => {
const { rerender, unmount } = render(<MapLoadingNotice id="loading" message="加载底图" />);
rerender(<MapLoadingNotice id="loading" title="准备管网" message="加载业务图层" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "loading", title: "准备管网", duration: Infinity }));
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("loading");
});
+20 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, type CSSProperties } from "react";
import { useEffect, useRef, type CSSProperties } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
@@ -17,10 +17,11 @@ export function MapToaster() {
position="top-center"
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
swipeDirections={[]}
toastOptions={{
unstyled: true,
classNames: {
toast: "group pointer-events-none",
toast: "group pointer-events-auto",
closeButton:
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
}
@@ -30,6 +31,7 @@ export function MapToaster() {
}
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useNoticeCleanup(id);
useEffect(() => {
showMapNotice({
id,
@@ -38,8 +40,6 @@ export function MapErrorNotice({ message, id = "map-error" }: { message: string;
message,
duration: Infinity
});
return () => dismissMapNotice(id);
}, [id, message]);
return null;
@@ -54,6 +54,7 @@ export function MapLoadingNotice({
title?: string;
id?: string | number;
}) {
useNoticeCleanup(id);
useEffect(() => {
showMapNotice({
id,
@@ -63,8 +64,6 @@ export function MapLoadingNotice({
duration: Infinity,
dismissible: false
});
return () => dismissMapNotice(id);
}, [id, message, title]);
return null;
@@ -89,7 +88,16 @@ export function MapSourceStatusNotice({
actionLabel,
onAction
}: MapSourceStatusNoticeProps) {
const previous = useRef<{ id: string | number; status: MapSourceStatus } | null>(null);
useNoticeCleanup(id);
useEffect(() => {
const last = previous.current;
previous.current = { id, status };
// Initial healthy sources and repeated tile-load messages stay quiet.
// Success is announced when the same source recovers from a problem.
if (status === "online" && !(actionLabel && onAction)
&& (!last || last.id !== id || last.status === "online")) return;
showMapNotice({
id,
tone: getSourceStatusTone(status),
@@ -99,13 +107,17 @@ export function MapSourceStatusNotice({
actionLabel,
onAction
});
return () => dismissMapNotice(id);
}, [actionLabel, id, message, onAction, sourceName, status]);
return null;
}
function useNoticeCleanup(id: string | number) {
// Content updates reuse the toast id. Dismissing during an update races
// Sonner's asynchronous removal against the newly published content.
useEffect(() => () => dismissMapNotice(id), [id]);
}
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") {
return "success";
@@ -21,6 +21,7 @@ function task(generation: number | null | undefined = 2) {
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
...(generation === undefined ? {} : { execution_generation: generation }) };
return {
execution_scope: "production",
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
@@ -42,6 +43,28 @@ function task(generation: number | null | undefined = 2) {
afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => {
it.each(["event_investigation", "external_work_order", "scheduled_flow"])(
"loads %s summaries and details with the Server execution scope", (taskType) => {
const detail = { ...task(), task_type: taskType, execution_scope: "production" };
const {
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
actions: _actions, permissions: _permissions, ...summary
} = detail;
expect(parseOperationalTaskSummary(summary)).toHaveProperty("execution_scope", "production");
expect(parseOperationalTask(detail)).toHaveProperty("execution_scope", "production");
}
);
it.each([undefined, null, "", 1])("rejects an invalid execution scope %j", (scope) => {
const detail = { ...task(), execution_scope: scope };
const {
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
actions: _actions, permissions: _permissions, ...summary
} = detail;
expect(() => parseOperationalTaskSummary(summary)).toThrow();
expect(() => parseOperationalTask(detail)).toThrow();
});
describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => {
const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 };
@@ -170,6 +193,7 @@ describe("operational task contract", () => {
it("keeps unknown fields and invalid generations rejected", () => {
expect(() => parseOperationalTask(task(0))).toThrow();
const value = task();
expect(() => parseOperationalTask({ ...value, unexpected: true })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
@@ -50,6 +50,7 @@ const operationalTaskRunPageSchema = z.object({
next_cursor: z.string().min(1).nullable()
}).strict();
const taskSummarySchema = z.object({
execution_scope: z.string().min(1),
handling_context: z.record(z.unknown()).optional(),
project_id: z.string().uuid(),
task_id: z.string().uuid(),
@@ -62,6 +62,7 @@ describe("execution step cards", () => {
});
const taskBase = {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: "event_investigation",
@@ -9,6 +9,7 @@ import {
function task(taskType: OperationalTask["task_type"]): OperationalTask {
return {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: taskType,
@@ -416,9 +416,8 @@ export function useWorkbenchMap({
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
});
map.on("sourcedataabort", (event) => {
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
});
// Moving or zooming cancels obsolete tile requests. Cancellation is part
// of navigation; source failures are reported by the error handler below.
map.on("error", (event) => {
const message = event.error?.message ?? "地图渲染异常";
+7 -3
View File
@@ -4,6 +4,7 @@ import { Rnd } from "react-rnd";
import { Maximize2, Minimize2, X } from "lucide-react";
import { Button } from "./button";
import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
import { useFloatingWindowDrag } from "./use-floating-window-drag";
type Bounds = { x: number; y: number; width: number; height: number };
@@ -84,6 +85,9 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
const current = maximized
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
: bounds;
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
width: current.width, height: current.height,
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
return <>
<Button ref={trigger} type="button" variant="ghost" size="icon-sm" className="shrink-0"
@@ -102,8 +106,7 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
bounds="parent" minWidth={Math.min(320, viewport.width - 16)} minHeight={Math.min(260, viewport.height - 16)}
maxWidth={viewport.width - 16} maxHeight={viewport.height - 16}
dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a"
disableDragging={maximized} enableResizing={!maximized}
onDragStop={(_, position) => setBounds((previous) => fitWindow({ ...previous, x: position.x, y: position.y }))}
disableDragging enableResizing={!maximized}
onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))}
style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }}
resizeHandleClasses={{ bottomRight: "floating-window-resize" }}
@@ -123,7 +126,8 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
}}>
<div ref={heading} tabIndex={0}
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
onPointerDown={startDrag}
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none select-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
onKeyDown={(event) => {
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
+84
View File
@@ -0,0 +1,84 @@
import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
import { flushSync } from "react-dom";
type Position = { x: number; y: number };
/** Move only the active DOM layer per frame; persist React bounds on release. */
export function useFloatingWindowDrag({ enabled, position, width, height, onCommit }: {
enabled: boolean;
position: Position;
width: number;
height: number;
onCommit: (position: Position) => void;
}) {
const cancel = useRef<(() => void) | null>(null);
useLayoutEffect(() => {
const stop = () => cancel.current?.();
window.addEventListener("resize", stop);
return () => {
stop();
window.removeEventListener("resize", stop);
};
}, [enabled]);
return (event: ReactPointerEvent<HTMLDivElement>) => {
if (!enabled || !event.isPrimary || event.button !== 0 || cancel.current
|| (event.target as Element).closest("button,input,select,textarea,a")) return;
const heading = event.currentTarget;
const element = heading.closest<HTMLElement>("[data-floating-window]");
if (!element) return;
event.preventDefault();
heading.focus({ preventScroll: true });
const pointerId = event.pointerId;
const startX = event.clientX;
const startY = event.clientY;
const maxX = window.innerWidth - width - 8;
const maxY = window.innerHeight - height - 8;
const previousTranslate = element.style.translate;
const previousWillChange = element.style.willChange;
let next = position;
let frame = 0;
element.style.willChange = "translate";
const updatePosition = (pointer: PointerEvent) => {
next = {
x: Math.max(8, Math.min(position.x + pointer.clientX - startX, maxX)),
y: Math.max(8, Math.min(position.y + pointer.clientY - startY, maxY))
};
};
const move = (pointer: PointerEvent) => {
if (pointer.pointerId !== pointerId) return;
updatePosition(pointer);
if (!frame) frame = requestAnimationFrame(() => {
frame = 0;
element.style.translate = `${next.x - position.x}px ${next.y - position.y}px`;
});
};
const cleanup = () => {
cancel.current = null;
cancelAnimationFrame(frame);
heading.removeEventListener("pointermove", move);
heading.removeEventListener("pointerup", finish);
heading.removeEventListener("pointercancel", abort);
heading.removeEventListener("lostpointercapture", abort);
element.style.translate = previousTranslate;
element.style.willChange = previousWillChange;
if (heading.hasPointerCapture(pointerId)) heading.releasePointerCapture(pointerId);
};
const abort = (pointer: PointerEvent) => {
if (pointer.pointerId === pointerId) cleanup();
};
const finish = (pointer: PointerEvent) => {
if (pointer.pointerId !== pointerId) return;
updatePosition(pointer);
// Commit and remove the temporary offset in one turn to avoid a snap.
if (next.x !== position.x || next.y !== position.y) flushSync(() => onCommit(next));
cleanup();
};
cancel.current = cleanup;
heading.addEventListener("pointermove", move);
heading.addEventListener("pointerup", finish);
heading.addEventListener("pointercancel", abort);
heading.addEventListener("lostpointercapture", abort);
heading.setPointerCapture(pointerId);
};
}
+2 -2
View File
@@ -2445,7 +2445,7 @@ details[open] > summary > .disclosure-accordion-indicator {
height: min(720px, 80dvh);
max-width: 760px;
}
/* Layout only: keep the shared acrylic material and its shadows unclipped. */
/* Flow layout owns stack geometry; keep Sonner opacity transitions without its absolute offsets. */
[data-sonner-toaster].map-notice-stack {
display: flex;
flex-direction: column;
@@ -2461,7 +2461,7 @@ details[open] > summary > .disclosure-accordion-indicator {
bottom: auto;
flex-shrink: 0;
height: auto;
--y: translateY(0);
transform: none;
}
[data-sonner-toaster].map-notice-stack [data-sonner-toast][data-visible="false"] {
display: none;
@@ -61,6 +61,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
]
};
let task: OperationalTask = {
execution_scope: "production",
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", priority: "urgent",
revision: 2, execution_generation: 1, execution_request_id: "85000000-0000-4000-8000-000000000001",
@@ -8,6 +8,133 @@ const chart = (index: number) => ({
});
test.setTimeout(60_000);
// Keep unrelated avatar animation out of chart geometry and drag measurements.
test.use({ reducedMotion: "reduce" });
test("cancelled drags release capture and preserve restore, resizing and keyboard controls", async ({ page }) => {
const task = investigationFixture(1, "图窗拖动取消核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await mock.emit(task, chart(1));
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
await expect(popup).toBeVisible();
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
const heading = popup.locator(".floating-window-heading");
await heading.click({ trial: true, position: { x: 200, y: 24 } });
const original = (await popup.boundingBox())!;
await heading.evaluate((node) => node.addEventListener("pointerdown", (event) => {
(node as HTMLElement).dataset.testPointerId = String((event as PointerEvent).pointerId);
}, { once: true }));
await page.mouse.move(original.x + 200, original.y + 24);
await page.mouse.down();
await page.mouse.move(original.x + 100, original.y + 64, { steps: 4 });
await expect.poll(async () => Math.round((await popup.boundingBox())!.x)).toBe(original.x - 100);
await heading.evaluate((node) => node.dispatchEvent(new PointerEvent("pointercancel", {
pointerId: Number((node as HTMLElement).dataset.testPointerId)
})));
await page.mouse.up();
expect(await popup.boundingBox()).toEqual(original);
expect(await popup.evaluate((node) => (node as HTMLElement).style.willChange)).toBe("");
await popup.getByRole("button", { name: "最大化窗口", exact: true }).click();
expect((await popup.boundingBox())!.x).toBe(8);
await popup.getByRole("button", { name: "还原窗口", exact: true }).click();
expect(await popup.boundingBox()).toEqual(original);
await heading.focus();
await page.keyboard.press("ArrowRight");
expect((await popup.boundingBox())!.x).toBe(original.x + 16);
await page.mouse.move(original.x + 16 + original.width - 3, original.y + original.height - 3);
await page.mouse.down();
await page.mouse.move(original.x + 16 + original.width + 37, original.y + original.height + 27, { steps: 4 });
await page.mouse.up();
expect((await popup.boundingBox())!.width).toBe(original.width + 40);
expect((await popup.boundingBox())!.height).toBe(original.height + 30);
await heading.focus();
await page.keyboard.press("Escape");
await expect(popup).toHaveCount(0);
expect(mock.mutations).toEqual([]);
});
test("profiles a single drag with eight comparison chart windows", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1600, height: 1000 });
const task = investigationFixture(1, "多图拖动性能核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
const slots = Array.from({ length: 13 }, (_, index) => `${String(5 + Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
for (let index = 1; index <= 8; index++) {
await mock.emit(task, { action: "open_chart", chart_id: `comparison-${index}`, evidence_refs: ["synthetic:pressure"], chart: {
title: `合成七日同期曲线 ${index}`, chart_type: "time_comparison", x_data: slots,
series: [{ name: "当前", type: "line", data: slots.map((_, point) => 30 - index + point / 10),
sample_times: slots.map((time) => `2026-07-16T${time}:00+08:00`) },
...Array.from({ length: 7 }, (_, day) => ({ name: `前${day + 1}天`, type: "scatter",
data: slots.map((_, point) => point < 10 ? null : 30 + day / 10),
sample_times: slots.map((time, point) => point < 10 ? null : `2026-07-${String(15 - day).padStart(2, "0")}T${time}:00+08:00`) }))]
} });
await expect(page.getByRole("dialog", { name: `合成七日同期曲线 ${index}`, exact: true })).toBeVisible();
}
await expect(page.locator("[data-floating-window]")).toHaveCount(8);
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await page.evaluate(() => document.fonts.ready);
const popup = page.getByRole("dialog", { name: "合成七日同期曲线 8", exact: true });
const otherWindows = page.locator('[data-floating-window]').filter({ hasNot: page.getByRole("heading", { name: "合成七日同期曲线 8", exact: true }) });
const otherPositions = await otherWindows.evaluateAll((nodes) => nodes.map((node) => {
const { x, y } = node.getBoundingClientRect();
return { x, y };
}));
const heading = (await popup.locator(".floating-window-heading").boundingBox())!;
const before = (await popup.boundingBox())!;
const cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
await cdp.send("Performance.enable");
const first = await cdp.send("Performance.getMetrics");
const events: Array<{ name: string; dur?: number }> = [];
cdp.on("Tracing.dataCollected", ({ value }) => events.push(...value));
await cdp.send("Tracing.start", { categories: "devtools.timeline", transferMode: "ReportEvents" });
await page.evaluate(() => {
Object.assign(window, { dragSvgMutations: 0, dragTransformWrites: 0 });
const observer = new MutationObserver((records) => {
(window as unknown as { dragSvgMutations: number }).dragSvgMutations += records.length;
});
document.querySelectorAll("[data-floating-window] svg").forEach((node) => observer.observe(node, { subtree: true, attributes: true, childList: true }));
const windowObserver = new MutationObserver((records) => {
for (const record of records) {
const previous = document.createElement("div");
previous.setAttribute("style", record.oldValue ?? "");
if (previous.style.transform !== (record.target as HTMLElement).style.transform)
(window as unknown as { dragTransformWrites: number }).dragTransformWrites++;
}
});
document.querySelectorAll("[data-floating-window]").forEach((node) => windowObserver.observe(node, { attributes: true, attributeFilter: ["style"], attributeOldValue: true }));
});
const x = heading.x + heading.width - 110;
const y = heading.y + heading.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x - 100, y - 60, { steps: 20 });
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-100);
await page.mouse.move(x - 200, y - 120, { steps: 20 });
await page.mouse.up();
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-200);
await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(-120);
const last = await cdp.send("Performance.getMetrics");
const finished = new Promise<void>((resolve) => cdp.once("Tracing.tracingComplete", () => resolve()));
await cdp.send("Tracing.end");
await finished;
const metric = (name: string) => (last.metrics.find((item) => item.name === name)?.value ?? 0) - (first.metrics.find((item) => item.name === name)?.value ?? 0);
const summary = { metrics: Object.fromEntries(["ScriptDuration", "LayoutDuration", "RecalcStyleDuration", "TaskDuration", "LayoutCount", "RecalcStyleCount"].map((name) => [name, metric(name)])),
trace: Object.fromEntries(["Paint", "Layout", "UpdateLayoutTree", "FunctionCall"].map((name) => [name, { count: events.filter((event) => event.name === name).length,
ms: events.filter((event) => event.name === name).reduce((total, event) => total + (event.dur ?? 0), 0) / 1000 }])),
svgMutations: await page.evaluate(() => (window as unknown as { dragSvgMutations: number }).dragSvgMutations),
transformWrites: await page.evaluate(() => (window as unknown as { dragTransformWrites: number }).dragTransformWrites) };
await testInfo.attach("drag-profile", { body: JSON.stringify(summary, null, 2), contentType: "application/json" });
console.info("Window drag metrics", JSON.stringify(summary));
expect(summary.transformWrites).toBeLessThanOrEqual(2);
expect(summary.svgMutations).toBe(0);
expect(await otherWindows.evaluateAll((nodes) => nodes.map((node) => {
const { x, y } = node.getBoundingClientRect();
return { x, y };
}))).toEqual(otherPositions);
await testInfo.attach("multiple-charts-after-drag", { body: await page.screenshot({ path: testInfo.outputPath("multiple-charts-after-drag.png") }), contentType: "image/png" });
});
for (const height of [900, 720]) {
test(`multiple chart headings stay accessible and drag on first press at ${height}px height`, async ({ page }, testInfo) => {
@@ -19,15 +146,18 @@ for (const height of [900, 720]) {
await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` });
await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible();
}
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
if (height === 900) await page.setViewportSize({ width: 1440, height: 720 });
for (let index = 1; index <= 4; index++) {
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
const heading = popup.locator(".floating-window-heading");
const box = (await heading.boundingBox())!;
expect((await popup.boundingBox())!.y).toBe(64 + (index - 1) * (box.height + 8));
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
expect(await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"), target))
.toBe(await popup.getAttribute("aria-labelledby"));
await expect.poll(async () => {
const current = (await heading.boundingBox())!;
return page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"),
{ x: current.x + current.width / 2, y: current.y + current.height / 2 });
}).toBe(await popup.getAttribute("aria-labelledby"));
}
await testInfo.attach(`multiple-charts-before-drag-${height}`, {
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png"
+193
View File
@@ -0,0 +1,193 @@
import { expect, test, type Page } from "@playwright/test";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
test.beforeEach(async ({ page }) => {
test.setTimeout(60_000);
await mockInvestigationPresentation(page, []);
});
async function openMap(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
}
async function clearInitialNotices(page: Page) {
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).dismissMapNotice();
});
await expect(page.locator("[data-sonner-toast]")).toHaveCount(0);
}
test("tile cancellation events during map navigation do not announce source outages", async ({ page }) => {
await openMap(page);
await clearInitialNotices(page);
await page.evaluate(() => {
const map = window.__waterNetworkMap!;
Object.assign(window, { noticeTileAborts: 0, noticeFalseOutages: 0 });
map.on("sourcedataabort", () => {
const state = window as unknown as { noticeTileAborts: number };
state.noticeTileAborts++;
});
const observer = new MutationObserver((records) => {
for (const record of records) for (const node of record.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
for (const notice of [node, ...node.querySelectorAll("[data-sonner-toast]")]) {
if (notice.matches("[data-sonner-toast]") && notice.textContent?.includes("GeoServer MVT 降级运行")) {
(window as unknown as { noticeFalseOutages: number }).noticeFalseOutages++;
}
}
}
});
observer.observe(document.body, { subtree: true, childList: true });
map.jumpTo({ zoom: 14 });
});
for (let i = 0; i < 3; i++) {
await page.mouse.move(220, 420);
await page.mouse.down();
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 5 });
await page.mouse.up();
// Deliver the same cancellation event MapLibre emits for obsolete tiles,
// independently of cache timing or how quickly the fixture responds.
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedataabort", { sourceId: "pipes" }));
await page.waitForTimeout(150);
}
await expect.poll(() => page.evaluate(() => (window as unknown as { noticeTileAborts: number }).noticeTileAborts)).toBeGreaterThan(0);
expect(await page.evaluate(() => (window as unknown as { noticeFalseOutages: number }).noticeFalseOutages)).toBe(0);
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 降级运行" })).toHaveCount(0);
});
test("a failed map source recovers in the same visible notice without entering removal", async ({ page }) => {
await openMap(page);
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
await page.waitForTimeout(350);
await clearInitialNotices(page);
await page.evaluate(() => window.__waterNetworkMap!.fire("error", { sourceId: "pipes", error: new Error("notice-regression-test") }));
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
await expect(notice).toBeVisible();
await expect(notice).toHaveCSS("opacity", "1");
const node = await notice.elementHandle();
await page.evaluate(() => window.__waterNetworkMap!.fire("sourcedata", { sourceId: "pipes", isSourceLoaded: true }));
await page.waitForTimeout(350);
expect(await node!.evaluate((element) => ({
connected: element.isConnected,
removed: element.getAttribute("data-removed"),
text: element.textContent
}))).toMatchObject({ connected: true, removed: "false", text: expect.stringContaining("GeoServer MVT 已连接") });
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
});
test("notice surfaces capture input above the task ticker while the outside map remains draggable", async ({ page }, testInfo) => {
await openMap(page);
await clearInitialNotices(page);
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).showMapNotice({ id: "notice-input-regression", tone: "warning", title: "通知交互回归",
message: "通知覆盖任务浮动条,滚轮与拖动由通知接收。", actionLabel: "查看任务", onAction: () => {} });
});
const notice = page.locator("[data-sonner-toast]").filter({ hasText: "通知交互回归" });
await expect(notice).toHaveCSS("opacity", "1");
const ticker = page.locator(".agent-task-ticker");
await expect(ticker).toBeVisible();
const point = await page.evaluate(() => {
const ticker = document.querySelector(".agent-task-ticker")!;
const r = ticker.getBoundingClientRect();
Object.assign(window, { noticeTickerWheelEvents: 0 });
ticker.addEventListener("wheel", () => {
(window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents++;
});
return { x: r.x + 170, y: r.y + 25 };
});
expect(await notice.evaluate((element, point) => element.contains(document.elementFromPoint(point.x, point.y)), point)).toBe(true);
const before = await notice.boundingBox();
await page.mouse.move(point.x, point.y);
await page.mouse.wheel(0, 120);
await page.mouse.down();
await page.mouse.move(point.x + 35, point.y + 15, { steps: 4 });
await page.mouse.up();
await expect(notice).toHaveCount(1);
expect(await notice.boundingBox()).toEqual(before);
expect(await page.evaluate(() => (window as unknown as { noticeTickerWheelEvents: number }).noticeTickerWheelEvents)).toBe(0);
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
await page.mouse.move(220, 420);
await page.mouse.down();
await page.mouse.move(330, 450, { steps: 6 });
await page.mouse.up();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
await testInfo.attach("notice-above-ticker", { body: await page.screenshot({ path: testInfo.outputPath("notice-priority.png") }), contentType: "image/png" });
await notice.getByRole("button", { name: "关闭通知" }).click();
await expect(notice).toHaveCount(0);
});
test("multiple notices stay anchored while panning and source recovery exits without a downward jump", async ({ page }, testInfo) => {
await openMap(page);
await clearInitialNotices(page);
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
const notices = await import(path);
notices.showMapNotice({ id: "scada-position-regression", tone: "error", title: "SCADA 位置回归",
message: "监测设备暂不可用。", duration: Infinity });
notices.showMapNotice({ id: "geoserver-position-regression", tone: "error", title: "GeoServer MVT 不可用",
message: "地图数据源请求失败。", duration: Infinity });
});
const recovery = page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 不可用" });
await expect(recovery).toBeVisible();
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).showMapNotice({ id: "report-position-regression", title: "报告位置回归",
message: "报告入口持续保留。", actionLabel: "查看报告", onAction: () => {} });
});
const scada = page.locator("[data-sonner-toast]").filter({ hasText: "SCADA 位置回归" });
const report = page.locator("[data-sonner-toast]").filter({ hasText: "报告位置回归" });
await expect(report).toHaveCSS("opacity", "1");
await expect.poll(() => page.locator("[data-sonner-toaster]").evaluate((element) =>
element.getAnimations({ subtree: true }).filter((animation) => animation.playState === "running").length
)).toBe(0);
const boxes = await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()]);
const center = await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray());
const track = await recovery.evaluateHandle((element) => {
const cards = ["报告位置回归", "GeoServer MVT 不可用", "SCADA 位置回归"].map((title) =>
[...document.querySelectorAll("[data-sonner-toast]")].find((card) => card.textContent?.includes(title))!);
const state = { stopped: false, panning: true, panY: [] as number[][], liveY: [] as number[], exitY: [] as number[] };
const sample = () => {
if (state.stopped || !element.isConnected) return;
if (state.panning) state.panY.push(cards.map((card) => card.getBoundingClientRect().y));
const y = element.getBoundingClientRect().y;
(element.getAttribute("data-removed") === "true" ? state.exitY : state.liveY).push(y);
requestAnimationFrame(sample);
};
sample();
return state;
});
for (let i = 0; i < 3; i++) {
await page.mouse.move(220, 420);
await page.mouse.down();
await page.mouse.move(i % 2 ? 450 : 750, 450, { steps: 8 });
await page.mouse.up();
}
expect(await page.evaluate(() => window.__waterNetworkMap!.getCenter().toArray())).not.toEqual(center);
expect(await Promise.all([report.boundingBox(), recovery.boundingBox(), scada.boundingBox()])).toEqual(boxes);
await track.evaluate((state) => { state.panning = false; });
await page.evaluate(async () => {
const path = "/src/features/map/core/components/notice-actions.tsx";
(await import(path)).showMapNotice({ id: "geoserver-position-regression", tone: "success", title: "GeoServer MVT 已连接",
message: "地图数据源已恢复正常。", duration: 700 });
});
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toBeVisible();
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "GeoServer MVT 已连接" })).toHaveCount(0, { timeout: 7000 });
const samples = await track.evaluate((state) => { state.stopped = true; return state; });
expect(samples.panY.length).toBeGreaterThan(0);
for (const positions of samples.panY) {
positions.forEach((y, index) => expect(Math.abs(y - boxes[index]!.y)).toBeLessThan(1));
}
expect(samples.liveY.length).toBeGreaterThan(0);
expect(samples.exitY.length).toBeGreaterThan(0);
for (const y of [...samples.liveY, ...samples.exitY]) expect(y).toBeLessThanOrEqual(boxes[1]!.y + 1);
expect((await report.boundingBox())!.y).toBe(boxes[0]!.y);
const reportBox = (await report.boundingBox())!;
const scadaBox = (await scada.boundingBox())!;
expect(scadaBox.y).toBeCloseTo(reportBox.y + reportBox.height + 8, 0);
await testInfo.attach("notice-positions-after-pan-and-recovery", {
body: await page.screenshot({ path: testInfo.outputPath("notice-positions.png") }), contentType: "image/png"
});
});
@@ -66,6 +66,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
execution_detail: null
};
let task: OperationalTask = {
execution_scope: "production",
project_id: projectId,
task_id: taskId,
task_type: "event_investigation",
@@ -3,13 +3,13 @@ import type { OperationalTask, OperationalTaskSummary } from "../../src/features
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("调查和预约任务接收 Server 返回的空回放上下文并展示详情", async ({ page }, testInfo) => {
test("调查和预约任务接收 Server 返回的执行范围与空回放上下文并展示详情", async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "reduce" });
const now = new Date().toISOString();
const id = "72000000-0000-4000-8000-000000000001";
const appointmentId = "72000000-0000-4000-8000-000000000002";
const summary: OperationalTaskSummary = {
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", task_id: id,
execution_scope: "production", project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e", task_id: id,
task_type: "event_investigation", workflow: "burst_report",
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human",
priority: "urgent", revision: 1, execution_generation: 1, execution_request_id: id,
@@ -65,7 +65,7 @@ test("调查和预约任务接收 Server 返回的空回放上下文并展示详
await panel.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText(summary.title, { exact: true }).first().click();
await expect(panel.getByTestId("scheduled-condition-detail-scroll")).toContainText(summary.summary);
await testInfo.attach("investigation-loaded", { body: await panel.screenshot(), contentType: "image/png" });
await testInfo.attach("investigation-loaded", { body: await panel.screenshot({ path: testInfo.outputPath("investigation-loaded.png") }), contentType: "image/png" });
await panel.getByRole("button", { name: /预约任务/ }).click();
await panel.getByText(appointment.title, { exact: true }).first().click();
@@ -73,5 +73,5 @@ test("调查和预约任务接收 Server 返回的空回放上下文并展示详
await expect(detail).toContainText("核对预约任务详情");
await expect(detail).toContainText("凤阳维修组");
await expect(detail).toContainText("凤阳核验点");
await testInfo.attach("appointment-loaded", { body: await panel.screenshot(), contentType: "image/png" });
await testInfo.attach("appointment-loaded", { body: await panel.screenshot({ path: testInfo.outputPath("appointment-loaded.png") }), contentType: "image/png" });
});
@@ -1267,6 +1267,7 @@ test.describe("外围系统", () => {
if (request.method() === "POST" && url.pathname.endsWith("/operational-tasks")) {
const input = request.postDataJSON() as Record<string, unknown>;
operationalTask = {
execution_scope: "production",
project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001",
task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
@@ -17,6 +17,7 @@ async function expectPushedOrder(page: Page, failFirstDetail = false) {
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
};
const task = {
execution_scope: "production",
project_id: TEST_PROJECT_ID, task_id: taskId, task_type: "external_work_order",
workflow: "external-work-order", lifecycle_status: "open", active_run: null,
status: "queued", priority: "normal", revision: 2, execution_generation: 1,
@@ -11,6 +11,7 @@ export function investigationFixture(index: number, title: string): OperationalT
const runId = `93000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
const requestId = `94000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
return {
execution_scope: "production",
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "burst_report",
lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
execution_request_id: requestId, title, summary: "仅供浏览器测试的合成调查",