import { expect, test } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import { mockAgentApi } from "./support/mock-agent-api";
test("调查图样式对照保留中文标题、分工和真实 SVG 关系", async ({ page }, testInfo) => {
await page.route("http://127.0.0.1:8787/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setViewportSize({ width: 1536, height: 1250 });
await page.setContent(`
调查脉络 · 三种视觉表达
使用截图中的节点与关系制作的样式设计示例
A · 工程流程
白底、分工分组、明确的节点和连线层次
B · 墨色报告
黑白、直角、虚实线区分分工,适合打印
`);
const renderCandidate = async (candidate: string) => page.evaluate(async (selected) => {
const reactModule = await import("/node_modules/.vite/deps/react.js");
const React = reactModule.default ?? reactModule;
const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js");
const { createRoot } = clientModule.default ?? clientModule;
const { MessageResponse } = await import("/src/shared/ai-elements/message.tsx");
const { investigationDiagramConfig, investigationDiagramPlugins, INVESTIGATION_DIAGRAM_FONT, styleInvestigationDiagram } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
const { investigationDiagram } = await import("/src/features/workbench/utils/investigation-presentation.ts");
const titles = ["读取后端任务上下文与授权范围", "查询授权范围内的现有数据", "核实事件位置与生产资产映射", "现场核验爆管情况与影响范围", "停水隔离", "抢修作业", "复水与回访"];
const edges = ["已读上下文后查询数据", "依据查询结果核实资产映射", "映射缺失需现场核验", "确认爆管后停水", "停水后抢修", "抢修后复水"];
const diagram = investigationDiagram({ execution_generation: 1, latest_report: null, latest_plan: { execution_generation: 1, plan: {
nodes: titles.map((title, index) => ({ id: `node${index}`, title, owner: index < 3 ? "agent" : "human", kind: "activity", evidence_refs: [], action_refs: [] })),
edges: edges.map((condition, index) => ({ from: `node${index}`, to: `node${index + 1}`, condition }))
} } }, null);
await document.fonts.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, titles.join(""));
let source = styleInvestigationDiagram(diagram);
let config = investigationDiagramConfig;
if (selected === "ink") {
source += "\nclassDef tjAgent fill:#FFFFFF,stroke:#4B5262,color:#29303E,stroke-width:1.3px;\nclassDef tjHuman fill:#FFFFFF,stroke:#4B5262,color:#29303E,stroke-width:1.3px,stroke-dasharray:5 3;";
config = { ...config, themeVariables: { ...config.themeVariables, clusterBkg: "#FFFFFF", clusterBorder: "#C5CBD5", lineColor: "#606A7B" },
themeCSS: config.themeCSS + " .node rect,.cluster rect {rx:0;ry:0;} .nodeLabel{font-weight:400;} .cluster-label .nodeLabel{background:#FFFFFF;}" };
}
if (selected === "sketch") {
source += "\nclassDef tjAgent fill:#EDF4E9,stroke:#788A6D,color:#3B5135,stroke-width:1.3px;\nclassDef tjHuman fill:#FFF4DC,stroke:#B39964,color:#6B572B,stroke-width:1.3px;";
config = { ...config, look: "handDrawn", flowchart: { ...config.flowchart, curve: "basis", rankSpacing: 44 },
themeVariables: { ...config.themeVariables, fontSize: "16px", clusterBkg: "#FFFCF4", clusterBorder: "#CAC3AF", lineColor: "#8E8979" },
themeCSS: config.themeCSS + " .cluster-label .nodeLabel{background:#FFFCF4;}" };
}
createRoot(document.getElementById(selected)).render(React.createElement(MessageResponse, {
plugins: investigationDiagramPlugins,
mermaid: { config }, controls: { mermaid: { panZoom: false, fullscreen: false, download: false, copy: false } },
children: "```mermaid\n" + source + "\n```"
}));
}, candidate);
for (const candidate of ["engineering", "ink", "sketch"]) {
await renderCandidate(candidate);
const surface = page.locator(`#${candidate}`);
await surface.scrollIntoViewIfNeeded();
await expect(surface.locator("svg[id]")).toHaveCount(1);
await expect(surface).toContainText("读取后端任务上下文与授权范围");
await expect(surface).toContainText("人工 · 现场协作");
await writeFile(testInfo.outputPath(`${candidate}.svg`), await surface.locator("svg[id]").evaluate((svg) => svg.outerHTML));
await surface.screenshot({ path: testInfo.outputPath(`${candidate}.png`) });
}
await page.locator("#style-comparison").screenshot({ path: testInfo.outputPath("investigation-style-comparison.png") });
await page.setViewportSize({ width: 1740, height: 1450 });
await page.evaluate(async () => {
const reactModule = await import("/node_modules/.vite/deps/react.js");
const React = reactModule.default ?? reactModule;
const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js");
const { createRoot } = clientModule.default ?? clientModule;
const { MessageResponse } = await import("/src/shared/ai-elements/message.tsx");
const { investigationDiagramConfig, investigationDiagramPlugins, INVESTIGATION_DIAGRAM_FONT, styleInvestigationDiagram } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
const { investigationDiagram } = await import("/src/features/workbench/utils/investigation-presentation.ts");
const labels = ["工单接入与信息登记", "上游监测点数据质量核查", "上游演示指标阈值比对", "相邻片区同类投诉排查", "投诉分布范围确认", "阶段性判断", "二次供水水箱清洗记录核验", "现场采样", "水箱清洗处置", "复测", "居民回访"];
const links = [[0,1,"并行核查"],[0,2,"并行核查"],[0,3,"并行核查"],[0,4,"并行核查"],
[1,5,"数据可用性结论"],[2,5,"上游水质结论"],[3,5,"范围排除依据"],[4,5,"投诉分布依据"],
[5,7,"条件分支:需现场核验"],[5,6,"条件分支:疑似二次供水问题 → 核验清洗记录"],
[6,8,"条件分支:清洗记录缺失或异常 → 清洗处置"],[7,9,"条件分支:采样支持处置后复测"],
[8,9,"条件分支:处置后复测"],[9,10,"条件分支:复测结果反馈回访"]];
const diagram = investigationDiagram({ execution_generation: 1, latest_report: null, latest_plan: { execution_generation: 1, plan: {
nodes: labels.map((title, index) => ({ id: `parallel${index}`, title, owner: index < 6 ? "agent" : "human", kind: "activity",
evidence_refs: index < 7 ? [`design-example-${index}`] : [], action_refs: [] })),
edges: links.map(([from,to,condition]) => ({ from: `parallel${from}`, to: `parallel${to}`, condition }))
} } }, null);
await document.fonts.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, labels.join(""));
document.getElementById("style-comparison").remove();
const container = document.createElement("main"); container.id = "parallel-comparison";
container.style.cssText = "padding:32px;background:#FFFFFF;font-family:'Noto Sans SC Variable',sans-serif";
document.body.append(container);
createRoot(container).render(React.createElement(MessageResponse, {
plugins: investigationDiagramPlugins,
mermaid: { config: investigationDiagramConfig }, controls: { mermaid: { panZoom: false, fullscreen: false, download: false, copy: false } },
children: "```mermaid\n" + styleInvestigationDiagram(diagram) + "\n```"
}));
const style = document.createElement("style");
style.textContent = '#parallel-comparison [data-streamdown="mermaid"],#parallel-comparison [data-streamdown="mermaid"] > div {max-height:none!important;background:#FFFFFF} #parallel-comparison [data-streamdown="mermaid"] [role="img"]{width:100%} #parallel-comparison .font-mono{display:none}';
document.head.append(style);
});
const parallel = page.locator("#parallel-comparison");
await parallel.scrollIntoViewIfNeeded();
await expect(parallel.locator("svg[id]")).toHaveCount(1);
const titleGap = await parallel.evaluate((element) => {
const title = [...element.querySelectorAll(".cluster-label")].find((item) => item.textContent?.includes("Agent"));
const firstNode = [...element.querySelectorAll(".node")].find((item) => item.textContent?.includes("工单接入"));
return firstNode!.getBoundingClientRect().top - title!.getBoundingClientRect().bottom;
});
const captionOverlaps = await parallel.evaluate((element) => {
const labels = [...element.querySelectorAll(".edgeLabel")].map((label) => label.getBoundingClientRect());
return [...element.querySelectorAll(".cluster-label")].flatMap((caption) => {
const box = caption.getBoundingClientRect();
return labels.some((label) => box.left < label.right && box.right > label.left && box.top < label.bottom && box.bottom > label.top)
? [caption.textContent]
: [];
});
});
await writeFile(testInfo.outputPath("parallel-diagram.svg"), await parallel.locator("svg[id]").evaluate((svg) => svg.outerHTML));
await parallel.screenshot({ path: testInfo.outputPath("parallel-diagram.png") });
await writeFile(testInfo.outputPath("parallel-diagram-spacing.json"), JSON.stringify({ viewport: page.viewportSize(), titleGap }, null, 2));
expect(titleGap).toBeGreaterThanOrEqual(18);
expect(captionOverlaps).toEqual([]);
});