同步仿真框架并接入AMESim气动组件

This commit is contained in:
huojiarong committed 2026-07-30 03:53:10 +00:00
1 parent 420bafeb4e
commit db4bdb4b70
109 files changed
+26920 -420

No files matched your search

+124
View File
@@ -9,10 +9,13 @@
"version": "0.1.0",
"dependencies": {
"@xyflow/react": "^12.11.2",
"html2canvas": "^1.4.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
@@ -83,6 +86,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -853,12 +872,30 @@
"d3-zoom": "^3.0.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1014,6 +1051,19 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1287,6 +1337,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -1326,6 +1385,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@@ -1426,6 +1532,15 @@
"node": ">=0.10.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1495,6 +1610,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
+6 -1
View File
@@ -6,14 +6,19 @@
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
"preview": "vite preview --host 127.0.0.1"
"preview": "vite preview --host 127.0.0.1",
"test": "playwright test",
"test:e2e": "playwright test"
},
"dependencies": {
"@xyflow/react": "^12.11.2",
"html2canvas": "^1.4.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
workers: 1,
timeout: 30_000,
expect: {
timeout: 6_000,
},
use: {
baseURL: "http://127.0.0.1:4173",
channel: "msedge",
headless: true,
viewport: { width: 1440, height: 900 },
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: {
command: "npm run dev -- --port 4173 --strictPort",
url: "http://127.0.0.1:4173",
reuseExistingServer: false,
timeout: 60_000,
},
});
+4257 -279
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
import { useLayoutEffect } from "react";
import { useReactFlow, useStore } from "@xyflow/react";
type AutoFitViewProps = {
enabled?: boolean;
expectedNodeCount: number;
minZoom?: number;
padding?: number;
requestKey: number | string;
};
export function AutoFitView({
enabled = true,
expectedNodeCount,
minZoom = 0.1,
padding = 0.18,
requestKey,
}: AutoFitViewProps) {
const expectedNodesAreMeasured = useStore((state) => {
if (expectedNodeCount <= 0 || state.nodeLookup.size !== expectedNodeCount) {
return false;
}
for (const node of state.nodeLookup.values()) {
if (
!node.measured.width ||
!node.measured.height ||
node.measured.width <= 0 ||
node.measured.height <= 0
) {
return false;
}
}
return true;
});
const { fitView } = useReactFlow();
useLayoutEffect(() => {
if (!enabled || !expectedNodesAreMeasured) {
return;
}
void fitView({ duration: 0, minZoom, padding });
}, [
enabled,
expectedNodesAreMeasured,
fitView,
minZoom,
padding,
requestKey,
]);
return null;
}
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
export type WorkspaceView = "modeling" | "results";
type WorkspaceViewTabsProps = {
activeView: WorkspaceView;
hasResults: boolean;
hasUnreadResults?: boolean;
onViewChange: (view: WorkspaceView) => void;
};
export function WorkspaceViewTabs({
activeView,
hasResults,
hasUnreadResults = false,
onViewChange,
}: WorkspaceViewTabsProps) {
return (
<div aria-label="工作区视图" className="workspace-view-tabs" role="tablist">
<button
aria-selected={activeView === "modeling"}
className={activeView === "modeling" ? "active" : ""}
onClick={() => onViewChange("modeling")}
role="tab"
type="button"
>
建模
</button>
<button
aria-label={hasUnreadResults ? "结果,有新的仿真结果" : "结果"}
aria-selected={activeView === "results"}
className={activeView === "results" ? "active" : ""}
onClick={() => onViewChange("results")}
role="tab"
title={hasUnreadResults ? "有新的仿真结果" : undefined}
type="button"
>
结果
{hasResults && hasUnreadResults ? (
<span aria-hidden="true" className="result-available-dot" />
) : null}
</button>
</div>
);
}
+1982 -76
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1,6 +1,7 @@
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $PSScriptRoot
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
Sort-Object Name -Descending |
Select-Object -First 1
@@ -10,4 +11,4 @@ if (-not $nodeDir) {
}
$env:Path = "$($nodeDir.FullName);$env:Path"
& (Join-Path $nodeDir.FullName "npm.cmd") run dev
& (Join-Path $nodeDir.FullName "npm.cmd") run dev -- --strictPort
+81
View File
@@ -0,0 +1,81 @@
import { expect, test } from "@playwright/test";
import {
expectAllNodesInsideCanvas,
prepareApp,
resultSnapshot,
wideProject,
} from "./fixtures";
test("加载本地工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(project),
);
}, wideProject);
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toContainText("工程已加载");
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("工程已加载");
});
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
window.localStorage.setItem(
"system-simulation-flow:autosave",
JSON.stringify(project),
);
}, wideProject);
await page.goto("/");
await page.getByRole("button", { name: "恢复" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toContainText("已恢复上次自动保存的工程");
await page.getByRole("button", { name: "展开仿真控制台" }).click();
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
});
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, resultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
await page.getByRole("tab", { name: "建模" }).click();
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toBeVisible();
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await expect(
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
).toHaveCount(0);
await expect(page.locator(".results-system-canvas .react-flow__edge")).toHaveCount(
wideProject.edges.length,
);
});
+213
View File
@@ -0,0 +1,213 @@
import { expect, type Page } from "@playwright/test";
const physicalPort = (
name: string,
side: "left" | "right",
nominalRole: "inlet" | "outlet" | "bidirectional",
) => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole,
positiveFlowDirection: "intoComponent",
side,
order: side === "left" ? 0 : 1,
});
export const componentCatalog = {
schemaVersion: 1,
libraries: [
{
id: "e2e-library",
label: "自动化测试组件库",
version: "1.0.0",
sourcePackage: "tests.e2e",
temporary: true,
order: 1,
components: [
{
type: "generic_sensor",
label: "通用测试元件",
modelType: "generic_sensor",
modelVersion: "1.0.0",
symbol: "symbol-not-registered",
order: 1,
category: {
id: "generic",
label: "通用组件",
order: 1,
},
ports: [
physicalPort("port_a", "left", "bidirectional"),
physicalPort("port_b", "right", "bidirectional"),
],
parameters: [
{
name: "gain",
label: "增益",
quantity: "dimensionless",
unit: "",
default: 1,
minimumExclusive: false,
},
],
},
],
},
],
};
const projectPort = (
name: string,
side: "left" | "right",
nominalRole: "inlet" | "outlet" | "bidirectional",
) => ({
name,
kind: "physical",
domain: "pneumatic",
nominalRole,
positiveFlowDirection: "intoComponent",
side,
});
export const wideProject = {
name: "demo-system",
nodes: Array.from({ length: 4 }, (_, index) => ({
id: `generic_sensor_${index + 1}`,
type: "simulationComponent",
position: {
x: index * 620,
y: index % 2 === 0 ? 0 : 260,
},
data: {
label: `generic_sensor_${index + 1}`,
componentType: "generic_sensor",
modelType: "generic_sensor",
symbol: "symbol-not-registered",
ports: [
projectPort("port_a", "left", "bidirectional"),
projectPort("port_b", "right", "bidirectional"),
],
parameters: { gain: 1 },
parameterUnits: { gain: "" },
rotation: 0,
mirrored: false,
},
})),
edges: Array.from({ length: 3 }, (_, index) => ({
id: `edge-${index + 1}`,
source: `generic_sensor_${index + 1}`,
target: `generic_sensor_${index + 2}`,
sourceHandle: "port_b",
targetHandle: "port_a",
})),
simulation: {
t_start: 0,
t_stop: 10,
step: 0.1,
max_step: 0.05,
method: "RK45",
},
};
export const resultSnapshot = {
id: "e2e-result",
createdAt: "2026-07-28T10:00:00.000Z",
project: wideProject,
result: {
success: true,
status: "completed",
partial: false,
message: "Simulation completed",
simulatedUntil: 10,
requestedStopTime: 10,
variables: [
{
key: "generic_sensor_1.value",
componentId: "generic_sensor_1",
componentType: "generic_sensor",
scope: "component",
portName: null,
name: "value",
label: "数值",
quantity: "dimensionless",
unit: "",
category: "state",
order: 1,
},
],
final: { "generic_sensor_1.value": 1 },
series: {
time: [0, 5, 10],
"generic_sensor_1.value": [0, 0.5, 1],
},
diagnostics: {
pressureFlow: {
solveCount: 3,
maxScaledResidual: 0,
maxEvaluationsPerSolve: 1,
},
stream: {
maxIterationsPerSolve: 1,
},
stateCount: 1,
sampleCount: 3,
},
},
};
export async function prepareApp(page: Page) {
await page.addInitScript(() => {
window.localStorage.clear();
window.sessionStorage.clear();
window.location.hash = "#/modeling";
});
await page.route("**/api/components/catalog", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(componentCatalog),
});
});
}
export async function expectAllNodesInsideCanvas(
page: Page,
canvasSelector: string,
expectedCount = wideProject.nodes.length,
) {
const canvas = page.locator(canvasSelector);
await expect(canvas).toBeVisible();
const nodes = canvas.locator(".react-flow__node");
await expect(nodes).toHaveCount(expectedCount);
await expect
.poll(
async () =>
page.evaluate(
({ selector, count }) => {
const canvasElement = document.querySelector<HTMLElement>(selector);
const nodeElements = Array.from(
canvasElement?.querySelectorAll<HTMLElement>(".react-flow__node") ?? [],
);
if (!canvasElement || nodeElements.length !== count) {
return false;
}
const canvasBounds = canvasElement.getBoundingClientRect();
const tolerance = 3;
return nodeElements.every((node) => {
const bounds = node.getBoundingClientRect();
return (
bounds.left >= canvasBounds.left - tolerance &&
bounds.top >= canvasBounds.top - tolerance &&
bounds.right <= canvasBounds.right + tolerance &&
bounds.bottom <= canvasBounds.bottom + tolerance
);
});
},
{ selector: canvasSelector, count: expectedCount },
),
{ message: `所有节点应完整显示在 ${canvasSelector} 内` },
)
.toBe(true);
}
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
import { prepareApp } from "./fixtures";
test.beforeEach(async ({ page }) => {
await prepareApp(page);
});
test("未知图形元件使用带端口和名称的默认节点,并将动作结果写入控制台", async ({
page,
}) => {
await page.goto("/");
const paletteItem = page.getByRole("button", { name: /通用测试元件/ });
await expect(paletteItem).toBeVisible();
await paletteItem.dragTo(page.locator(".flow-canvas .react-flow__pane"));
const node = page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]');
await expect(node).toBeVisible();
await expect(node.locator(".sim-node")).toHaveClass(/generic-component/);
await expect(node.locator(".node-symbol-anchor")).toHaveCount(0);
await expect(node.locator(".node-label")).toHaveText("generic_sensor_1");
await expect(node.locator(".port-handle")).toHaveCount(2);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
await expect(consolePanel).not.toHaveClass(/has-unread/);
await expect(consolePanel.locator(".simulation-console-unread")).toHaveCount(0);
await expect(consolePanel).not.toContainText("已添加组件");
await page.getByRole("button", { name: "生成系统 XML" }).click();
await expect(consolePanel).toContainText("XML 已生成");
const xmlBlock = consolePanel.getByLabel("生成的系统 XML");
await expect(xmlBlock).toBeVisible();
await expect(xmlBlock).toContainText('<?xml version="1.0" encoding="UTF-8"?>');
await expect(xmlBlock).toContainText('<Component id="generic_sensor_1"');
await expect(page.locator(".properties .xml-output")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("系统 XML");
await page.getByRole("button", { name: "检查模型" }).click();
await expect(consolePanel).toContainText(/模型检查/);
await expect(page.locator(".status-strip")).toHaveCount(0);
await expect(page.locator(".validation-section")).toHaveCount(0);
await expect(page.locator(".properties")).not.toContainText("仿真结果");
});