前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解

This commit is contained in:
ljz committed 2026-09-11 11:27:54 +08:00
1 parent 0dcb465d84
commit 91bd9fb252
77 files changed
+11223 -551

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { buildAlignmentIndex, findAlignment, type AlignmentAnchor } from "../../src/alignmentGuides";
import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures";
const KEY = "system-simulation-flow:project:demo-system";
const anchor = (nodeId: string, kind: AlignmentAnchor["kind"], x: number, y: number): AlignmentAnchor =>
({ id: `${nodeId}:${kind}`, nodeId, kind, x, y });
test("中心、定位点和端口按同类参考点对齐,整块采用同一个平移量", () => {
for (const kind of ["center", "origin", "port"] as const) {
const index = buildAlignmentIndex([anchor("ref-x", kind, 100, 200), anchor("ref-y", kind, 300, 40)]);
const result = findAlignment([anchor("a", kind, 95, 10), anchor("b", kind, 145, 37)], index, 1)!;
expect(result.positionDelta).toEqual({ x: 5, y: 3 });
expect(result.guides.map((g) => g.kind)).toEqual([kind, kind]);
expect(result.guides.every((g) => g.from[g.axis] === g.to[g.axis])).toBe(true);
}
expect(findAlignment([anchor("a", "origin", 95, 10)],
buildAlignmentIndex([anchor("ref", "center", 100, 200)]), 1)).toBeNull();
});
test("对齐容差保持约 8 屏幕像素,远处元件与离开范围的元件不吸附", () => {
const index = buildAlignmentIndex([anchor("ref", "center", 100, 100)]);
expect(findAlignment([anchor("a", "center", 96.5, 0)], index, 2)?.positionDelta.x).toBe(3.5);
expect(findAlignment([anchor("a", "center", 95, 0)], index, 2)).toBeNull();
expect(findAlignment([anchor("a", "center", 96, 5000)], index, 1)).toBeNull();
expect(findAlignment([anchor("a", "center", 70, 0)], index, 0.1)).toBeNull();
});
test("大量元件共用同一坐标时,索引仍选取另一方向最近的参考点", () => {
const index = buildAlignmentIndex(Array.from({ length: 20000 }, (_, i) => anchor(`ref-${i}`, "port", 100, i * 20)));
const result = findAlignment([anchor("a", "port", 104, 200005)], index, 1)!;
expect(result.positionDelta).toEqual({ x: -4, y: -5 });
expect(result.guides[0].to).toEqual({ x: 100, y: 200000 });
});
async function loadProject(page: Page, ports: boolean, rotatedTarget = false, block = false) {
await prepareApp(page);
if (!ports) {
const catalog = structuredClone(componentCatalog);
catalog.libraries.flatMap((library) => library.components)
.find((component) => component.type === "generic_sensor")!.ports = [];
await page.route("**/api/components/catalog", (route) => route.fulfill({
contentType: "application/json", body: JSON.stringify(catalog),
}));
}
const nodes = [
{ id: "moving", x: 0, y: 0, rotation: 0 },
{ id: "reference", x: 400, y: block ? 400 : 210, rotation: rotatedTarget ? 90 : 0 },
...(block ? [{ id: "partner", x: 0, y: 130, rotation: 0 }] : []),
].map(({ id, x, y, rotation }) => ({
...structuredClone(wideProject.nodes[0]), id, position: { x, y },
data: { ...structuredClone(wideProject.nodes[0].data), label: id, rotation,
ports: ports ? wideProject.nodes[0].data.ports : [] },
}));
await page.addInitScript(({ key, project }) => localStorage.setItem(key, JSON.stringify(project)), {
key: KEY, project: { ...structuredClone(wideProject), nodes, edges: block ? [{
...wideProject.edges[0], source: "moving", target: "partner",
data: { isContactEdge: false, routePoints: [{ x: 200, y: 50 }, { x: 200, y: 170 }] },
}] : [] },
});
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas", block ? 3 : 2);
return {
moving: page.locator('.react-flow__node[data-id="moving"]'),
reference: page.locator('.react-flow__node[data-id="reference"]'),
guides: page.locator(".alignment-guides"),
};
}
async function bounds(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { ...box!, cx: box!.x + box!.width / 2, cy: box!.y + box!.height / 2 };
}
async function beginDrag(page: Page, node: Locator) {
const before = await bounds(node);
await page.mouse.move(before.cx, before.cy);
await page.mouse.down();
// 先跨过 React Flow 的拖拽启动阈值,再按实际抓取偏移定位。
await page.mouse.move(before.cx, before.cy + 5);
const after = await bounds(node);
return { x: before.cx - after.cx, y: before.cy + 5 - after.cy };
}
for (const kind of ["center", "origin", "port"] as const) {
test(`拖动时显示${kind}辅助线并精确吸附,松手后隐藏,支持撤销`, async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, kind === "port", kind !== "center");
const before = await bounds(moving);
const target = await bounds(reference);
const portSelector = '.port-handle[data-port-name="port_b"]';
const sourceAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy
: kind === "center" ? before.cy : before.y;
const targetAnchor = kind === "port" ? (await bounds(reference.locator(portSelector))).cy
: kind === "center" ? target.cy : target.y;
const grab = await beginDrag(page, moving);
await page.mouse.move(before.cx + grab.x, before.cy + targetAnchor - sourceAnchor + 4 + grab.y, { steps: 8 });
await expect(guides.locator(`[data-alignment-kind="${kind}"]`)).toBeVisible();
await expect(guides.locator("text")).toHaveCount(0);
await expect(guides).toHaveCSS("opacity", "0.5");
await expect(guides).toHaveCSS("pointer-events", "none");
const snapped = await bounds(moving);
const snappedAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy
: kind === "center" ? snapped.cy : snapped.y;
expect(Math.abs(snappedAnchor - targetAnchor)).toBeLessThan(0.7);
const line = guides.locator('[data-alignment-axis="y"] line');
expect(await line.getAttribute("y1")).toBe(await line.getAttribute("y2"));
await expect(line).toHaveCSS("stroke", "rgb(128, 197, 239)");
await expect(line).toHaveCSS("stroke-width", "4px");
await expect(line).toHaveCSS("stroke-dasharray", "none");
await expect(line).toHaveCSS("vector-effect", "non-scaling-stroke");
await expect(guides.locator("circle").first()).toHaveAttribute("r", "5");
await expect(guides.locator("circle").first()).toHaveCSS("fill", "rgb(128, 197, 239)");
await expect(guides.locator("circle").first()).toHaveCSS("stroke", "none");
if (kind === "center") await page.screenshot({ path: "test-results/alignment-preview.png" });
await page.mouse.up();
await expect(guides).toHaveCount(0);
expect(Math.abs((await bounds(moving)).y - snapped.y)).toBeLessThan(0.7);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.keyboard.press("Control+z");
expect(Math.abs((await bounds(moving)).y - before.y)).toBeLessThan(0.7);
});
}
test("离开范围、按 Alt 或关闭自动对齐时自由拖动且不残留辅助线", async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, false);
const start = await bounds(moving);
const target = await bounds(reference);
const grab = await beginDrag(page, moving);
await page.mouse.move(start.cx + grab.x, target.cy + 4 + grab.y, { steps: 8 });
await expect(guides).toBeVisible();
await page.keyboard.down("Alt");
await page.mouse.move(start.cx + grab.x, target.cy + 5 + grab.y);
await expect(guides).toHaveCount(0);
expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(3);
await page.keyboard.up("Alt");
await page.mouse.move(start.cx + grab.x, target.cy + 3 + grab.y);
await expect(guides).toBeVisible();
await page.mouse.move(start.cx + grab.x, target.cy + 35 + grab.y);
await expect(guides).toHaveCount(0);
await page.mouse.up();
await page.getByRole("button", { name: "关闭自动对齐(拖动时 Alt 暂停)", exact: true }).click();
const current = await bounds(moving);
const nextGrab = await beginDrag(page, moving);
await page.mouse.move(current.cx + nextGrab.x, target.cy + 4 + nextGrab.y, { steps: 5 });
await expect(guides).toHaveCount(0);
await page.mouse.up();
expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(2);
});
test("多选块对齐保持相对位置,内部折线路径整体平移,保存重载仍一致", async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, true, false, true);
const partner = page.locator('.react-flow__node[data-id="partner"]');
const before = await bounds(moving);
const partnerBefore = await bounds(partner);
const target = await bounds(reference);
await moving.locator(".sim-node").click();
await partner.locator(".sim-node").click({ modifiers: ["Control"] });
const grab = await beginDrag(page, moving);
await page.mouse.move(target.cx + 4 + grab.x, before.cy + grab.y, { steps: 8 });
await expect(guides.locator('[data-alignment-axis="x"]')).toBeVisible();
const moved = await bounds(moving);
const partnerMoved = await bounds(partner);
expect(Math.abs(moved.cx - target.cx)).toBeLessThan(0.7);
expect(Math.abs(partnerMoved.x - moved.x - (partnerBefore.x - before.x))).toBeLessThan(0.7);
expect(Math.abs(partnerMoved.y - moved.y - (partnerBefore.y - before.y))).toBeLessThan(0.7);
await page.mouse.up();
await expect(guides).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const project = await page.evaluate((key) => JSON.parse(localStorage.getItem(key)!), KEY);
const first = project.nodes.find((node: { id: string }) => node.id === "moving");
const second = project.nodes.find((node: { id: string }) => node.id === "partner");
expect(first.position.x).toBeCloseTo(400, 1);
expect(second.position.x).toBeCloseTo(first.position.x, 3);
expect(second.position.y - first.position.y).toBeCloseTo(130, 3);
expect(project.edges[0].data.routePoints).toEqual([
{ x: 200 + first.position.x, y: 50 + first.position.y },
{ x: 200 + first.position.x, y: 170 + first.position.y },
]);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas", 3);
expect(Math.abs((await bounds(moving)).cx - (await bounds(reference)).cx)).toBeLessThan(0.7);
});
test("接触端口吸附优先,显示端口辅助线,只有落下后才建立连接", async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, true);
const start = await bounds(moving);
const source = await bounds(moving.locator('.port-handle[data-port-name="port_b"]'));
const target = await bounds(reference.locator('.port-handle[data-port-name="port_a"]'));
const grab = await beginDrag(page, moving);
await page.mouse.move(start.cx + target.cx - source.cx - 5 + grab.x,
start.cy + target.cy - source.cy + 4 + grab.y, { steps: 12 });
await expect(guides.locator('[data-alignment-kind="port"]')).toHaveCount(2);
await expect(guides.locator("text")).toHaveCount(0);
await expect(guides).toHaveCSS("opacity", "0.5");
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.mouse.up();
await expect(guides).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
const connectedSource = await bounds(moving.locator('.port-handle[data-port-name="port_b"]'));
expect(Math.hypot(connectedSource.cx - target.cx, connectedSource.cy - target.cy)).toBeLessThan(0.7);
});
+24 -37
View File
@@ -1439,7 +1439,6 @@ test("PNCH012 按参考图使用 45% 结构与独立 15px 文字", async ({
test("不同尺寸档位使用紧凑节点框且旋转时保持中心", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
expect(paneBounds).not.toBeNull();
@@ -1554,41 +1553,26 @@ test("不同尺寸档位使用紧凑节点框且旋转时保持中心", async ({
);
}
await page.getByRole("button", { name: "开启网格吸附", exact: true }).click();
const snappedTarget = { x: 600, y: 610 };
const snappedNode = await dragSymbolToCanvas(
const placedTarget = { x: 601, y: 613 };
const placedNode = await dragSymbolToCanvas(
page,
"amesim_f000",
snappedTarget,
placedTarget,
);
const snappedBounds = await snappedNode.boundingBox();
expect(snappedBounds).not.toBeNull();
const placedBounds = await placedNode.boundingBox();
expect(placedBounds).not.toBeNull();
expect(
Math.abs(
snappedBounds!.x + snappedBounds!.width / 2 -
(paneBounds!.x + snappedTarget.x),
placedBounds!.x + placedBounds!.width / 2 -
(paneBounds!.x + placedTarget.x),
),
).toBeLessThanOrEqual(10);
).toBeLessThan(2);
expect(
Math.abs(
snappedBounds!.y + snappedBounds!.height / 2 -
(paneBounds!.y + snappedTarget.y),
placedBounds!.y + placedBounds!.height / 2 -
(paneBounds!.y + placedTarget.y),
),
).toBeLessThanOrEqual(10);
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:autosave",
);
const project = raw ? JSON.parse(raw) : null;
const position = project?.nodes?.at(-1)?.position;
return position
? { xRemainder: position.x % 18, yRemainder: position.y % 18 }
: null;
}),
)
.toEqual({ xRemainder: 0, yRemainder: 0 });
).toBeLessThan(2);
});
test("LMECHN1 按右侧端口数展开工作区画布且组件库图标保持固定", async ({
@@ -1895,7 +1879,6 @@ test("旧版 LMECHN1 工程会把固定参考端口迁移到动态最大编号",
test("LMECHN1 动态参考端口使用实际锚点参与接触吸附", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const node = await dragSymbolToCanvas(page, "amesim_lmechn1", {
x: 600,
@@ -2953,8 +2936,8 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
await expectSymbolAnchors(signalOrifice, [
{ portName: "res", x: 27.35, y: 43.2 },
{ portName: "port_2", x: 12.8, y: 24 },
{ portName: "port_3", x: 51.2, y: 24 },
{ portName: "port_2", x: 51.2, y: 24 },
{ portName: "port_3", x: 12.8, y: 24 },
]);
await firstOrifice.locator('[data-symbol-envelope="true"]').click();
@@ -3143,13 +3126,17 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await signalOutput.click();
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await clickLocatorCenter(page, firstPhysicalOutput);
await expectNoActivePortConnection(page);
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
await expect(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/);
await clickLocatorCenter(page, compatiblePhysicalPort);
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(2);
await page.keyboard.press("Escape");
await expectNoActivePortConnection(page);
await expect(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/);
@@ -3507,7 +3494,6 @@ test("兼容接口接近时吸附为无连线接触连接,拖开后自动断
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
@@ -3597,11 +3583,16 @@ test("兼容接口接近时吸附为无连线接触连接,拖开后自动断
await unusedPort.click();
await expect(unusedPort).toHaveAttribute("data-connection-state", "origin");
await page.mouse.click(firstPortCenter.x, firstPortCenter.y);
await expectNoActivePortConnection(page);
// 连线模式下点击已连接端口所在元件只确定转角,不取消连线或改选元件。
await expect(unusedPort).toHaveAttribute("data-connection-state", "origin");
await expect(page.locator(".manual-connection-waypoint")).toHaveCount(1);
await expect(contactEdge).not.toHaveClass(/selected|editor-edge-selected/);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(firstPort).toHaveAttribute("data-connected", "true");
await expect(secondPort).toHaveAttribute("data-connected", "true");
await page.keyboard.press("Escape");
await expectNoActivePortConnection(page);
await secondOrifice.locator(".node-symbol").click();
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
@@ -3658,7 +3649,6 @@ test("PNCH012 四端口接触连接后主体各方向仍可直接选中", async
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const chamber = await dragSymbolToCanvas(page, "amesim_pnch012", {
x: 500,
@@ -3835,7 +3825,6 @@ test("信号输出连接后隐藏且拒绝第二条连接", async ({ page }) =>
test("一次拖动会吸附同一落点内的全部兼容接口", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const targetChamber = await dragSymbolToCanvas(page, "amesim_pnch023", {
x: 300,
@@ -3929,7 +3918,6 @@ test("一次拖动会吸附同一落点内的全部兼容接口", async ({ page
test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const orifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
@@ -3966,7 +3954,6 @@ test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ pa
test("旋转后的接口可以与方向不同的兼容接口接触连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const horizontalOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 300,
+271
View File
@@ -0,0 +1,271 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { Position } from "@xyflow/react";
import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures";
import { confirmOrthogonalCorner, extendOrthogonalRoute } from "../../src/edgeRouting";
const PROJECT_KEY = "system-simulation-flow:project:demo-system";
test("每次左键只确认一段并转向,空白预览不插入自动中点", () => {
const source = { x: 0, y: 0 };
const pointer = { x: 120, y: 65 };
const fixed = confirmOrthogonalCorner([source], pointer, Position.Right);
expect(fixed).toEqual([source, { x: 120, y: 0 }]);
expect(extendOrthogonalRoute([source], pointer, Position.Right)).toEqual([
source, { x: 120, y: 0 }, pointer,
]);
expect(extendOrthogonalRoute(fixed, pointer, Position.Right)).toEqual([
source, { x: 120, y: 0 }, pointer,
]);
expect(confirmOrthogonalCorner(fixed, { x: 200, y: 95 }, Position.Right)).toEqual([
source, { x: 120, y: 0 }, { x: 120, y: 95 },
]);
expect(confirmOrthogonalCorner(fixed, { x: 300, y: 0 }, Position.Right)).toBe(fixed);
expect(confirmOrthogonalCorner(fixed, { x: NaN, y: 65 }, Position.Right)).toBe(fixed);
expect(confirmOrthogonalCorner([source], pointer, Position.Bottom)).toEqual([
source, { x: 0, y: 65 },
]);
});
test("各方向端口收尾只调整未确认末段,并保持正交", () => {
for (const sourcePosition of Object.values(Position)) {
for (const targetPosition of Object.values(Position)) {
const fixed = confirmOrthogonalCorner([{ x: 0, y: 0 }], { x: 100, y: 80 }, sourcePosition);
const target = { x: 270, y: 230 };
const route = extendOrthogonalRoute(fixed, target, sourcePosition, targetPosition);
expect(route.slice(0, fixed.length)).toEqual(fixed);
expect(route.at(-1)).toEqual(target);
for (let i = 1; i < route.length; i++) {
expect(route[i].x === route[i - 1].x || route[i].y === route[i - 1].y).toBe(true);
expect(route[i]).not.toEqual(route[i - 1]);
}
const previous = route.at(-2)!;
if (targetPosition === Position.Left || targetPosition === Position.Right) {
expect(previous.y).toBe(target.y);
} else {
expect(previous.x).toBe(target.x);
}
}
}
});
async function loadRoutingProject(page: Page, rotation = 0, reverseSignal = false) {
await prepareApp(page);
if (reverseSignal) {
const catalog = structuredClone(componentCatalog);
const definition = catalog.libraries.flatMap((library) => library.components)
.find((component) => component.type === "generic_sensor")!;
definition.ports = definition.ports.map((port) => ({
...port, kind: "signal", domain: "signal",
nominalRole: port.side === "left" ? "input" : "output",
}));
await page.route("**/api/components/catalog", (route) => route.fulfill({
contentType: "application/json", body: JSON.stringify(catalog),
}));
}
const nodes = [
["origin", 0, 0], ["target", 650, 0], ["obstacle", 280, 260],
["edge-source", 0, 450], ["edge-target", 650, 450],
].map(([id, x, y]) => ({
...structuredClone(wideProject.nodes[0]), id: String(id),
position: { x: Number(x), y: Number(y) },
data: { ...structuredClone(wideProject.nodes[0].data), label: String(id),
rotation: id === "origin" ? rotation : 0 },
}));
const project = { ...structuredClone(wideProject), nodes, edges: [{
...wideProject.edges[0], id: "existing-edge",
source: "edge-source", target: "edge-target",
}] };
await page.addInitScript(({ key, value }) => {
localStorage.setItem(key, JSON.stringify(value));
}, { key: PROJECT_KEY, value: project });
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(5);
await expectAllNodesInsideCanvas(page, ".flow-canvas", 5);
const output = page.locator('.react-flow__node[data-id="origin"] .port-handle[data-port-name="port_b"]');
const input = page.locator('.react-flow__node[data-id="target"] .port-handle[data-port-name="port_a"]');
return {
origin: reverseSignal ? input : output,
target: reverseSignal ? output : input,
draft: page.locator(".manual-connection-draft .react-flow__connection-path"),
};
}
async function center(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 };
}
async function pathPoints(path: Locator) {
const values = (await path.getAttribute("d"))!.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)!.map(Number);
return Array.from({ length: values.length / 2 }, (_, index) => ({
x: values[index * 2], y: values[index * 2 + 1],
}));
}
for (const { rotation, reverseSignal } of [
{ rotation: 0, reverseSignal: false },
{ rotation: 90, reverseSignal: false },
{ rotation: 0, reverseSignal: true },
]) {
test(`逐段定转折后已确认部分保持固定(${reverseSignal ? "信号输入端反向起画" : `旋转 ${rotation}°`})`, async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page, rotation, reverseSignal);
const start = await center(origin);
const waypoint = {
x: Math.round(start.x + (reverseSignal ? -120 : 120)),
y: Math.round(start.y + 65),
};
await origin.click();
await page.mouse.move(waypoint.x, waypoint.y);
await expect(draft).toBeVisible();
const beforeClick = await pathPoints(draft);
// 初始为 L 形橡皮筋,一次点击只确认第一个转角,不固定末端。
expect(beforeClick).toHaveLength(3);
const firstFixed = beforeClick.slice(0, -1);
await page.mouse.click(waypoint.x, waypoint.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect(await pathPoints(draft)).toEqual(beforeClick);
for (const offset of [{x: 100, y: 30}, {x: -45, y: 110}, {x: 90, y: -55}]) {
await page.mouse.move(waypoint.x + offset.x, waypoint.y + offset.y);
await expect.poll(async () => (await pathPoints(draft)).slice(0, firstFixed.length)).toEqual(firstFixed);
}
const secondPoint = { x: waypoint.x + 90, y: waypoint.y - 55 };
const secondPreview = await pathPoints(draft);
const secondFixed = secondPreview.slice(0, -1);
await page.mouse.click(secondPoint.x, secondPoint.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
expect(await pathPoints(draft)).toEqual(secondPreview);
await page.mouse.move(secondPoint.x + 40, secondPoint.y + 110);
await expect.poll(async () => (await pathPoints(draft)).slice(0, secondFixed.length)).toEqual(secondFixed);
await target.hover();
const completedPreview = await pathPoints(draft);
expect(completedPreview.slice(0, secondFixed.length)).toEqual(secondFixed);
await target.click();
await expect(draft).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const persisted = await page.evaluate((key) => {
const project = JSON.parse(localStorage.getItem(key)!);
return project.edges.find((edge: { id: string }) => edge.id !== "existing-edge").data.routePoints;
}, PROJECT_KEY);
expect(persisted).toEqual((reverseSignal ? completedPreview.reverse() : completedPreview).slice(1, -1));
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
const finalizedPath = page.locator('.flow-canvas .react-flow__edge:not([data-id="existing-edge"]) .react-flow__edge-path');
const maximumDeviation = await finalizedPath.evaluate((path: SVGPathElement, fixed) => {
const samples = Array.from({ length: Math.ceil(path.getTotalLength()) + 1 }, (_, index) =>
path.getPointAtLength(index),
);
return Math.max(...fixed.map((point) =>
Math.min(...samples.map((sample) => Math.hypot(sample.x - point.x, sample.y - point.y))),
));
}, secondFixed.slice(1));
expect(maximumDeviation).toBeLessThan(1);
});
}
test("右键逐段退回并恢复绘制方向,退到起点后再次右键取消", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const start = await center(origin);
await origin.click();
await page.mouse.click(start.x + 120, start.y + 65);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
const firstFixed = (await pathPoints(draft)).slice(0, 2);
await page.mouse.click(start.x + 200, start.y + 120);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
const afterUndo = await pathPoints(draft);
expect(afterUndo.slice(0, 2)).toEqual(firstFixed);
expect(afterUndo[2].x).toBe(firstFixed[1].x); // 恢复为纵向绘制
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveAttribute("data-waypoint-count", "0");
const atStart = await pathPoints(draft);
expect(atStart[1].y).toBe(atStart[0].y); // 恢复起始端口的横向
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveCount(0);
await expect(origin).toHaveAttribute("data-connection-state", "idle");
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
// 取消的草稿不生成线路,下一次连接不受 React Flow 的点击状态干扰。
await origin.click();
await target.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await expect(draft).toHaveCount(0);
});
test("绘线时缩放、滚动条和中键平移不会确认额外转角或改变已确认路径", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const start = await center(origin);
await origin.click();
await page.mouse.click(start.x + 120, start.y + 65);
const fixed = (await pathPoints(draft)).slice(0, 2);
const viewport = page.locator(".flow-canvas .react-flow__viewport");
const beforeZoom = await viewport.getAttribute("style");
await page.keyboard.down("Control");
await page.mouse.wheel(0, 240);
await page.keyboard.up("Control");
await expect(viewport).not.toHaveAttribute("style", beforeZoom!);
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
const scrollbar = page.getByRole("scrollbar", { name: "水平滚动画布" });
const beforeScroll = await viewport.getAttribute("style");
await scrollbar.focus();
await page.keyboard.press("ArrowRight");
await expect(viewport).not.toHaveAttribute("style", beforeScroll!);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
const beforePan = await viewport.getAttribute("style");
await page.mouse.move(start.x + 180, start.y + 180);
await page.mouse.down({ button: "middle" });
await page.mouse.move(start.x + 220, start.y + 200, { steps: 4 });
await page.mouse.up({ button: "middle" });
await expect(viewport).not.toHaveAttribute("style", beforePan!);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
await target.click();
await expect(draft).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
});
test("连接模式点击元件和已有连线只添加折点,取消后恢复选择", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const obstacle = page.locator('.react-flow__node[data-id="obstacle"]');
const obstacleCenter = await center(obstacle.locator(".sim-node"));
const edge = page.locator('.react-flow__edge[data-id="existing-edge"]');
const edgeCenter = await edge.locator(".react-flow__edge-path").evaluate((path: SVGPathElement) => {
const p = path.getPointAtLength(path.getTotalLength() / 2).matrixTransform(path.getScreenCTM()!);
return { x: p.x, y: p.y };
});
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(edge).toHaveClass(/selected|editor-edge-selected/);
await origin.click();
await expect(origin).toHaveAttribute("data-connection-state", "origin");
await page.mouse.click(obstacleCenter.x, obstacleCenter.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
await expect(obstacle).not.toHaveClass(/selected/);
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
await expect(edge).not.toHaveClass(/selected|editor-edge-selected/);
await expect(page.locator(".manual-edge-segment-handle, .manual-edge-corner-handle, .manual-edge-route-handle")).toHaveCount(0);
await expect(origin).toHaveAttribute("data-connection-state", "origin");
await expect(target).toHaveAttribute("data-connection-state", "compatible");
const beforeDrag = await obstacle.getAttribute("style");
await page.mouse.move(obstacleCenter.x, obstacleCenter.y);
await page.mouse.down();
await page.mouse.move(obstacleCenter.x + 35, obstacleCenter.y + 25, { steps: 3 });
await page.mouse.up();
expect(await obstacle.getAttribute("style")).toBe(beforeDrag);
await expect(page.locator(".react-flow__selection")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(draft).toHaveCount(0);
await page.mouse.click(obstacleCenter.x, obstacleCenter.y);
await expect(obstacle).toHaveClass(/selected/);
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(edge).toHaveClass(/selected|editor-edge-selected/);
});
@@ -130,7 +130,6 @@ test("多选块拖动会即时断开边界接触,并由块内空闲端口整
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const fixedNode = await dragGenericToCanvas(page, { x: 150, y: 180 });
const firstMovingNode = await dragGenericToCanvas(page, { x: 410, y: 180 });
@@ -211,7 +210,6 @@ test("复制块保留内部连接,同时未连接端口仍参与待放置吸
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const first = await dragGenericToCanvas(page, { x: 170, y: 180 });
const second = await dragGenericToCanvas(page, { x: 410, y: 370 });
+5 -5
View File
@@ -490,7 +490,7 @@ test("恢复自动保存工程后自动适配建模画布", async ({ page }) =>
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
});
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
test("切换到结果页时自动适配只读系统图,旧网格线设置不再生效", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(({ project, snapshot }) => {
window.sessionStorage.setItem(
@@ -513,7 +513,7 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
page.locator(
'.flow-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(1);
).toHaveCount(0);
await expect(
page.locator(
'.flow-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
@@ -533,7 +533,7 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(1);
).toHaveCount(0);
await expect(
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
@@ -659,8 +659,8 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
}),
);
for (const shape of idleResultPortShapes) {
expect(Math.abs(shape.width - 6)).toBeLessThan(0.05);
expect(Math.abs(shape.height - 6)).toBeLessThan(0.05);
expect(Math.abs(shape.width - 6 * resultViewportZoom)).toBeLessThan(0.05);
expect(Math.abs(shape.height - 6 * resultViewportZoom)).toBeLessThan(0.05);
expect(shape.glyphWidth).not.toBeNull();
expect(shape.glyphHeight).not.toBeNull();
expect(
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, type Locator } from "@playwright/test";
import { prepareApp } from "./fixtures";
async function bounds(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { ...box!, cx: box!.x + box!.width / 2, cy: box!.y + box!.height / 2 };
}
test("不再提供网格吸附或网格线开关,拖入、微移和粘贴不再对网格取整", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
await expect(page.getByRole("button", { name: /网格吸附|主网格线/ })).toHaveCount(0);
await expect(page.locator('pattern[id$="major-grid-lines"]')).toHaveCount(0);
await expect(page.locator('pattern[id$="minor-grid-dots"]')).toHaveCount(1);
const pane = page.locator(".flow-canvas .react-flow__pane");
const canvas = await bounds(pane);
const drop = { x: 231, y: 197 };
await page.getByRole("button", { name: /通用测试元件/ }).dragTo(pane, { targetPosition: drop });
const node = page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]');
const initial = await bounds(node);
expect(initial.cx).toBeCloseTo(canvas.x + drop.x, 0);
expect(initial.cy).toBeCloseTo(canvas.y + drop.y, 0);
// 先跨过 React Flow 的拖拽阈值,再移动少于原网格间距的距离。
await page.mouse.move(initial.cx, initial.cy);
await page.mouse.down();
await page.mouse.move(initial.cx, initial.cy + 5);
const started = await bounds(node);
await page.mouse.move(initial.cx + 7, initial.cy + 16);
await page.mouse.up();
const moved = await bounds(node);
expect(moved.x - started.x).toBeCloseTo(7, 0);
expect(moved.y - started.y).toBeCloseTo(11, 0);
await page.keyboard.press("Control+c");
await page.keyboard.press("Control+v");
const pointer = { x: canvas.x + 493, y: canvas.y + 371 };
await page.mouse.move(pointer.x, pointer.y);
const preview = page.locator(".flow-canvas .pending-paste-node");
await expect(preview).toBeVisible();
const pending = await bounds(preview);
expect(pending.cx).toBeCloseTo(pointer.x, 0);
expect(pending.cy).toBeCloseTo(pointer.y, 0);
await page.mouse.click(pointer.x, pointer.y);
await expect(preview).toHaveCount(0);
const placed = await bounds(page.locator('.react-flow__node[data-id="generic_sensor_2"]'));
expect(placed.cx).toBeCloseTo(pointer.x, 0);
expect(placed.cy).toBeCloseTo(pointer.y, 0);
});
test("忽略旧版网格线开关并保留网格点偏好", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(() => localStorage.setItem(
"system-simulation-flow:canvas-grid-visibility", JSON.stringify({ lines: true, dots: false }),
));
await page.goto("/");
await expect(page.locator(".flow-canvas .react-flow__background")).toHaveCount(0);
await expect(page.getByRole("button", { name: /网格吸附|主网格线/ })).toHaveCount(0);
await page.getByRole("button", { name: "显示网格点", exact: true }).click();
await expect(page.locator('pattern[id$="minor-grid-dots"]')).toHaveCount(1);
await expect(page.locator('pattern[id$="major-grid-lines"]')).toHaveCount(0);
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem(
"system-simulation-flow:canvas-grid-visibility",
)!))).toEqual({ dots: true });
});
+6 -1
View File
@@ -32,7 +32,12 @@ for (const diagnosticsMode of ["native", "legacy-native", "python"] as const) {
} else {
await expect(consolePanel).not.toContainText("最大方程残差");
}
const saved = await page.evaluate(() => JSON.parse(sessionStorage.getItem("system-simulation-flow:latest-result") ?? "null"));
await expect.poll(() => page.evaluate(() => JSON.parse(sessionStorage.getItem("system-simulation-flow:latest-result") ?? "null")?.storage)).toBe("indexeddb");
const saved = await page.evaluate(async () => {
// @ts-expect-error Vite serves the persistence module in the test browser.
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
return loadStoredResultSnapshot();
});
expect(saved.result.diagnostics.sampleCount).toBe(3);
expect(saved.result.series).toEqual(resultSnapshot.result.series);
await page.reload();
+122
View File
@@ -0,0 +1,122 @@
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<string, any>(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);
});
@@ -0,0 +1,234 @@
import { expect, test, type Page } from "@playwright/test";
import { chartDisplaySamples, chartSampleBounds, preparedChartSamples, sampledChartPath } from "../../src/chartData";
import { resultSnapshot, prepareApp } from "./fixtures";
test("million-point plots preserve extrema and raw indices within a pixel-sized path", () => {
const time = Array.from({ length: 1_000_000 }, (_, i) => i * 0.001);
const values = time.map((t) => Math.sin(t));
values[123456] = 1e9;
values[654321] = -1e9;
const started = performance.now();
const samples = preparedChartSamples(time, values);
const displayed = chartDisplaySamples(samples, 0, 1000, 800);
expect(chartSampleBounds(samples)).toMatchObject({ yMin: -1e9, yMax: 1e9 });
expect(displayed.some((sample) => sample.dataIndex === 123456 && sample.y === 1e9)).toBe(true);
expect(displayed.some((sample) => sample.dataIndex === 654321 && sample.y === -1e9)).toBe(true);
expect(displayed.length).toBeLessThanOrEqual(4 * 802);
expect(preparedChartSamples(time, values)).toBe(samples);
expect(chartDisplaySamples(samples, 0, 1000, 800)).toBe(displayed);
const narrow = chartDisplaySamples(samples, 123.450, 123.465, 800);
expect(narrow.some((sample) => sample.dataIndex === 123456)).toBe(true);
expect(narrow.length).toBeLessThan(20);
expect(displayed.every((sample) => sample.y === values[sample.dataIndex])).toBe(true);
console.log(JSON.stringify({ probe: "million-point-chart", rawPoints: samples.length, displayPoints: displayed.length, elapsedMs: performance.now() - started }));
});
test("pixel decimation preserves gaps, duplicate-time jumps, boundaries and unsorted input", () => {
const time = Array.from({ length: 10000 }, (_, i) => i);
const values = time.map(() => 0);
time[4000] = Number.NaN;
time[5001] = time[5000];
values[5001] = 999;
const samples = preparedChartSamples(time, values);
const display = chartDisplaySamples(samples, 0, 10000, 10);
expect(display.some((sample) => sample.breakBefore && sample.dataIndex === 4001)).toBe(true);
expect(display.some((sample) => sample.dataIndex === 5001 && sample.y === 999)).toBe(true);
const path = sampledChartPath(time, values, (x) => x, (y) => y, 0, 10000, 10);
expect(path.match(/\bM /g)).toHaveLength(2);
const crossing = chartDisplaySamples(preparedChartSamples([0, 10], [-10, 10]), 4, 6, 10);
expect(crossing.map((sample) => sample.x)).toEqual([0, 10]);
const unsorted = preparedChartSamples([2, 0, 1], [20, 0, 10]);
expect(chartDisplaySamples(unsorted, 0, 2, 1)).toEqual(unsorted);
});
async function probePage(page: Page) {
await prepareApp(page);
await page.route("**/result-performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>Result probe</title>" }));
await page.goto("/result-performance-probe");
}
test("chunked persistence restores one million doubles exactly beyond sessionStorage quota", async ({ page }) => {
await probePage(page);
const measurement = await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot, loadStoredResultSnapshot, resultSavePending } = await import("/src/resultPersistence.ts");
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const snapshot = { ...template, result: { ...template.result, series: { time: [0, 1], values, empty: [] } } };
const serialized = JSON.stringify(snapshot);
const originalStringify = JSON.stringify;
let largeStringifyCalls = 0;
JSON.stringify = function(value: unknown, ...args: any[]) {
if ((value as any)?.result?.series || (value as any)?.snapshot?.result?.series) largeStringifyCalls++;
return (originalStringify as any)(value, ...args);
} as typeof JSON.stringify;
let ticks = 0;
const timer = setInterval(() => { ticks++; }, 0);
const start = performance.now();
const saving = storeResultSnapshot(snapshot);
const savingImmediately = resultSavePending();
await saving;
const writeMs = performance.now() - start;
clearInterval(timer);
JSON.stringify = originalStringify;
const readStart = performance.now();
const loaded = await loadStoredResultSnapshot();
return {
rawJsonBytes: serialized.length, writeMs, readMs: performance.now() - readStart, ticks,
savingImmediately, pendingAfter: resultSavePending(), largeStringifyCalls,
markerBytes: sessionStorage.getItem("system-simulation-flow:latest-result")!.length,
equal: loaded.result.series.values.length === values.length && loaded.result.series.values.every((value: number, i: number) => value === values[i]),
empty: loaded.result.series.empty,
};
}, resultSnapshot);
expect(measurement).toMatchObject({ equal: true, savingImmediately: true, pendingAfter: false, largeStringifyCalls: 0, empty: [] });
expect(measurement.ticks).toBeGreaterThan(1);
expect(measurement.markerBytes).toBeLessThan(200);
console.log(JSON.stringify({ probe: "large-result-persistence", ...measurement }));
await page.reload();
expect(await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
return (await loadStoredResultSnapshot()).result.series.values.length;
})).toBe(1_000_000);
});
test("failed and superseded saves preserve the last complete snapshot", async ({ page }) => {
await probePage(page);
const outcome = await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot, loadStoredResultSnapshot, resultSavePending } = await import("/src/resultPersistence.ts");
await storeResultSnapshot(template);
const firstMarker = sessionStorage.getItem("system-simulation-flow:latest-result");
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function() { throw new DOMException("Injected quota failure", "QuotaExceededError"); };
let failed = false;
try { await storeResultSnapshot({ ...template, id: "failed" }); } catch { failed = true; }
IDBObjectStore.prototype.put = originalPut;
const preserved = firstMarker === sessionStorage.getItem("system-simulation-flow:latest-result") && (await loadStoredResultSnapshot()).id === template.id;
const old = storeResultSnapshot({ ...template, id: "older", result: { ...template.result, series: { time: Array.from({ length: 200000 }, (_, i) => i) } } });
const latest = storeResultSnapshot({ ...template, id: "newest" });
const saved = await Promise.all([old, latest]);
return { failed, preserved, saved, newest: (await loadStoredResultSnapshot()).id, pending: resultSavePending() };
}, resultSnapshot);
expect(outcome).toEqual({ failed: true, preserved: true, saved: [false, true], newest: "newest", pending: false });
});
async function prepareLargeResult(page: Page) {
await probePage(page);
await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot } = await import("/src/resultPersistence.ts");
const count = 250000;
const time = Array.from({ length: count }, (_, i) => i * 0.001);
const series: Record<string, number[]> = { time };
const variables = Array.from({ length: 3 }, (_, index) => ({
...template.result.variables[0], key: `generic_sensor_1.stress_${index}`, name: `stress_${index}`,
label: `压力${index}`, quantity: "pressure", unit: "Pa", order: index,
}));
variables.forEach((variable, index) => { series[variable.key] = time.map((t) => 100000 + Math.sin(t + index) * 1000); });
series[variables[0].key][123456] = 1e9;
const snapshot = { ...template, id: "large-chart-stress", result: { ...template.result, variables, series, diagnostics: { ...template.result.diagnostics, sampleCount: count } } };
await storeResultSnapshot(snapshot);
}, resultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.locator(".results-variable-list button")).toHaveCount(3);
}
test("250,000-sample single, multi and stacked charts stay bounded and export full raw data", async ({ page }) => {
test.setTimeout(90000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await prepareLargeResult(page);
const variables = page.locator(".results-variable-list button");
await variables.first().click();
const single = page.locator('.result-chart-window[data-chart-kind="single"]');
await expect(single).toBeVisible();
const svg = single.locator('svg[data-result-chart="true"]');
expect(Number(await svg.getAttribute("data-view-y-max"))).toBeGreaterThan(1e9);
const firstPath = await single.locator('path[data-chart-curve="true"], g[data-chart-clipped-series="true"] path, path[data-chart-clipped-series="true"]').first().getAttribute("d");
expect((firstPath!.match(/\b[ML] /g) ?? []).length).toBeLessThan(5000);
await single.getByRole("combobox", { name: "压力0纵轴单位", exact: true }).selectOption("kPa");
expect(Number(await svg.getAttribute("data-view-y-max"))).toBeGreaterThan(1e6);
await single.getByRole("button", { name: "打开 压力0 曲线游标", exact: true }).click();
const readings = await single.locator(".result-chart-cursor-panel dd").allTextContents();
const readTime = Number.parseFloat(readings[0]);
const readValue = Number.parseFloat(readings[1]);
expect(readTime).toBe(125);
expect(readValue).toBeCloseTo((100000 + Math.sin(125) * 1000) / 1000, 4);
// X-axis wheel zoom must redraw only the viewport and keep the exact raw samples.
await single.getByRole("button", { name: "打开 压力0 曲线缩放", exact: true }).click();
const xAxis = svg.locator('[data-chart-zoom-axis="x"]');
const bounds = await xAxis.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2);
await page.mouse.wheel(0, -160);
await expect(svg).toHaveAttribute("data-zoomed", "true");
await single.getByRole("button", { name: "恢复 压力0 原始尺寸", exact: true }).click();
await page.getByRole("button", { name: "新建同单位多曲线对比窗口", exact: true }).click();
const multi = page.locator('.result-chart-window[data-chart-kind="multi"]');
await variables.nth(0).dragTo(multi);
await variables.nth(1).dragTo(multi);
await expect(multi).toContainText("2/2 条显示");
await page.getByRole("button", { name: "新建异单位上下对比窗口", exact: true }).click();
const mixed = page.locator('.result-chart-window[data-chart-kind="mixed"]');
await variables.nth(1).dragTo(mixed);
await variables.nth(2).dragTo(mixed);
await expect(mixed).toContainText("2/2 条显示");
const paths = await page.locator('g[data-chart-clipped-series="true"] path, path[data-chart-clipped-series="true"]').evaluateAll((elements) => elements.map((element) => (element.getAttribute("d")?.match(/\b[ML] /g) ?? []).length));
expect(paths.length).toBeGreaterThanOrEqual(5);
expect(paths.every((count) => count < 10000)).toBe(true);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "下载结果文件", exact: true }).click();
const download = await downloadPromise;
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(Buffer.from(chunk));
const exported = JSON.parse(Buffer.concat(chunks).toString("utf8"));
expect(exported.snapshot.result.series.time).toHaveLength(250000);
expect(exported.snapshot.result.series["generic_sensor_1.stress_0"][123456]).toBe(1e9);
// Compare the exact original browser data, not Math.sin recomputed in Node's V8.
const rawMatches = await page.evaluate(async (exportedSeries) => {
// @ts-expect-error browser-side Vite module
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
const raw = (await loadStoredResultSnapshot()).result.series;
return Object.entries(exportedSeries).every(([key, values]) => {
const numericValues = values as number[];
return numericValues.length === raw[key].length && numericValues.every((value, i) => Object.is(value, raw[key][i]));
});
}, exported.snapshot.result.series);
expect(rawMatches).toBe(true);
await page.reload();
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.locator(".result-chart-window")).toHaveCount(3);
expect(errors).toEqual([]);
console.log(JSON.stringify({ probe: "large-multi-chart", pointsPerCurve: 250000, paths }));
});
test("browser chart benchmark keeps redraw paths small without resampling raw cursor data", async ({ page }) => {
await probePage(page);
const metrics = await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const { sampledChartPath, preparedChartSamples } = await import("/src/chartData.ts");
// @ts-expect-error browser-side Vite module
const { segmentedChartPath } = await import("/src/resultEventSeries.ts");
const time = Array.from({ length: 250000 }, (_, i) => i * .001);
const values = time.map((x) => Math.sin(x));
const x = (value: number) => value / 250 * 800;
const y = (value: number) => 200 - value * 100;
const start = performance.now();
const oldPath = segmentedChartPath(time, values, x, y);
const oldMs = performance.now() - start;
const first = performance.now();
const newPath = sampledChartPath(time, values, x, y, 0, 250, 800);
const firstMs = performance.now() - first;
const redraw = performance.now();
for (let i = 0; i < 100; i++) sampledChartPath(time, values, x, y, 0, 250, 800);
const redrawMs = (performance.now() - redraw) / 100;
return { points: time.length, oldMs, firstMs, redrawMs, oldCommands: (oldPath.match(/\b[ML] /g) ?? []).length, newCommands: (newPath.match(/\b[ML] /g) ?? []).length, rawSamples: preparedChartSamples(time, values).length };
});
expect(metrics.rawSamples).toBe(250000);
expect(metrics.newCommands).toBeLessThan(3209);
expect(metrics.oldCommands).toBe(250000);
console.log(JSON.stringify({ probe: "browser-chart-comparison", ...metrics }));
});
@@ -0,0 +1,328 @@
import { expect, test, type Page } from "@playwright/test";
import { createNdjsonLineDecoder } from "../../src/ndjsonStream";
import { prepareApp, resultSnapshot, wideProject } from "./fixtures";
test("NDJSON preserves UTF-8, CRLF, empty lines and an unterminated final result", () => {
const events = [
{ event: "progress", message: "正在仿真 🚀", progress: 5 },
{ event: "result", result: { values: [1, -2.3e-12, null], message: "第一行\n第二行" } },
];
const bytes = new TextEncoder().encode(`\n${JSON.stringify(events[0])}\r\n\n${JSON.stringify(events[1])}`);
for (const size of [1, 2, 3, 7, 64, bytes.length]) {
const decoded: unknown[] = [];
const decoder = createNdjsonLineDecoder((line) => {
if (line.trim()) decoded.push(JSON.parse(line));
});
for (let offset = 0; offset < bytes.length; offset += size) {
decoder.write(bytes.subarray(offset, offset + size));
}
decoder.finish();
decoder.finish();
expect(decoded).toEqual(events);
}
});
test("NDJSON immediately propagates errors without consuming later events", () => {
const seen: unknown[] = [];
const decoder = createNdjsonLineDecoder((line) => seen.push(JSON.parse(line)));
expect(() => decoder.write(new TextEncoder().encode('1\ninvalid\n2\n'))).toThrow();
expect(seen).toEqual([1]);
const truncated = createNdjsonLineDecoder((line) => JSON.parse(line));
truncated.write(new TextEncoder().encode('{"event":"result"'));
expect(() => truncated.finish()).toThrow();
});
test("large NDJSON stress preserves every number across 1 KiB and 64 KiB chunks", () => {
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const expected = { event: "result", result: { message: "大结果", values } };
const bytes = new TextEncoder().encode(JSON.stringify(expected) + "\n");
for (const size of [1024, 65536]) {
let decoded: typeof expected | undefined;
let calls = 0;
const start = performance.now();
const decoder = createNdjsonLineDecoder((line) => {
calls += 1;
decoded = JSON.parse(line);
});
for (let offset = 0; offset < bytes.length; offset += size) {
decoder.write(bytes.subarray(offset, offset + size));
}
decoder.finish();
const elapsedMs = performance.now() - start;
expect(calls).toBe(1);
expect(decoded?.result.message).toBe("大结果");
expect(decoded?.result.values.length).toBe(values.length);
// Avoid a million matcher calls; compare every sample, not just the endpoints.
expect(decoded!.result.values.every((value, index) => value === values[index])).toBe(true);
console.log(JSON.stringify({ probe: "ndjson-million-values", bytes: bytes.length, chunkBytes: size, elapsedMs }));
}
});
test("100,000 progress messages coalesce while terminal states, logs and cleanup remain immediate", async ({ page }) => {
await page.route("**/performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>Probe</title>" }));
await page.goto("/performance-probe");
const clockStart = new Date("2026-09-10T00:00:00Z");
await page.clock.install({ time: clockStart });
await page.clock.pauseAt(new Date(clockStart.getTime() + 1000));
await page.evaluate(async () => {
// @ts-expect-error Vite serves this browser-side TypeScript module.
const { createSimulationConsoleStore } = await import("/src/simulationConsoleStore.ts");
const initial = { state: "running", percent: 0, message: "积分中", startTime: 0, simulatedTime: 0, totalTime: 10 };
const store = createSimulationConsoleStore(initial);
const probe = { store, initial, notifications: 0 };
store.subscribe(() => { probe.notifications += 1; });
(window as any).__storeProbe = probe;
for (let i = 1; i <= 100_000; i++) store.queueProgress({ ...initial, percent: i / 1000, simulatedTime: i / 10000 });
});
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(0);
await page.clock.runFor(249);
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(0);
await page.clock.runFor(1);
expect(await page.evaluate(() => {
const p = (window as any).__storeProbe;
return [p.notifications, p.store.getSnapshot().progress.percent];
})).toEqual([1, 100]);
for (let i = 1; i <= 4; i++) {
await page.evaluate((value) => {
const p = (window as any).__storeProbe;
for (let j = 0; j < 1000; j++) p.store.queueProgress({ ...p.initial, percent: value });
}, i);
await page.clock.runFor(250);
}
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(5);
await page.evaluate(() => {
const p = (window as any).__storeProbe;
p.store.queueProgress({ ...p.initial, percent: 80 });
p.store.setProgress((current: any) => ({ ...current, message: "正在停止仿真" }));
});
await page.clock.runFor(1000);
expect(await page.evaluate(() => {
const p = (window as any).__storeProbe;
return [p.notifications, p.store.getSnapshot().progress.message, p.store.getSnapshot().progress.percent];
})).toEqual([6, "正在停止仿真", 80]);
for (const state of ["success", "warning", "error"]) {
await page.evaluate((state) => {
const p = (window as any).__storeProbe;
p.store.queueProgress({ ...p.initial, percent: 90 });
p.store.setProgress({ ...p.initial, state, message: state });
}, state);
await page.clock.runFor(500);
expect(await page.evaluate(() => (window as any).__storeProbe.store.getSnapshot().progress.state)).toBe(state);
}
const counts = await page.evaluate(() => {
const p = (window as any).__storeProbe;
for (let i = 0; i < 1000; i++) p.store.appendEntry({ id: i, timestamp: "", level: "info", message: String(i), format: "message" });
const ids = p.store.getSnapshot().entries.map((entry: any) => entry.id);
p.store.clearEntries();
p.store.queueProgress({ ...p.initial, percent: 1 });
p.store.cancelPendingProgress();
return { length: ids.length, first: ids[0], last: ids.at(-1), notifications: p.notifications };
});
expect(counts).toMatchObject({ length: 400, first: 600, last: 999 });
await page.clock.runFor(1000);
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(counts.notifications);
});
async function prepareStreamApp(page: Page, nodeCount = 4) {
await prepareApp(page);
// Instrument only the test-served module; production code has no render counters.
await page.route("**/src/App.tsx", async (route) => {
const response = await route.fetch();
const source = await response.text();
expect(source).toContain("function FlowWorkbench() {");
await route.fulfill({ response, body: source.replace("function FlowWorkbench() {", "function FlowWorkbench() { window.__workbenchRenders = (window.__workbenchRenders || 0) + 1;") });
});
await page.addInitScript(() => {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
if (!String(args[0]).includes("/api/system-xml/simulate-stream")) return nativeFetch(...args);
const encoder = new TextEncoder();
return new Response(new ReadableStream({ start(controller) {
(window as any).__sendSimulation = (events: unknown[], close = false) => {
controller.enqueue(encoder.encode(events.map((event) => JSON.stringify(event)).join("\n") + "\n"));
if (close) controller.close();
};
(window as any).__simulationController = controller;
} }), { headers: { "Content-Type": "application/x-ndjson" } });
};
});
await page.goto("/");
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
...wideProject.nodes[0], id: `generic_sensor_${i + 1}`,
position: { x: (i % 20) * 160, y: Math.floor(i / 20) * 150 },
data: { ...wideProject.nodes[0].data, label: `sensor_${i + 1}` },
}));
const project = { ...wideProject, nodes, edges: nodes.map((node, i) => ({
id: `edge_${i}`, source: node.id, target: nodes[(i + 1) % nodes.length].id,
sourceHandle: "port_b", targetHandle: "port_a", data: { isContactEdge: false },
})) };
await page.locator('input[type="file"]').setInputFiles({ name: "stress.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(project)) });
await expect(page.locator(".react-flow__node")).toHaveCount(nodeCount);
// Let import/layout and the 600 ms autosave settle before counting simulation-only renders.
await expect(page.getByText(/已自动保存/).first()).toBeVisible();
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expect.poll(() => page.evaluate(() => typeof (window as any).__sendSimulation)).toBe("function");
}
test("300-node workbench does not rerender for a 10,000-event progress storm", async ({ page }) => {
test.setTimeout(60_000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await prepareStreamApp(page, 300);
await page.evaluate(() => (window as any).__sendSimulation([{ event: "progress", phase: "integrating", progress: 1, simulatedTime: 0.1, totalTime: 10, message: "正在积分" }]));
const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(consolePanel).toContainText("正在积分");
const before = await page.evaluate(() => (window as any).__workbenchRenders);
const started = Date.now();
for (let burst = 0; burst < 20; burst++) {
await page.evaluate((burst) => {
const events = Array.from({ length: 500 }, (_, i) => ({ event: "progress", phase: "integrating", progress: 1 + (burst * 500 + i) / 125, simulatedTime: 0.1 + (burst * 500 + i) / 1250, totalTime: 10, message: "正在积分", activitySequence: burst * 500 + i + 1 }));
(window as any).__sendSimulation(events);
return new Promise((resolve) => setTimeout(resolve, 25));
}, burst);
}
await expect(consolePanel).toContainText("81%");
const after = await page.evaluate(() => (window as any).__workbenchRenders);
expect(after).toBe(before);
await expect(consolePanel).toHaveClass(/minimized/);
await page.evaluate((result) => (window as any).__sendSimulation([{ event: "result", result }], true), resultSnapshot.result);
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
await expect(consolePanel).toContainText("仿真完成");
await expect(consolePanel).toHaveClass(/minimized/);
expect(errors).toEqual([]);
console.log(JSON.stringify({ probe: "progress-storm", nodes: 300, events: 10000, workbenchRenders: after - before, elapsedMs: Date.now() - started }));
});
test("stop and stream errors cannot be overwritten by delayed running progress", async ({ page }) => {
await prepareStreamApp(page);
let cancellationRequests = 0;
await page.route("**/api/system-xml/simulations/*/cancel", (route) => {
cancellationRequests++;
return route.fulfill({ json: { accepted: true, status: "running" } });
});
await page.evaluate(() => (window as any).__sendSimulation([
{ event: "progress", phase: "integrating", progress: 1, message: "正在积分", simulatedTime: 0.1, totalTime: 10 },
{ event: "progress", phase: "integrating", progress: 40, message: "正在积分", simulatedTime: 4, totalTime: 10 },
]));
await page.getByRole("button", { name: "停止仿真", exact: true }).click();
await expect.poll(() => cancellationRequests).toBe(1);
await page.evaluate((result) => (window as any).__sendSimulation([{ event: "result", result: { ...result, status: "stopped", success: false, partial: true, simulatedUntil: 4 } }], true), resultSnapshot.result);
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(panel).toContainText("已手动终止");
await page.clock.install();
await page.clock.runFor(1000);
await expect(panel).toContainText("已手动终止");
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await page.evaluate(() => (window as any).__sendSimulation([
{ event: "progress", phase: "integrating", progress: 5, message: "运行中", simulatedTime: 0.5, totalTime: 10 },
{ event: "progress", phase: "integrating", progress: 7, message: "运行中", simulatedTime: 0.7, totalTime: 10 },
{ event: "error", message: "压力测试错误", status: 422 },
], true));
await page.clock.runFor(1000);
await expect(panel).toContainText("压力测试错误");
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
});
test("browser benchmark compares old and new decoders on the same 18 MiB result", async ({ page }) => {
await page.route("**/performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>Probe</title>" }));
await page.goto("/performance-probe");
const measurement = await page.evaluate(async () => {
// @ts-expect-error Vite serves this browser-side TypeScript module.
const { createNdjsonLineDecoder } = await import("/src/ndjsonStream.ts");
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const bytes = new TextEncoder().encode(JSON.stringify({ event: "result", values }) + "\n");
const chunks: Uint8Array[] = [];
for (let i = 0; i < bytes.length; i += 65536) chunks.push(bytes.subarray(i, i + 65536));
const timings: Array<{ oldMs: number; newMs: number }> = [];
let correct = true;
for (let run = 0; run < 3; run++) {
const oldDecoder = new TextDecoder();
let buffer = "";
let oldValues: number[] = [];
let started = performance.now();
for (const chunk of chunks) {
buffer += oldDecoder.decode(chunk, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
oldValues = JSON.parse(buffer.slice(0, newlineIndex).trim()).values;
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf("\n");
}
}
const oldMs = performance.now() - started;
let newValues: number[] = [];
started = performance.now();
const decoder = createNdjsonLineDecoder((line: string) => { newValues = JSON.parse(line.trim()).values; });
chunks.forEach((chunk) => decoder.write(chunk));
decoder.finish();
const newMs = performance.now() - started;
correct &&= oldValues.length === values.length && newValues.length === values.length &&
newValues.every((value, i) => value === values[i] && value === oldValues[i]);
timings.push({ oldMs, newMs });
}
return { bytes: bytes.length, chunks: chunks.length, correct, timings };
});
expect(measurement.correct).toBe(true);
// Record timings instead of a hardware-dependent millisecond assertion.
console.log(JSON.stringify({ probe: "browser-ndjson-comparison", ...measurement }));
});
test("full app publishes a million-value streamed result without opening charts", async ({ page }) => {
await prepareStreamApp(page);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const measurement = await page.evaluate((template) => {
const points = 10000;
const series: Record<string, number[]> = { time: Array.from({ length: points }, (_, i) => i * 10 / (points - 1)) };
for (let v = 0; v < 100; v++) series[`generic_sensor_1.value_${v}`] = Array.from({ length: points }, (_, i) => Math.sin(i * 0.013 + v) * 1e5);
const result = { ...template, diagnostics: { ...template.diagnostics, sampleCount: points }, series };
const bytes = new TextEncoder().encode(JSON.stringify({ event: "result", result }) + "\n");
(window as any).__resultStressStarted = performance.now();
for (let i = 0; i < bytes.length; i += 65536) (window as any).__simulationController.enqueue(bytes.subarray(i, i + 65536));
(window as any).__simulationController.close();
return { bytes: bytes.length, points, variables: 100 };
}, resultSnapshot.result);
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
await expect(page.getByRole("complementary", { name: "仿真控制台", exact: true })).toContainText("10000 个采样点");
expect(errors).toEqual([]);
const elapsedMs = await page.evaluate(() => performance.now() - (window as any).__resultStressStarted);
console.log(JSON.stringify({ probe: "large-result-publication", ...measurement, elapsedMs }));
});
test("activity watchdog still sees all messages and recovers a genuinely stalled run", async ({ page }) => {
await prepareStreamApp(page);
const clockStart = new Date("2026-09-10T00:00:00Z");
await page.clock.install({ time: clockStart });
await page.clock.pauseAt(new Date(clockStart.getTime() + 1000));
let cancellationRequests = 0;
await page.route("**/api/system-xml/simulations/*/cancel", (route) => {
cancellationRequests++;
return route.fulfill({ json: { accepted: true, status: "running" } });
});
await page.route(/\/api\/system-xml\/simulations\/[^/]+$/, (route) => route.fulfill({ json: {
status: "stalled", result: { ...resultSnapshot.result, status: "stalled", success: false, simulatedUntil: 5 },
} }));
const sendHeartbeat = (sequence: number) => page.evaluate((sequence) => (window as any).__sendSimulation([{
event: "progress", phase: "integrating", progress: 50, simulatedTime: 5, totalTime: 10,
message: "正在积分", heartbeat: true, activitySequence: sequence, acceptedStepSequence: 10, acceptedTime: 5,
}]), sequence);
await sendHeartbeat(1);
for (let sequence = 2; sequence <= 6; sequence++) {
await page.clock.runFor(20000);
await sendHeartbeat(sequence);
}
expect(cancellationRequests).toBe(0);
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeDisabled();
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(panel).toContainText("内部计算仍活跃");
for (let i = 0; i < 3; i++) {
await page.clock.runFor(20000);
await sendHeartbeat(6);
}
await expect.poll(() => cancellationRequests).toBe(1);
await expect(panel).toContainText("异常而终止");
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
await page.clock.runFor(1000);
await expect(panel).toContainText("异常而终止");
});