Compare commits

..
Author SHA1 Message Date
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
jiang e0fe4a5a10 fix(charts): preserve comparison types and draggable window headings
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m11s
Keep current lines and historical scatter points in a dedicated comparison chart type. Expose floating headings and use idempotent placement so StrictMode no longer masks production overlap.
2026-11-08 03:04:14 +08:00
13 changed files with 345 additions and 30 deletions
+3 -1
View File
@@ -56,7 +56,9 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。 通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。 通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。
## 2. Tailwind 定制方式 ## 2. Tailwind 定制方式
@@ -31,7 +31,7 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
const start = index(proposed[0], labels.length); const start = index(proposed[0], labels.length);
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1)); const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
const hidden = indices(state.hidden, series.length); const hidden = indices(state.hidden, series.length);
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar", const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type === "bar" ? "bar" : "line",
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true }; hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
const data = answerChartTableData(node, chart); const data = answerChartTableData(node, chart);
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState; const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
+2 -2
View File
@@ -64,7 +64,7 @@ export const answerUiCatalog = defineCatalog(schema, {
props: z props: z
.object({ .object({
title: shortText, title: shortText,
chart_type: z.enum(["line", "bar"]), chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(), x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(), y_axis_name: z.string().max(128).optional(),
x_data: z.array(shortText).min(1).max(100), x_data: z.array(shortText).min(1).max(100),
@@ -82,7 +82,7 @@ export const answerUiCatalog = defineCatalog(schema, {
}) })
.strict(), .strict(),
description: description:
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。" "展示真实数据。chart_type=line 为普通折线,bar 为柱状图,time_comparison 为当前曲线与历史散点的同期对比。同期对比的 x_data 为 UTC+8 的 HH:mm 或 HH:mm:ss 共享时刻槽,series.type 使用 line/scatter,每组 sample_times 与 data 等长,保留真实带时区日期,缺测均填 null;历史点放入对应时刻槽。浏览者可切换图形与数据表。"
}, },
Evidence: { Evidence: {
props: z props: z
@@ -16,7 +16,7 @@ import { AnswerInteractionContext } from "./answer-ui-interaction-context";
export type AnswerChartProps = { export type AnswerChartProps = {
title: string; title: string;
chart_type: "line" | "bar"; chart_type: "line" | "bar" | "time_comparison";
x_data: string[]; x_data: string[];
series: SafeChartSeries[]; series: SafeChartSeries[];
x_axis_name?: string; x_axis_name?: string;
@@ -100,7 +100,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const numberLabel = chartValueLabel; const numberLabel = chartValueLabel;
const option = (type: "line" | "bar") => { const option = (type: "line" | "bar") => {
const horizontal = type === "bar" && horizontalBars; const horizontal = type === "bar" && horizontalBars;
const seriesType = (series: SafeChartSeries) => view === props.chart_type ? series.type ?? type : type; const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
const includesBars = visible.some((series) => seriesType(series) === "bar"); const includesBars = visible.some((series) => seriesType(series) === "bar");
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar", const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto", axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
@@ -15,7 +15,7 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
const chartSchema = z const chartSchema = z
.object({ .object({
title: z.string().max(200), title: z.string().max(200),
chart_type: z.enum(["line", "bar"]), chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(), x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(), y_axis_name: z.string().max(128).optional(),
x_data: z.array(z.string().max(128)).min(1).max(100), x_data: z.array(z.string().max(128)).min(1).max(100),
@@ -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 { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions"; import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types"; import type { MapNoticeTone } from "./notice-types";
@@ -17,10 +17,11 @@ export function MapToaster() {
position="top-center" position="top-center"
offset={{ top: 84, right: 16, bottom: 48, left: 16 }} offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }} mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
swipeDirections={[]}
toastOptions={{ toastOptions={{
unstyled: true, unstyled: true,
classNames: { classNames: {
toast: "group pointer-events-none", toast: "group pointer-events-auto",
closeButton: 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" "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 }) { export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useNoticeCleanup(id);
useEffect(() => { useEffect(() => {
showMapNotice({ showMapNotice({
id, id,
@@ -38,8 +40,6 @@ export function MapErrorNotice({ message, id = "map-error" }: { message: string;
message, message,
duration: Infinity duration: Infinity
}); });
return () => dismissMapNotice(id);
}, [id, message]); }, [id, message]);
return null; return null;
@@ -54,6 +54,7 @@ export function MapLoadingNotice({
title?: string; title?: string;
id?: string | number; id?: string | number;
}) { }) {
useNoticeCleanup(id);
useEffect(() => { useEffect(() => {
showMapNotice({ showMapNotice({
id, id,
@@ -63,8 +64,6 @@ export function MapLoadingNotice({
duration: Infinity, duration: Infinity,
dismissible: false dismissible: false
}); });
return () => dismissMapNotice(id);
}, [id, message, title]); }, [id, message, title]);
return null; return null;
@@ -89,7 +88,16 @@ export function MapSourceStatusNotice({
actionLabel, actionLabel,
onAction onAction
}: MapSourceStatusNoticeProps) { }: MapSourceStatusNoticeProps) {
const previous = useRef<{ id: string | number; status: MapSourceStatus } | null>(null);
useNoticeCleanup(id);
useEffect(() => { 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({ showMapNotice({
id, id,
tone: getSourceStatusTone(status), tone: getSourceStatusTone(status),
@@ -99,13 +107,17 @@ export function MapSourceStatusNotice({
actionLabel, actionLabel,
onAction onAction
}); });
return () => dismissMapNotice(id);
}, [actionLabel, id, message, onAction, sourceName, status]); }, [actionLabel, id, message, onAction, sourceName, status]);
return null; 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 { function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") { if (status === "online") {
return "success"; return "success";
@@ -416,9 +416,8 @@ export function useWorkbenchMap({
updateStatusFromSourceEvent(event, "online", setSourceStatuses); updateStatusFromSourceEvent(event, "online", setSourceStatuses);
}); });
map.on("sourcedataabort", (event) => { // Moving or zooming cancels obsolete tile requests. Cancellation is part
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses); // of navigation; source failures are reported by the error handler below.
});
map.on("error", (event) => { map.on("error", (event) => {
const message = event.error?.message ?? "地图渲染异常"; const message = event.error?.message ?? "地图渲染异常";
+20 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Rnd } from "react-rnd"; import { Rnd } from "react-rnd";
import { Maximize2, Minimize2, X } from "lucide-react"; import { Maximize2, Minimize2, X } from "lucide-react";
@@ -48,16 +48,31 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
}; };
useEffect(() => () => removeFloatingWindow(id), [id]); useEffect(() => () => removeFloatingWindow(id), [id]);
useEffect(() => { useLayoutEffect(() => {
if (!open) return; if (!open) return;
const offset = (floatingWindowOrder().filter((windowId) => windowId !== id).length % 5) * 24; const others = floatingWindowOrder().filter((windowId) => windowId !== id);
setBounds((current) => fitWindow({ ...current, x: current.x + offset, y: current.y + offset })); const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
.reduce((height, element) => Math.max(height, element.offsetHeight), 48);
const step = headingHeight + 8;
const minHeight = Math.min(260, window.innerHeight - 16);
const slots = Math.max(1, Math.floor((window.innerHeight - 64 - minHeight - 8) / step) + 1);
const slot = others.length % slots;
const y = Math.max(8, Math.min(64 + slot * step, window.innerHeight - minHeight - 8));
// Keep the preceding headings exposed. Shorter viewports reduce content
// height instead of clamping several windows onto the same title bar.
// Absolute placement also makes StrictMode's repeated setup idempotent.
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + slot * 24, y,
width: 800, height: Math.min(560, window.innerHeight - y - 8) }));
raiseFloatingWindow(id); raiseFloatingWindow(id);
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true }); if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
focusOnOpen.current = false; focusOnOpen.current = false;
const resize = () => { const resize = () => {
setViewport({ width: window.innerWidth, height: window.innerHeight }); setViewport({ width: window.innerWidth, height: window.innerHeight });
setBounds((current) => fitWindow(current)); setBounds((current) => {
const minimumHeight = Math.min(260, window.innerHeight - 16);
const y = Math.max(8, Math.min(current.y, window.innerHeight - minimumHeight - 8));
return fitWindow({ ...current, y, height: Math.min(current.height, window.innerHeight - y - 8) });
});
}; };
window.addEventListener("resize", resize); window.addEventListener("resize", resize);
return () => { return () => {
@@ -75,9 +90,7 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open} aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open}
onClick={() => { onClick={() => {
if (!open) { if (!open) {
const offset = (order.length % 5) * 24;
setViewport({ width: window.innerWidth, height: window.innerHeight }); setViewport({ width: window.innerWidth, height: window.innerHeight });
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + offset, y: 64 + offset, width: 800, height: 560 }));
setMaximized(false); setMaximized(false);
focusOnOpen.current = true; focusOnOpen.current = true;
setManuallyOpen(true); setManuallyOpen(true);
+2 -2
View File
@@ -2445,7 +2445,7 @@ details[open] > summary > .disclosure-accordion-indicator {
height: min(720px, 80dvh); height: min(720px, 80dvh);
max-width: 760px; 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 { [data-sonner-toaster].map-notice-stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2461,7 +2461,7 @@ details[open] > summary > .disclosure-accordion-indicator {
bottom: auto; bottom: auto;
flex-shrink: 0; flex-shrink: 0;
height: auto; height: auto;
--y: translateY(0); transform: none;
} }
[data-sonner-toaster].map-notice-stack [data-sonner-toast][data-visible="false"] { [data-sonner-toaster].map-notice-stack [data-sonner-toast][data-visible="false"] {
display: none; display: none;
@@ -9,6 +9,50 @@ const chart = (index: number) => ({
test.setTimeout(60_000); test.setTimeout(60_000);
for (const height of [900, 720]) {
test(`multiple chart headings stay accessible and drag on first press at ${height}px height`, async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height });
const task = investigationFixture(1, "多图窗口拖动核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
for (let index = 1; index <= 4; index++) {
await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` });
await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible();
}
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 testInfo.attach(`multiple-charts-before-drag-${height}`, {
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png"
});
for (const index of [1, 4, 2]) {
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
const before = (await popup.boundingBox())!;
const heading = (await popup.locator(".floating-window-heading").boundingBox())!;
const start = { x: heading.x + heading.width - 110, y: heading.y + heading.height / 2 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x - 120, start.y - 32, { steps: 12 });
await page.mouse.up();
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-120);
await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(-32);
expect((await popup.boundingBox())!.width).toBe(before.width);
expect((await popup.boundingBox())!.height).toBe(before.height);
}
await testInfo.attach(`multiple-charts-after-drag-${height}`, {
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-after-drag-${height}.png`) }), contentType: "image/png"
});
expect(mock.mutations).toEqual([]);
});
}
test("live analysis opens a window, preserves its position and respects closing and task switches", async ({ page }, testInfo) => { test("live analysis opens a window, preserves its position and respects closing and task switches", async ({ page }, testInfo) => {
const task = investigationFixture(1, "图表自动展示核查"); const task = investigationFixture(1, "图表自动展示核查");
const other = investigationFixture(2, "另一项调查"); const other = investigationFixture(2, "另一项调查");
+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"
});
});
+3 -3
View File
@@ -9,8 +9,8 @@ const times = Array.from({ length: 13 }, (_, index) => `${String(9 + Math.floor(
const block = { const block = {
version: "answer-ui@1", block_id: "scada-week", phase: "final", fallback_text: "合成 SCADA 七日对照", version: "answer-ui@1", block_id: "scada-week", phase: "final", fallback_text: "合成 SCADA 七日对照",
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: { spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
title: "压力与前七天同时刻对照(m)", chart_type: "line", y_axis_name: "压力(m)", x_axis_name: "时间(UTC0)", title: "压力与前七天同时刻对照(m)", chart_type: "time_comparison", y_axis_name: "压力(m)", x_axis_name: "时间",
x_data: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().slice(11, 16)), x_data: times,
series: [{ name: "当日三小时", type: "line", data: [34, 34.2, 33.8, 34, 33.5, 33, 32.5, 32, 31, 30, 29, 27, 25], series: [{ name: "当日三小时", type: "line", data: [34, 34.2, 33.8, 34, 33.5, 33, 32.5, 32, 31, 30, 29, 27, 25],
sample_times: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().replace(".000Z", "Z")) }, sample_times: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().replace(".000Z", "Z")) },
...Array.from({ length: 7 }, (_, index) => ({ name: `6月${13 - index}日`, type: "scatter", ...Array.from({ length: 7 }, (_, index) => ({ name: `6月${13 - index}日`, type: "scatter",
@@ -45,7 +45,7 @@ for (const width of [1440, 375, 320]) {
await panel.getByRole("button", { name: "发送 Agent 指令" }).click(); await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
const ui = panel.locator(".answer-ui-chart"); const ui = panel.locator(".answer-ui-chart");
await expect(ui).toBeVisible(); await expect(ui).toBeVisible();
await expect(ui).toContainText("时间(UTC+8)"); await expect(ui.locator("svg text").filter({ hasText: /^时间$/ })).toBeVisible();
await expect(ui.getByRole("tab", { name: "复合", exact: true })).toBeVisible(); await expect(ui.getByRole("tab", { name: "复合", exact: true })).toBeVisible();
await expect(ui.getByLabel("显示的数据序列").getByRole("button")).toHaveCount(8); await expect(ui.getByLabel("显示的数据序列").getByRole("button")).toHaveCount(8);
// Each historical day has exactly three unconnected SVG scatter symbols. // Each historical day has exactly three unconnected SVG scatter symbols.