规范仿真模型库并完善前端交互
归档仿真模型并补充组件目录、建模规范与校验。 完善控制台、默认节点、视图适配及前端自动化测试。
This commit is contained in:
1 parent
f7f1078911
commit
bbc88a6bbb
71 files changed
+4574
-806
No files matched your search
Generated
+64
@@ -15,6 +15,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",
|
||||
@@ -85,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",
|
||||
@@ -1368,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",
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"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",
|
||||
@@ -16,6 +18,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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
+616
-252
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
WorkspaceViewTabs,
|
||||
type WorkspaceView,
|
||||
} from "./WorkspaceViewTabs";
|
||||
import { AutoFitView } from "./AutoFitView";
|
||||
|
||||
type ResultPortDefinition = {
|
||||
name: string;
|
||||
@@ -1134,7 +1135,7 @@ export function SimulationResultsView({
|
||||
edgesFocusable={false}
|
||||
edgesReconnectable={false}
|
||||
elementsSelectable
|
||||
fitView
|
||||
minZoom={0.1}
|
||||
nodeTypes={nodeTypes}
|
||||
nodes={readOnlyNodes}
|
||||
nodesConnectable={false}
|
||||
@@ -1146,8 +1147,15 @@ export function SimulationResultsView({
|
||||
selectionOnDrag={false}
|
||||
zoomOnDoubleClick={false}
|
||||
>
|
||||
<AutoFitView
|
||||
enabled={readOnlyNodes.length > 0}
|
||||
expectedNodeCount={readOnlyNodes.length}
|
||||
requestKey={snapshot.id}
|
||||
/>
|
||||
<Background gap={18} />
|
||||
<Controls />
|
||||
<Controls
|
||||
fitViewOptions={{ duration: 180, minZoom: 0.1, padding: 0.18 }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+150
-176
@@ -369,15 +369,6 @@ textarea {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #e3e8ef;
|
||||
color: #155e75;
|
||||
background: #ecfeff;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid #e3e8ef;
|
||||
@@ -385,13 +376,90 @@ textarea {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.palette-list,
|
||||
.palette-list {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.parameter-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.palette-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.catalog-source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.catalog-source > span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #d49b16;
|
||||
}
|
||||
|
||||
.catalog-source.ready > span {
|
||||
background: #198754;
|
||||
}
|
||||
|
||||
.catalog-source.fallback > span {
|
||||
background: #b45309;
|
||||
}
|
||||
|
||||
.palette-library + .palette-library {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #e3e8ef;
|
||||
}
|
||||
|
||||
.palette-library-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.palette-library-heading strong {
|
||||
color: #17212b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.palette-library-heading small {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.palette-category + .palette-category {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.palette-category-title {
|
||||
margin-bottom: 6px;
|
||||
color: #526172;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.palette-category-items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -523,6 +591,34 @@ textarea {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.icon-generic {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.icon-generic::before,
|
||||
.icon-generic::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
box-sizing: border-box;
|
||||
border: 1.5px solid #4b5563;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
content: "";
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.icon-generic::before {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.icon-generic::after {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.icon-tee::before,
|
||||
.icon-tee::after {
|
||||
position: absolute;
|
||||
@@ -678,6 +774,22 @@ textarea {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.sim-node.generic-component .node-label {
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
display: -webkit-box;
|
||||
max-width: calc(100% - 28px);
|
||||
overflow: hidden;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: normal;
|
||||
background: transparent;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.symbol-cylinder,
|
||||
.symbol-tank,
|
||||
.symbol-pipe,
|
||||
@@ -842,138 +954,6 @@ textarea {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.validation-section {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e3e8ef;
|
||||
background: #fffdf9;
|
||||
}
|
||||
|
||||
.validation-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.validation-heading span {
|
||||
color: #9a6700;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.validation-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.validation-item {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr);
|
||||
gap: 7px;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
padding: 7px;
|
||||
border: 1px solid #e5c0bd;
|
||||
border-radius: 5px;
|
||||
color: #7a271a;
|
||||
text-align: left;
|
||||
background: #fff7f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.validation-item.warning {
|
||||
border-color: #ead59a;
|
||||
color: #713f12;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.validation-item:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.validation-item span {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.validation-item strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.xml-output {
|
||||
width: calc(100% - 24px);
|
||||
min-height: 220px;
|
||||
margin: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #c2ccda;
|
||||
border-radius: 6px;
|
||||
color: #1f2933;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.result-title small {
|
||||
color: #b45309;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.4fr);
|
||||
gap: 8px 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-grid span {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.result-grid strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: #1f2933;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.run-message {
|
||||
padding: 8px;
|
||||
border: 1px solid #d6dee9;
|
||||
border-radius: 6px;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.component-result {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e3e8ef;
|
||||
}
|
||||
|
||||
.component-result > strong {
|
||||
color: #1f2933;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.results-shell {
|
||||
display: grid;
|
||||
grid-template-rows: 62px minmax(0, 1fr);
|
||||
@@ -2032,11 +2012,6 @@ textarea {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.simulation-console.minimized.has-unread {
|
||||
border-color: #1d6fb8;
|
||||
animation: simulation-console-attention 1.2s ease-out 2;
|
||||
}
|
||||
|
||||
.simulation-console-collapsed {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 34px minmax(0, 1fr);
|
||||
@@ -2082,7 +2057,7 @@ textarea {
|
||||
|
||||
.simulation-console-collapsed-open {
|
||||
display: grid;
|
||||
grid-template-columns: 18px auto minmax(0, 1fr) auto 18px;
|
||||
grid-template-columns: 18px auto minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
@@ -2109,7 +2084,6 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.simulation-console-collapsed-message,
|
||||
.simulation-console-collapsed-status {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -2126,21 +2100,6 @@ textarea {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.simulation-console-unread {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 24px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
color: #ffffff;
|
||||
background: #c13d35;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.simulation-console-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2361,21 +2320,36 @@ textarea {
|
||||
color: #a9322d;
|
||||
}
|
||||
|
||||
@keyframes simulation-console-attention {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(29, 111, 184, 0.2), 0 10px 28px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
.simulation-console-entry-message {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.simulation-console-entry.xml {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.simulation-console-entry-xml {
|
||||
min-width: 0;
|
||||
max-height: 145px;
|
||||
margin: 0;
|
||||
padding: 8px 9px;
|
||||
overflow: auto;
|
||||
border: 1px solid #cad5e1;
|
||||
border-radius: 4px;
|
||||
color: #172231;
|
||||
background: #f8fafc;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.simulation-console.maximized .simulation-console-entry-xml {
|
||||
max-height: calc(100vh - 170px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.simulation-console.minimized.has-unread {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.simulation-console-progress-track span {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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("仿真结果");
|
||||
});
|
||||
Reference in new issue
Block a user