feat: extend agent-driven map workbench
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { expect, test, type Locator } from "playwright/test";
|
||||
|
||||
test("agent header reveals the shell acrylic without nesting another filter", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
const topBar = page.locator("header.acrylic-navigation");
|
||||
const agentPanel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const agentHeader = agentPanel.locator(".agent-panel-header");
|
||||
|
||||
await expect(topBar).toBeVisible();
|
||||
await expect(agentPanel).toBeVisible();
|
||||
await expect(agentHeader).toBeVisible();
|
||||
|
||||
await expectAcrylicSurface(topBar);
|
||||
await expectAcrylicSurface(agentPanel);
|
||||
await expectTransparentAcrylicContext(agentHeader);
|
||||
await expectLightweightContextSurfaces(agentPanel.locator(".agent-panel-conversation"));
|
||||
await expectSolidInternalSurfaces(agentPanel.locator(".agent-panel-band"));
|
||||
});
|
||||
|
||||
async function expectAcrylicSurface(shell: Locator) {
|
||||
const composition = await shell.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const filteredDescendants: string[] = [];
|
||||
|
||||
for (const descendant of element.querySelectorAll("*")) {
|
||||
if (getComputedStyle(descendant).backdropFilter !== "none") {
|
||||
filteredDescendants.push(descendant.className);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor,
|
||||
filteredDescendants,
|
||||
transform: style.transform,
|
||||
willChange: style.willChange
|
||||
};
|
||||
});
|
||||
|
||||
expect(composition.backdropFilter).toContain("blur(");
|
||||
expect(getAlpha(composition.backgroundColor)).toBeLessThan(1);
|
||||
expect(composition.filteredDescendants).toEqual([]);
|
||||
expect(composition.transform).toBe("none");
|
||||
expect(composition.willChange).toBe("auto");
|
||||
}
|
||||
|
||||
function getAlpha(color: string) {
|
||||
const match = color.match(/^rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)$/);
|
||||
|
||||
return match ? Number(match[1]) : 1;
|
||||
}
|
||||
|
||||
async function expectTransparentAcrylicContext(header: Locator) {
|
||||
const style = await header.evaluate((element) => {
|
||||
const computedStyle = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: computedStyle.backdropFilter,
|
||||
backgroundColor: computedStyle.backgroundColor,
|
||||
className: element.className,
|
||||
webkitBackdropFilter: computedStyle.getPropertyValue("-webkit-backdrop-filter")
|
||||
};
|
||||
});
|
||||
|
||||
expect(style.backdropFilter).toBe("none");
|
||||
expect(style.backgroundColor).toBe("rgba(0, 0, 0, 0)");
|
||||
expect(["", "none"]).toContain(style.webkitBackdropFilter);
|
||||
expect(String(style.className)).not.toContain("acrylic-");
|
||||
}
|
||||
|
||||
async function expectLightweightContextSurfaces(surfaces: Locator) {
|
||||
const surfaceStyles = await surfaces.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
backgroundColor: style.backgroundColor
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
expect(surfaceStyles.length).toBeGreaterThan(0);
|
||||
for (const style of surfaceStyles) {
|
||||
expect(style.backdropFilter).toBe("none");
|
||||
expect(getAlpha(style.backgroundColor)).toBeLessThanOrEqual(0.12);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSolidInternalSurfaces(surfaces: Locator) {
|
||||
const surfaceStyles = await surfaces.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
return {
|
||||
backdropFilter: style.backdropFilter,
|
||||
className: element.className,
|
||||
webkitBackdropFilter: style.getPropertyValue("-webkit-backdrop-filter")
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
expect(surfaceStyles.length).toBeGreaterThan(0);
|
||||
for (const style of surfaceStyles) {
|
||||
expect(style.backdropFilter).toBe("none");
|
||||
expect(["", "none"]).toContain(style.webkitBackdropFilter);
|
||||
expect(String(style.className)).not.toContain("acrylic-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test("Agent panel resizes by drag without exceeding half the viewport", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const panel = page.locator('aside[aria-label="Agent 命令面板"]');
|
||||
const resizeHandle = page.getByRole("separator", { name: "调整 Agent 面板宽度" });
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(resizeHandle).toBeVisible();
|
||||
|
||||
const handleBox = await resizeHandle.boundingBox();
|
||||
expect(handleBox).not.toBeNull();
|
||||
|
||||
await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(1_200, handleBox!.y + handleBox!.height / 2);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(720);
|
||||
|
||||
await resizeHandle.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(460);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width).toBe(476);
|
||||
});
|
||||
|
||||
test("Agent resize handle stays hidden in the mobile layout", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("separator", { name: "调整 Agent 面板宽度" })).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test("speech capability detection preserves the server hydration tree", async ({ page }) => {
|
||||
const hydrationErrors: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error" && /hydration|server rendered html/i.test(message.text())) {
|
||||
hydrationErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, "SpeechRecognition", {
|
||||
configurable: true,
|
||||
value: class SpeechRecognition {}
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "networkidle" });
|
||||
|
||||
await expect(page.getByRole("button", { name: "语音输入" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "权限批准模式" })).toBeVisible();
|
||||
expect(hydrationErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("speech recognition failure clears the active animation and explains the error", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, "SpeechRecognition", {
|
||||
configurable: true,
|
||||
value: class SpeechRecognition {
|
||||
onerror: ((event: { error: string }) => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
|
||||
start() {
|
||||
window.setTimeout(() => {
|
||||
this.onerror?.({ error: "not-allowed" });
|
||||
this.onend?.();
|
||||
}, 20);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.onend?.();
|
||||
}
|
||||
|
||||
abort() {}
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "networkidle" });
|
||||
|
||||
await page.getByRole("button", { name: "语音输入" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "语音输入" })).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.locator('[data-slot="voice-input-pulse"]')).toHaveCount(0);
|
||||
await expect(page.getByText("无法使用麦克风,请在浏览器地址栏允许麦克风权限后重试")).toBeVisible();
|
||||
});
|
||||
|
||||
test("speech recognition writes final transcript into the prompt", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(window, "SpeechRecognition", {
|
||||
configurable: true,
|
||||
value: class SpeechRecognition {
|
||||
onresult: ((event: { resultIndex: number; results: Array<Array<{ transcript: string }> & { isFinal: boolean }> }) => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
|
||||
start() {
|
||||
window.setTimeout(() => {
|
||||
const result = Object.assign([{ transcript: "检查城南泵站水位" }], { isFinal: true });
|
||||
this.onresult?.({ resultIndex: 0, results: [result] });
|
||||
}, 20);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.onend?.();
|
||||
}
|
||||
|
||||
abort() {}
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.goto("/", { waitUntil: "networkidle" });
|
||||
|
||||
await page.getByRole("button", { name: "语音输入" }).click();
|
||||
|
||||
await expect(page.getByPlaceholder("输入调度问题,Agent 将通过后端会话流式响应")).toHaveValue("检查城南泵站水位");
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
|
||||
test("Dev Panel opens on desktop, owns the right workspace, and closes with Escape", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap));
|
||||
const trigger = page.getByRole("button", { name: "切换地图 Dev Panel" });
|
||||
await expect(trigger).toBeVisible();
|
||||
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeVisible();
|
||||
await trigger.click();
|
||||
|
||||
const panel = page.getByTestId("map-dev-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveCSS("width", "400px");
|
||||
await expect(page.getByRole("navigation", { name: "地图工具" })).toBeHidden();
|
||||
await expect(panel.getByRole("button", { name: "定位并高亮" })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "重置全部" })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
});
|
||||
|
||||
test("Dev entry is absent below the desktop breakpoint", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("button", { name: "切换地图 Dev Panel" })).toBeHidden();
|
||||
await expect(page.getByTestId("map-dev-panel")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { expect, test, type Page } from "playwright/test";
|
||||
|
||||
test("frontend actions replace SCADA analysis results and remain legible at 375px", async ({ page }) => {
|
||||
const receipts: Array<Record<string, unknown>> = [];
|
||||
await mockAgentActions(page, receipts);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => Boolean(
|
||||
window.__waterNetworkMap?.getSource("agent-scada-analysis")
|
||||
));
|
||||
await expect(
|
||||
page.getByRole("button", { name: "选择 Agent 模型" }).filter({ visible: true }).first()
|
||||
).toContainText("测试模型");
|
||||
|
||||
await page.evaluate(() => {
|
||||
const map = window.__waterNetworkMap;
|
||||
[
|
||||
"agent-scada-analysis-label",
|
||||
"agent-scada-analysis-indicator",
|
||||
"agent-scada-analysis-level",
|
||||
"agent-scada-analysis-casing"
|
||||
].forEach((id) => {
|
||||
if (map?.getLayer(id)) map.removeLayer(id);
|
||||
});
|
||||
});
|
||||
|
||||
const prompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first();
|
||||
await prompt.fill("将 MP01 标为高、MP02 标为低并显示编号");
|
||||
await page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first().click();
|
||||
|
||||
await expect(page.getByText("已渲染 1 个,高 0、中 0、低 1。")).toBeVisible();
|
||||
await page.waitForFunction(() => {
|
||||
const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as {
|
||||
_data?: { features?: Array<{ properties?: Record<string, unknown> }> };
|
||||
};
|
||||
const features = source?._data?.features ?? [];
|
||||
return features.length === 1 && features[0].properties?.sensor_id === "MP02";
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const map = window.__waterNetworkMap;
|
||||
return Boolean(
|
||||
map?.isSourceLoaded("agent-scada-analysis") &&
|
||||
map.queryRenderedFeatures(undefined, { layers: ["agent-scada-analysis-level"] }).length > 0 &&
|
||||
map.queryRenderedFeatures(undefined, { layers: ["agent-scada-analysis-label"] }).length > 0
|
||||
);
|
||||
});
|
||||
const rendered = await page.evaluate(() => {
|
||||
const map = window.__waterNetworkMap;
|
||||
const source = map?.getSource("agent-scada-analysis") as unknown as {
|
||||
_data?: { features?: Array<{ properties?: Record<string, unknown> }> };
|
||||
};
|
||||
const feature = source?._data?.features?.[0];
|
||||
const labelLayer = map?.getLayer("agent-scada-analysis-label");
|
||||
const levelLayer = map?.getLayer("agent-scada-analysis-level");
|
||||
const indicatorLayer = map?.getLayer("agent-scada-analysis-indicator");
|
||||
return {
|
||||
label: feature?.properties?.analysis_label,
|
||||
labelLayer: labelLayer?.type,
|
||||
labelSize: map?.getLayoutProperty("agent-scada-analysis-label", "text-size"),
|
||||
labelFont: map?.getLayoutProperty("agent-scada-analysis-label", "text-font"),
|
||||
level: feature?.properties?.analysis_level,
|
||||
levelLayer: levelLayer?.type,
|
||||
levelColors: map?.getPaintProperty("agent-scada-analysis-level", "circle-stroke-color"),
|
||||
indicatorLayer: indicatorLayer?.type
|
||||
};
|
||||
});
|
||||
expect(rendered).toEqual({
|
||||
label: "MP02 · 低",
|
||||
labelLayer: "symbol",
|
||||
labelSize: 15,
|
||||
labelFont: ["Noto Sans Regular"],
|
||||
level: "low",
|
||||
levelLayer: "circle",
|
||||
levelColors: [
|
||||
"match",
|
||||
["get", "analysis_level"],
|
||||
"high", "#DC2626",
|
||||
"medium", "#F59E0B",
|
||||
"low", "#16A34A",
|
||||
"#7C3AED"
|
||||
],
|
||||
indicatorLayer: "circle"
|
||||
});
|
||||
|
||||
await expect.poll(() => receipts.length).toBe(2);
|
||||
expect(receipts).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ status: "failed", error: expect.objectContaining({ code: "ACTION_SUPERSEDED" }) }),
|
||||
expect.objectContaining({
|
||||
status: "succeeded",
|
||||
output: expect.objectContaining({ rendered_ids: ["MP02"], fitted: true })
|
||||
})
|
||||
]));
|
||||
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.evaluate(() => window.__waterNetworkMap?.resize());
|
||||
await expect(page.getByText("已渲染 1 个,高 0、中 0、低 1。")).toBeVisible();
|
||||
expect(await page.evaluate(() => {
|
||||
const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as {
|
||||
_data?: { features?: Array<{ properties?: Record<string, unknown> }> };
|
||||
};
|
||||
return {
|
||||
label: source?._data?.features?.[0]?.properties?.analysis_label,
|
||||
viewportWidth: document.documentElement.clientWidth
|
||||
};
|
||||
})).toEqual({ label: "MP02 · 低", viewportWidth: 375 });
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole("button", { name: "新建 Agent 对话" }).filter({ visible: true }).click();
|
||||
await page.waitForFunction(() => {
|
||||
const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as {
|
||||
_data?: { features?: unknown[] };
|
||||
};
|
||||
return source?._data?.features?.length === 0;
|
||||
});
|
||||
|
||||
const nextPrompt = page.getByPlaceholder(/输入调度问题/).filter({ visible: true }).first();
|
||||
await nextPrompt.fill("新一组分析:将 MP01 标为高、MP02 标为低并显示编号");
|
||||
await page.getByRole("button", { name: "发送 Agent 指令" }).filter({ visible: true }).first().click();
|
||||
await page.waitForFunction(() => {
|
||||
const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as {
|
||||
_data?: { features?: Array<{ properties?: Record<string, unknown> }> };
|
||||
};
|
||||
const features = source?._data?.features ?? [];
|
||||
return features.length === 1 && features[0].properties?.analysis_label === "MP02 · 低";
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
expect(await page.evaluate(() => {
|
||||
const source = window.__waterNetworkMap?.getSource("agent-scada-analysis") as unknown as {
|
||||
_data?: { features?: Array<{ properties?: Record<string, unknown> }> };
|
||||
};
|
||||
return source?._data?.features?.map((feature) => feature.properties?.analysis_label);
|
||||
})).toEqual(["MP02 · 低"]);
|
||||
await expect.poll(() => receipts.length).toBe(4);
|
||||
});
|
||||
|
||||
async function mockAgentActions(page: Page, receipts: Array<Record<string, unknown>>) {
|
||||
let streamRequestCount = 0;
|
||||
|
||||
await page.route("**/api/map-features", async (route) => {
|
||||
const body = route.request().postDataJSON() as { featureIds?: string[] };
|
||||
const sensorId = body.featureIds?.[0] ?? "";
|
||||
if (sensorId === "MP01") await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
await route.fulfill({
|
||||
json: {
|
||||
type: "FeatureCollection",
|
||||
features: [{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: sensorId === "MP01" ? [120.7, 28] : [120.72, 28.02] },
|
||||
properties: { sensor_id: sensorId }
|
||||
}]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/agent/chat/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
if (/\/frontend-actions\/[^/]+\/result$/.test(path)) {
|
||||
receipts.push(route.request().postDataJSON() as Record<string, unknown>);
|
||||
await route.fulfill({ json: { ok: true } });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/frontend-action-registry")) {
|
||||
await route.fulfill({ json: {
|
||||
schema_version: "frontend-action-registry@1",
|
||||
actions: [
|
||||
{ id: "render_scada_analysis", version: "frontend-action@1" },
|
||||
{ id: "clear_scada_analysis", version: "frontend-action@1" }
|
||||
]
|
||||
} });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/models")) {
|
||||
await route.fulfill({ json: {
|
||||
default_model: "test/model",
|
||||
models: [{ id: "test/model", label: "测试模型", description: "SCADA 动作测试", icon: "bolt" }]
|
||||
} });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/sessions")) {
|
||||
await route.fulfill({ json: { sessions: [] } });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/ui-registry")) {
|
||||
await route.fulfill({ json: { schema_version: "agent-ui-registry@1", components: [], actions: [] } });
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/stream") && route.request().method() === "POST") {
|
||||
streamRequestCount += 1;
|
||||
const now = Date.now();
|
||||
const sessionId = `scada-session-${streamRequestCount}`;
|
||||
const chunks = [
|
||||
{ type: "start", messageId: `assistant-scada-action-${streamRequestCount}` },
|
||||
frontendActionChunk(`action-${streamRequestCount}-first`, `call-${streamRequestCount}-first`, sessionId, "MP01", "high", now),
|
||||
frontendActionChunk(`action-${streamRequestCount}-second`, `call-${streamRequestCount}-second`, sessionId, "MP02", "low", now),
|
||||
{ type: "text-start", id: "answer" },
|
||||
{ type: "text-delta", id: "answer", delta: "已更新地图结果。" },
|
||||
{ type: "text-end", id: "answer" },
|
||||
{ type: "finish", finishReason: "stop" }
|
||||
];
|
||||
await route.fulfill({
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/event-stream",
|
||||
"X-Vercel-AI-UI-Message-Stream": "v1"
|
||||
},
|
||||
body: chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: {} });
|
||||
});
|
||||
}
|
||||
|
||||
function frontendActionChunk(
|
||||
actionId: string,
|
||||
toolCallId: string,
|
||||
sessionId: string,
|
||||
sensorId: string,
|
||||
level: "high" | "low",
|
||||
now: number
|
||||
) {
|
||||
return {
|
||||
type: "data-frontend_action",
|
||||
data: {
|
||||
version: "frontend-action@1",
|
||||
actionId,
|
||||
toolCallId,
|
||||
sessionId,
|
||||
session_id: sessionId,
|
||||
name: "render_scada_analysis",
|
||||
params: { items: [{ sensor_id: sensorId, level }] },
|
||||
issuedAt: now,
|
||||
expiresAt: now + 15_000
|
||||
},
|
||||
transient: true
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user