300 lines
9.9 KiB
TypeScript
300 lines
9.9 KiB
TypeScript
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>> = [];
|
|
const wfsRequests: string[] = [];
|
|
await mockAgentActions(page, receipts, wfsRequests);
|
|
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 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: 18,
|
|
labelFont: ["Noto Sans Regular"],
|
|
level: "low",
|
|
levelLayer: "circle",
|
|
levelColors: [
|
|
"match",
|
|
["get", "analysis_level"],
|
|
"high",
|
|
"#F43F5E",
|
|
"medium",
|
|
"#F59E0B",
|
|
"low",
|
|
"#10B981",
|
|
"#8B5CF6"
|
|
],
|
|
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 })
|
|
})
|
|
])
|
|
);
|
|
expect(wfsRequests).toEqual(expect.arrayContaining(["MP01", "MP02"]));
|
|
|
|
await page.setViewportSize({ width: 375, height: 812 });
|
|
await page.evaluate(() => window.__waterNetworkMap?.resize());
|
|
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>>,
|
|
wfsRequests: string[]
|
|
) {
|
|
let streamRequestCount = 0;
|
|
|
|
await page.route(/\/wenzhou\/ows\?/, async (route) => {
|
|
const url = new URL(route.request().url());
|
|
expect(route.request().method()).toBe("GET");
|
|
expect(url.searchParams.get("typeNames")).toBe("wenzhou:geo_scadas_mat");
|
|
const sensorId = url.searchParams.get("cql_filter")?.match(/'([^']+)'/)?.[1] ?? "";
|
|
wfsRequests.push(sensorId);
|
|
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 }
|
|
}
|
|
]
|
|
}
|
|
}).catch(() => undefined);
|
|
});
|
|
|
|
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
|
|
};
|
|
}
|