import { expect, test, type Page } from "@playwright/test"; import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { normalizePortComputation, portConnectionIssue, referenceSupplyIssues, } from "../../src/portComputation"; import { expandSimulationConsole, prepareApp } from "./fixtures"; const root = fileURLToPath(new URL("../../..", import.meta.url)); const localPython = resolve(root, ".venv-win/Scripts/python.exe"); const python = process.env.TEST_PYTHON ?? (existsSync(localPython) ? localPython : "python"); // Use real backend metadata: frontend/backend drift must fail this test. const catalog = JSON.parse(execFileSync(python, ["-c", "import json; from app.simulation.registry import build_component_catalog; print(json.dumps(build_component_catalog()))", ], { cwd: root, encoding: "utf8" })); const definitions = new Map(catalog.libraries.flatMap((library: any) => library.components.map((component: any) => [component.modelType, component]))); const port = (model: string, name: string) => definitions.get(model).ports.find((p: any) => p.name === name); function node(id: string, model: string, x: number, y = 0) { const definition = definitions.get(model); return { id, type: "simulationComponent", position: { x, y }, data: { label: id, componentType: model, modelType: model, modelVersion: definition.modelVersion, symbol: definition.symbol, ports: definition.ports.map((p: any) => ({ ...p })), parameters: Object.fromEntries(definition.parameters.map((p: any) => [p.name, p.name === "gi" ? 0 : p.default])), parameterUnits: Object.fromEntries(definition.parameters.map((p: any) => [p.name, p.unit])), rotation: 0, mirrored: false, } }; } function project(wrong = false) { const nodes = [node("junction", "amesim_p4node2", 330), node("valve", "amesim_pnvo001_fixed", 0), node("storage", "amesim_pnl0001", 660)]; if (wrong) { // A forged/old snapshot must not replace the current catalog contract. nodes[0].data.ports.forEach((p: any) => { delete p.computation; }); } return { projectSchemaVersion: 1, name: "port-supply-e2e", nodes, edges: wrong ? [ { id: "wrong-reference", source: "junction", sourceHandle: "port_2", target: "valve", targetHandle: "port_2", data: { isContactEdge: false } }, { id: "wrong-branch", source: "junction", sourceHandle: "port_1", target: "storage", targetHandle: "port_2", data: { isContactEdge: false } }, ] : [], simulation: { t_start: 0, t_stop: 0.01, step: 0.001, max_step: 0.001, method: "RK45" }, }; } async function load(page: Page, value: object) { await prepareApp(page); await page.route("**/api/components/catalog", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(catalog), })); await page.goto("/"); await expect(page.locator(".catalog-source.ready")).toBeVisible(); await page.locator('input[type="file"]').setInputFiles({ name: "port-supply.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(value)), }); await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(3); } test("真实目录的参考口/支路口供需在两种绘线方向下一致", () => { const reference = port("amesim_p4node2", "port_2"); const branch = port("amesim_p4node2", "port_1"); const state = port("amesim_pnl0001", "port_2"); const flow = port("amesim_pnvo001_fixed", "port_2"); for (const [a, b] of [[reference, state], [branch, flow]]) { expect(portConnectionIssue(a, b)).toBeNull(); expect(portConnectionIssue(b, a)).toBeNull(); } for (const [a, b] of [[reference, flow], [flow, reference]]) { expect(portConnectionIssue(a, b)).toContain("温度"); expect(portConnectionIssue(a, b)).toContain("压力"); } expect(portConnectionIssue(branch, state)).toContain("能量流率"); expect(portConnectionIssue(flow, port("amesim_pnl00r", "port_1"))).toBeNull(); expect(() => normalizePortComputation({ mode: "fixed", inputs: ["T"], outputs: ["T"] })).toThrow(); }); test("参考链能追溯储气状态,拒绝无来源循环", () => { const nodes = [node("a", "amesim_p4node2", 0), node("b", "amesim_p4node2", 300), node("state", "amesim_pnl0001", 600)]; const edges = [{ source: "a", sourceHandle: "port_2", target: "b", targetHandle: "port_1" }, { source: "b", sourceHandle: "port_2", target: "state", targetHandle: "port_2" }]; expect(referenceSupplyIssues(nodes, edges)).toEqual([]); edges[1] = { source: "b", sourceHandle: "port_2", target: "a", targetHandle: "port_1" }; expect(referenceSupplyIssues(nodes, edges)).toHaveLength(4); expect(referenceSupplyIssues(nodes, edges)[0].message).toContain("没有实际提供者"); }); test("画布按变量供需过滤接线,并说明不兼容的具体原因", async ({ page }) => { await load(page, project()); const handle = (id: string, name: string) => page.locator( `.react-flow__node[data-id="${id}"] .port-handle[data-port-name="${name}"]`); const reference = handle("junction", "port_2"); await expect(reference).toHaveAttribute("title", /温度、压力参考口/); await reference.click(); await expect(handle("valve", "port_2")).toHaveAttribute("data-connection-state", "incompatible"); await expect(handle("valve", "port_2")).toHaveAttribute("title", /需要对端提供压力、温度/); await expect(handle("storage", "port_2")).toHaveAttribute("data-connection-state", "compatible"); await handle("storage", "port_2").click(); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1); }); test("导入时保留供需错误连线,模型检查阻止仿真且不信任旧端口快照", async ({ page }) => { let simulations = 0; await page.route("**/api/system-xml/simulate-stream", (route) => { simulations += 1; return route.fulfill({ status: 500, body: "unexpected simulation" }); }); await load(page, project(true)); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2); await page.getByRole("button", { name: "运行仿真", exact: true }).click(); await expandSimulationConsole(page); const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true }); await expect(panel).toContainText("需要对端提供压力、温度"); await expect(panel).toContainText("质量流率、能量流率"); expect(simulations).toBe(0); await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2); });