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
13 changed files with 260 additions and 10 deletions
@@ -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", const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
...(generation === undefined ? {} : { execution_generation: generation }) }; ...(generation === undefined ? {} : { execution_generation: generation }) };
return { return {
execution_scope: "production",
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint", project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null, lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2, status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
@@ -42,6 +43,28 @@ function task(generation: number | null | undefined = 2) {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => { 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) => { describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => {
const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 }; 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", () => { it("keeps unknown fields and invalid generations rejected", () => {
expect(() => parseOperationalTask(task(0))).toThrow(); expect(() => parseOperationalTask(task(0))).toThrow();
const value = task(); const value = task();
expect(() => parseOperationalTask({ ...value, unexpected: true })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, 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, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).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() next_cursor: z.string().min(1).nullable()
}).strict(); }).strict();
const taskSummarySchema = z.object({ const taskSummarySchema = z.object({
execution_scope: z.string().min(1),
handling_context: z.record(z.unknown()).optional(), handling_context: z.record(z.unknown()).optional(),
project_id: z.string().uuid(), project_id: z.string().uuid(),
task_id: z.string().uuid(), task_id: z.string().uuid(),
@@ -62,6 +62,7 @@ describe("execution step cards", () => {
}); });
const taskBase = { const taskBase = {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111", project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222", task_id: "22222222-2222-4222-8222-222222222222",
task_type: "event_investigation", task_type: "event_investigation",
@@ -9,6 +9,7 @@ import {
function task(taskType: OperationalTask["task_type"]): OperationalTask { function task(taskType: OperationalTask["task_type"]): OperationalTask {
return { return {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111", project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222", task_id: "22222222-2222-4222-8222-222222222222",
task_type: taskType, task_type: taskType,
+7 -3
View File
@@ -4,6 +4,7 @@ import { Rnd } from "react-rnd";
import { Maximize2, Minimize2, X } from "lucide-react"; import { Maximize2, Minimize2, X } from "lucide-react";
import { Button } from "./button"; import { Button } from "./button";
import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer"; 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 }; 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 const current = maximized
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 } ? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
: bounds; : bounds;
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
width: current.width, height: current.height,
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
return <> return <>
<Button ref={trigger} type="button" variant="ghost" size="icon-sm" className="shrink-0" <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)} bounds="parent" minWidth={Math.min(320, viewport.width - 16)} minHeight={Math.min(260, viewport.height - 16)}
maxWidth={viewport.width - 16} maxHeight={viewport.height - 16} maxWidth={viewport.width - 16} maxHeight={viewport.height - 16}
dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a" dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a"
disableDragging={maximized} enableResizing={!maximized} disableDragging enableResizing={!maximized}
onDragStop={(_, position) => setBounds((previous) => fitWindow({ ...previous, x: position.x, y: position.y }))}
onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))} onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))}
style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }} style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }}
resizeHandleClasses={{ bottomRight: "floating-window-resize" }} 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(); } if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
}}> }}>
<div ref={heading} tabIndex={0} <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 加方向键调整大小" aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; 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);
};
}
@@ -61,6 +61,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
] ]
}; };
let task: OperationalTask = { let task: OperationalTask = {
execution_scope: "production",
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint", 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", 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", 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); 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]) { for (const height of [900, 720]) {
test(`multiple chart headings stay accessible and drag on first press at ${height}px height`, async ({ page }, testInfo) => { 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 mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` });
await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible(); 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 }); if (height === 900) await page.setViewportSize({ width: 1440, height: 720 });
for (let index = 1; index <= 4; index++) { for (let index = 1; index <= 4; index++) {
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true }); const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
const heading = popup.locator(".floating-window-heading"); const heading = popup.locator(".floating-window-heading");
const box = (await heading.boundingBox())!; const box = (await heading.boundingBox())!;
expect((await popup.boundingBox())!.y).toBe(64 + (index - 1) * (box.height + 8)); 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 }; await expect.poll(async () => {
expect(await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"), target)) const current = (await heading.boundingBox())!;
.toBe(await popup.getAttribute("aria-labelledby")); 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}`, { await testInfo.attach(`multiple-charts-before-drag-${height}`, {
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png" body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png"
@@ -66,6 +66,7 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
execution_detail: null execution_detail: null
}; };
let task: OperationalTask = { let task: OperationalTask = {
execution_scope: "production",
project_id: projectId, project_id: projectId,
task_id: taskId, task_id: taskId,
task_type: "event_investigation", task_type: "event_investigation",
@@ -3,13 +3,13 @@ import type { OperationalTask, OperationalTaskSummary } from "../../src/features
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { mockWorkbenchContext } from "./support/mock-workbench-context";
test("调查和预约任务接收 Server 返回的空回放上下文并展示详情", async ({ page }, testInfo) => { test("调查和预约任务接收 Server 返回的执行范围与空回放上下文并展示详情", async ({ page }, testInfo) => {
await page.emulateMedia({ reducedMotion: "reduce" }); await page.emulateMedia({ reducedMotion: "reduce" });
const now = new Date().toISOString(); const now = new Date().toISOString();
const id = "72000000-0000-4000-8000-000000000001"; const id = "72000000-0000-4000-8000-000000000001";
const appointmentId = "72000000-0000-4000-8000-000000000002"; const appointmentId = "72000000-0000-4000-8000-000000000002";
const summary: OperationalTaskSummary = { 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", task_type: "event_investigation", workflow: "burst_report",
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", lifecycle_status: "waiting_human", active_run: null, status: "waiting_human",
priority: "urgent", revision: 1, execution_generation: 1, execution_request_id: id, 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.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText(summary.title, { exact: true }).first().click(); await panel.getByText(summary.title, { exact: true }).first().click();
await expect(panel.getByTestId("scheduled-condition-detail-scroll")).toContainText(summary.summary); 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.getByRole("button", { name: /预约任务/ }).click();
await panel.getByText(appointment.title, { exact: true }).first().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 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")) { if (request.method() === "POST" && url.pathname.endsWith("/operational-tasks")) {
const input = request.postDataJSON() as Record<string, unknown>; const input = request.postDataJSON() as Record<string, unknown>;
operationalTask = { operationalTask = {
execution_scope: "production",
project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001", project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001",
task_type: "event_investigation", workflow: "water_quality_complaint", task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null, 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: [] conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
}; };
const task = { const task = {
execution_scope: "production",
project_id: TEST_PROJECT_ID, task_id: taskId, task_type: "external_work_order", project_id: TEST_PROJECT_ID, task_id: taskId, task_type: "external_work_order",
workflow: "external-work-order", lifecycle_status: "open", active_run: null, workflow: "external-work-order", lifecycle_status: "open", active_run: null,
status: "queued", priority: "normal", revision: 2, execution_generation: 1, 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 runId = `93000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
const requestId = `94000000-0000-4000-8000-${String(index).padStart(12, "0")}`; const requestId = `94000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
return { return {
execution_scope: "production",
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "burst_report", 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, lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
execution_request_id: requestId, title, summary: "仅供浏览器测试的合成调查", execution_request_id: requestId, title, summary: "仅供浏览器测试的合成调查",