现有组件功能与Amesim组件比较对齐,补全功能;仿真服务启动环境检测,旧版json工程文件适配读取
This commit is contained in:
1 parent
b6c22a54f8
commit
6fc9afe41d
119 files changed
+260114
-351
No files matched your search
@@ -4119,7 +4119,7 @@ test("当前工程按组件目录刷新图标且保持保存位置", async ({ pa
|
||||
)
|
||||
.toEqual({
|
||||
position: { x: 200, y: 160 },
|
||||
version: 1,
|
||||
version: 2,
|
||||
hasNodeSize: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -472,7 +472,7 @@ test("恢复自动保存工程后自动适配建模画布", async ({ page }) =>
|
||||
}, wideProject);
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "恢复" }).click();
|
||||
await page.getByRole("button", { name: "恢复", exact: true }).click();
|
||||
|
||||
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
||||
const consolePanel = page.getByRole("complementary", {
|
||||
|
||||
@@ -78,7 +78,7 @@ test("未知图形元件使用带端口和名称的默认节点且拖入动作
|
||||
await expect(page.locator(".properties")).not.toContainText("仿真结果");
|
||||
});
|
||||
|
||||
test("XML 导出拒绝目录未定义的组件参数", async ({ page }) => {
|
||||
test("新版存档拒绝目录未定义的参数,保留当前有效工程及 XML", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page
|
||||
.getByRole("button", { name: /通用测试元件/ })
|
||||
@@ -105,9 +105,10 @@ test("XML 导出拒绝目录未定义的组件参数", async ({ page }) => {
|
||||
name: "仿真控制台",
|
||||
exact: true,
|
||||
});
|
||||
await expect(consolePanel).toContainText("XML 生成失败");
|
||||
await expect(consolePanel).toContainText("目录未定义的参数:typoGain");
|
||||
await expect(consolePanel.getByLabel("生成的系统 XML")).toHaveCount(0);
|
||||
await expect(consolePanel).toContainText("加载失败");
|
||||
await expect(consolePanel).toContainText("未知参数 typoGain");
|
||||
await expect(consolePanel.getByLabel("生成的系统 XML")).toContainText('<Parameter name="gain" value="1"');
|
||||
await expect(consolePanel.getByLabel("生成的系统 XML")).not.toContainText("typoGain");
|
||||
});
|
||||
|
||||
test("XML 参数使用可往返的双精度文本", async ({ page }) => {
|
||||
|
||||
@@ -733,7 +733,7 @@ test("MECMAS21 当前参数编码由工程版本统一并写入 XML", async ({
|
||||
}),
|
||||
);
|
||||
expect(savedContracts).toContainEqual({
|
||||
projectSchemaVersion: 1,
|
||||
projectSchemaVersion: 2,
|
||||
modelVersion: "0.2.0",
|
||||
});
|
||||
|
||||
@@ -883,7 +883,7 @@ test("MECMAS21 旧的 0/1 编码不转换并阻止执行", async ({
|
||||
await expect(consolePanel).toContainText("模型检查发现");
|
||||
});
|
||||
|
||||
test("缺失模型版本保留来源并补当前默认值,生成 XML 只警告", async ({
|
||||
test("缺失模型版本加载时警告并补默认值,保存时升级当前版本", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
@@ -926,7 +926,7 @@ test("缺失模型版本保留来源并补当前默认值,生成 XML 只警告
|
||||
await expect(node.locator('[data-model-contract-compatible="true"]')).toBeVisible();
|
||||
await expect(node.getByLabel(/模型合同不兼容/)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||
const persistedWithoutDefaults = await page.evaluate(() => {
|
||||
const persistedWithCurrentVersion = await page.evaluate(() => {
|
||||
const key = Object.keys(window.localStorage).find((candidate) =>
|
||||
candidate.startsWith("system-simulation-flow:project:"),
|
||||
);
|
||||
@@ -948,12 +948,12 @@ test("缺失模型版本保留来源并补当前默认值,生成 XML 只警告
|
||||
)?.data;
|
||||
return Boolean(
|
||||
data &&
|
||||
data.modelVersion === undefined &&
|
||||
data.modelVersion === "0.2.0" &&
|
||||
data.parameters &&
|
||||
("strib" in data.parameters),
|
||||
);
|
||||
});
|
||||
expect(persistedWithoutDefaults).toBe(true);
|
||||
expect(persistedWithCurrentVersion).toBe(true);
|
||||
|
||||
const consolePanel = page.getByRole("complementary", {
|
||||
name: "仿真控制台",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { componentCatalog, expandSimulationConsole, prepareApp, wideProject } from "./fixtures";
|
||||
|
||||
const extraInputs = ["vol1", "vol2", "vol3", "vol4", "dvol1", "dvol2", "dvol3", "dvol4"];
|
||||
const chamber = {
|
||||
type: "amesim_pnch012", modelType: "amesim_pnch012", modelVersion: "0.1.0",
|
||||
label: "PNCH012 变容气室", symbol: "amesim_pnch012", order: 99,
|
||||
category: { id: "storage", label: "储能元件", order: 20 },
|
||||
ports: Array.from({ length: 4 }, (_, index) => ({ ...wideProject.nodes[0].data.ports[index % 2],
|
||||
name: `port_${index + 1}`, order: index })),
|
||||
parameters: [
|
||||
{ name: "gi", label: "气体类型索引", default: 1, quantity: "dimensionless", unit: "" },
|
||||
{ name: "cvol0", label: "死容积", default: 0.015, quantity: "volume", unit: "m3" },
|
||||
{ name: "kth", label: "换热系数", default: 0, quantity: "heat_transfer_coefficient", unit: "W/(m2*K)" },
|
||||
{ name: "sth", label: "换热面积", default: 0.1, quantity: "area", unit: "m2" },
|
||||
{ name: "extemp", label: "外部温度", default: 293.15, quantity: "temperature", unit: "K" },
|
||||
{ name: "p0", label: "初始压力", default: 100000, quantity: "pressure", unit: "Pa" },
|
||||
{ name: "T0", label: "初始温度", default: 293.15, quantity: "temperature", unit: "K" },
|
||||
...extraInputs.map(name => ({ name, label: `端口 ${name.at(-1)} ${name.startsWith("d") ? "容积变化率" : "外部容积"}`,
|
||||
default: 0, quantity: name.startsWith("d") ? "volume_flow" : "volume", unit: name.startsWith("d") ? "m3/s" : "m3" })),
|
||||
],
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
await page.route("**/api/components/catalog", route => route.fulfill({ json: {
|
||||
...componentCatalog, libraries: [{ ...componentCatalog.libraries[1], components: [chamber] }],
|
||||
} }));
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: chamber.label, exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("PNCH012 新元件仅展示 Amesim 设置,端口容积输入默认仍为零", async ({ page }) => {
|
||||
await page.getByRole("button", { name: chamber.label, exact: true }).dragTo(page.locator(".flow-canvas .react-flow__pane"));
|
||||
const table = page.getByRole("table", { name: "组件参数", exact: true });
|
||||
for (const parameter of chamber.parameters.slice(0, 7)) {
|
||||
await expect(table.getByRole("rowheader", { name: parameter.label, exact: true })).toBeVisible();
|
||||
}
|
||||
for (const parameter of chamber.parameters.slice(7)) {
|
||||
await expect(table.getByRole("rowheader", { name: parameter.label, exact: true })).toHaveCount(0);
|
||||
}
|
||||
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||
const parameters = await page.evaluate(() => JSON.parse(localStorage.getItem("system-simulation-flow:autosave")!).nodes[0].data.parameters);
|
||||
for (const name of extraInputs) expect(parameters[name]).toBe(0);
|
||||
});
|
||||
|
||||
test("隐藏端口输入不清除旧工程的非零值或表达式,保存导出及 XML 保持原值", async ({ page }) => {
|
||||
const parameters = { ...Object.fromEntries(chamber.parameters.map(parameter => [parameter.name, parameter.default])),
|
||||
vol1: 0.00123, vol2: "2*0.0001", dvol1: 0.0000042 };
|
||||
const node = { ...wideProject.nodes[0], id: "chamber_1", data: { ...wideProject.nodes[0].data,
|
||||
componentType: chamber.type, modelType: chamber.modelType, modelVersion: chamber.modelVersion,
|
||||
ports: chamber.ports, parameters,
|
||||
parameterUnits: Object.fromEntries(chamber.parameters.map(parameter => [parameter.name, parameter.unit])),
|
||||
} };
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "old-chamber.json", mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify({ ...wideProject, nodes: [node], edges: [] })) });
|
||||
await page.locator('.flow-canvas .react-flow__node[data-id="chamber_1"]').click();
|
||||
for (const parameter of chamber.parameters.slice(7)) {
|
||||
await expect(page.getByRole("table", { name: "组件参数", exact: true }).getByRole("rowheader", { name: parameter.label, exact: true })).toHaveCount(0);
|
||||
}
|
||||
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "导出工程 JSON", exact: true }).click();
|
||||
const stream = await (await downloadPromise).createReadStream();
|
||||
if (!stream) throw new Error("没有导出的工程");
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
||||
expect(JSON.parse(Buffer.concat(chunks).toString()).nodes[0].data.parameters).toEqual(parameters);
|
||||
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
|
||||
await expandSimulationConsole(page);
|
||||
const xml = page.getByLabel("生成的系统 XML").last();
|
||||
await expect(xml).toContainText('name="vol1" value="0.00123"');
|
||||
await expect(xml).toContainText('name="vol2" value="0.0002"');
|
||||
await expect(xml).toContainText('name="dvol1" value="0.0000042"');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { prepareApp, wideProject } from "./fixtures";
|
||||
|
||||
for (const action of ["import", "load", "recover"] as const) {
|
||||
test(`${action}: first painted project is already fitted, including replacement with identical node IDs`, async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible();
|
||||
const old = { ...wideProject, nodes: wideProject.nodes.map((node, index) => ({
|
||||
...node, position: { x: index * 70, y: 0 },
|
||||
})) };
|
||||
if (action !== "recover") {
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "old.json", mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(old)) });
|
||||
await expect(page.locator(".flow-canvas")).not.toHaveClass(/is-fitting-project/);
|
||||
}
|
||||
await page.evaluate(({ project, action }) => {
|
||||
const key = action === "recover" ? "system-simulation-flow:autosave" : "system-simulation-flow:project:target";
|
||||
localStorage.setItem(key, JSON.stringify(project));
|
||||
}, { project: wideProject, action });
|
||||
if (action === "recover") {
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "恢复草稿", exact: true })).toBeEnabled();
|
||||
}
|
||||
if (action === "load") await page.getByRole("textbox", { name: "工程", exact: true }).fill("target");
|
||||
|
||||
// Frame sampling, not just final bounds: detect even a single visible frame
|
||||
// with the incoming wide graph rendered at the previous/default scale.
|
||||
await page.evaluate(() => {
|
||||
const samples: Array<{ opacity: number; fits: boolean; transform: string }> = [];
|
||||
Object.assign(window, { projectPaintSamples: samples, projectPaintDone: false });
|
||||
const sample = () => {
|
||||
const canvas = document.querySelector<HTMLElement>(".flow-canvas")!;
|
||||
const viewport = canvas.querySelector<HTMLElement>(".react-flow__viewport")!;
|
||||
const nodes = [...canvas.querySelectorAll<HTMLElement>(".react-flow__node")];
|
||||
// The last incoming node is positioned far away, unlike the old compact graph.
|
||||
const incoming = nodes.find(node => node.dataset.id === "generic_sensor_4");
|
||||
if (incoming && incoming.style.transform.includes("1860")) {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
samples.push({ opacity: Number(getComputedStyle(viewport).opacity),
|
||||
transform: getComputedStyle(viewport).transform,
|
||||
fits: nodes.every(node => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.left >= bounds.left - 1 && rect.right <= bounds.right + 1 &&
|
||||
rect.top >= bounds.top - 1 && rect.bottom <= bounds.bottom + 1;
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (!(window as unknown as { projectPaintDone: boolean }).projectPaintDone) requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
if (action === "import") {
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "target.json", mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(wideProject)) });
|
||||
} else {
|
||||
await page.getByRole("button", { name: action === "load" ? "加载工程" : "恢复草稿", exact: true }).click();
|
||||
}
|
||||
await expect(page.locator(".flow-canvas")).not.toHaveClass(/is-fitting-project/);
|
||||
const samples = await page.evaluate(async () => {
|
||||
for (let frame = 0; frame < 12; frame++) await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
|
||||
const state = window as unknown as { projectPaintDone: boolean;
|
||||
projectPaintSamples: Array<{ opacity: number; fits: boolean; transform: string }> };
|
||||
state.projectPaintDone = true;
|
||||
return state.projectPaintSamples;
|
||||
});
|
||||
const visible = samples.filter(sample => sample.opacity > 0);
|
||||
expect(visible.length).toBeGreaterThan(0);
|
||||
expect(visible.every(sample => sample.fits), JSON.stringify(samples)).toBe(true);
|
||||
expect(new Set(visible.map(sample => sample.transform)).size).toBe(1);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { prepareApp, wideProject } from "./fixtures";
|
||||
import { expandSimulationConsole, prepareApp, wideProject } from "./fixtures";
|
||||
|
||||
const projectNameField = (page: Page) =>
|
||||
page.getByRole("textbox", { name: "工程", exact: true });
|
||||
@@ -72,7 +72,7 @@ test("外部重命名的工程 JSON 导入后名称一致,保存和刷新后
|
||||
);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
}, renamedProject);
|
||||
expect(saved).toMatchObject({ name: renamedProject });
|
||||
expect(saved).toMatchObject({ name: renamedProject, projectSchemaVersion: 2 });
|
||||
expect(saved.nodes).toHaveLength(wideProject.nodes.length);
|
||||
|
||||
await deleteFirstNode(page);
|
||||
@@ -93,6 +93,156 @@ test("外部重命名的工程 JSON 导入后名称一致,保存和刷新后
|
||||
await expect(projectNameField(page)).toHaveValue(renamedProject);
|
||||
});
|
||||
|
||||
for (const pressure of [2.5, "1+1.5"]) {
|
||||
test(`v2 浏览器保存、加载及草稿恢复不重复提示或换算:${pressure}`, async ({ page }) => {
|
||||
const project = { ...wideProject, projectSchemaVersion: 2, name: "unit-roundtrip", edges: [],
|
||||
nodes: [{ ...wideProject.nodes[0], id: "pressure_1", data: {
|
||||
...wideProject.nodes[0].data, label: "pressure_1", componentType: "amesim_pneumatic_test",
|
||||
modelType: "amesim_pneumatic_test", parameters: {
|
||||
gi: 0, reference_pressure: pressure, reference_temperature: 20,
|
||||
}, parameterUnits: { reference_pressure: "bar", reference_temperature: "degC" },
|
||||
} }],
|
||||
};
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "unit-roundtrip.json",
|
||||
mimeType: "application/json", buffer: Buffer.from(JSON.stringify(project)) });
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(1);
|
||||
await expandSimulationConsole(page);
|
||||
const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
|
||||
const readSaved = () => page.evaluate(() => JSON.parse(localStorage.getItem("system-simulation-flow:autosave") || "null"));
|
||||
await expect.poll(async () => (await readSaved())?.projectSchemaVersion).toBe(2);
|
||||
for (let iteration = 0; iteration < 3; iteration++) {
|
||||
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||
const saved = await readSaved();
|
||||
expect(saved.nodes[0].data.parameters.reference_pressure).toBe(pressure);
|
||||
expect(saved.nodes[0].data.parameters.reference_temperature).toBe(20);
|
||||
const exported = await exportProject(page);
|
||||
expect(exported.project).toEqual(saved);
|
||||
await page.getByRole("button", { name: "加载工程", exact: true }).click();
|
||||
await expect(consolePanel).not.toContainText("已按旧工程规则读取参数");
|
||||
}
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: "恢复", exact: true }).click();
|
||||
await expandSimulationConsole(page);
|
||||
await expect(consolePanel).not.toContainText("已按旧工程规则读取参数");
|
||||
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
|
||||
await expect(page.getByLabel("生成的系统 XML").last()).toContainText('name="reference_pressure" value="250000"');
|
||||
await expect(page.getByLabel("生成的系统 XML").last()).toContainText('name="reference_temperature" value="293.15"');
|
||||
});
|
||||
}
|
||||
|
||||
test("草稿提示空闲淡出、交互暂停,精简按钮直接恢复且不删除存档", async ({ page }) => {
|
||||
await page.evaluate(project => localStorage.setItem("system-simulation-flow:autosave", JSON.stringify(project)), wideProject);
|
||||
await page.clock.install();
|
||||
await page.reload();
|
||||
const banner = page.locator(".recovery-banner");
|
||||
await expect(banner).toBeVisible();
|
||||
await banner.hover();
|
||||
await page.clock.runFor(6000);
|
||||
await expect(banner).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await page.getByRole("button", { name: "恢复", exact: true }).focus();
|
||||
await page.clock.runFor(6000);
|
||||
await expect(banner).toBeVisible();
|
||||
await projectNameField(page).focus();
|
||||
await page.clock.runFor(5100);
|
||||
await expect(banner).toHaveClass(/is-fading/);
|
||||
await page.clock.runFor(200);
|
||||
await expect(banner).toHaveCount(0);
|
||||
expect(await page.evaluate(() => JSON.parse(localStorage.getItem("system-simulation-flow:pending-recovery")!).nodes.length)).toBe(4);
|
||||
await expect(page.locator(".save-indicator")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "恢复草稿", exact: true }).click();
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(4);
|
||||
await expect(banner).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "恢复草稿", exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("草稿提示立即让位于粘贴提示,结束粘贴后不自动弹回", async ({ page }) => {
|
||||
await page.evaluate(project => localStorage.setItem("system-simulation-flow:autosave", JSON.stringify(project)), wideProject);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible();
|
||||
await page.locator(".recovery-banner").hover();
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "other.json",
|
||||
mimeType: "application/json", buffer: Buffer.from(JSON.stringify(wideProject)) });
|
||||
await page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]').click();
|
||||
await page.keyboard.press("Control+c");
|
||||
await page.keyboard.press("Control+v");
|
||||
await expect(page.locator(".pending-paste-hint")).toBeVisible();
|
||||
await expect(page.locator(".recovery-banner")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator(".recovery-banner")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "恢复草稿", exact: true }).click();
|
||||
await expect(page.locator(".recovery-banner")).toHaveCount(0);
|
||||
await expect(projectNameField(page)).toHaveValue(wideProject.name);
|
||||
await expect(page.getByRole("button", { name: "恢复草稿", exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("未恢复旧草稿也正常自动保存,控制台每 30 秒汇总成功消息且空闲不刷屏", async ({ page }) => {
|
||||
await page.evaluate(project => localStorage.setItem("system-simulation-flow:autosave", JSON.stringify(project)), wideProject);
|
||||
await page.clock.install();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible();
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "current-work.json",
|
||||
mimeType: "application/json", buffer: Buffer.from(JSON.stringify(wideProject)) });
|
||||
await expandSimulationConsole(page);
|
||||
await page.clock.runFor(1000);
|
||||
const stored = await page.evaluate(() => ({
|
||||
current: JSON.parse(localStorage.getItem("system-simulation-flow:autosave")!),
|
||||
recovery: JSON.parse(localStorage.getItem("system-simulation-flow:pending-recovery")!),
|
||||
}));
|
||||
expect(stored.current).toMatchObject({ projectSchemaVersion: 2, name: "current-work" });
|
||||
expect(stored.recovery).toEqual(wideProject);
|
||||
await expect(page.getByRole("button", { name: "恢复草稿", exact: true })).toBeEnabled();
|
||||
await expect(page.locator(".save-indicator")).toHaveCount(0);
|
||||
expect(await projectNameField(page).evaluate(input => input.getBoundingClientRect().width)).toBeGreaterThanOrEqual(240);
|
||||
const notices = page.locator(".simulation-console-dock-entry-message").filter({ hasText: "工程已自动保存:" });
|
||||
await expect(notices).toHaveCount(0);
|
||||
await page.clock.runFor(30_000);
|
||||
await expect(notices).toHaveCount(1);
|
||||
await expect(notices.last()).toContainText("current-work");
|
||||
await page.clock.runFor(30_000);
|
||||
await expect(notices).toHaveCount(1);
|
||||
await projectNameField(page).fill("changed-work");
|
||||
await page.clock.runFor(31_000);
|
||||
await expect(notices).toHaveCount(2);
|
||||
await expect(notices.last()).toContainText("changed-work");
|
||||
await page.getByRole("button", { name: "恢复草稿", exact: true }).click();
|
||||
await expect(projectNameField(page)).toHaveValue(wideProject.name);
|
||||
expect(await page.evaluate(() => localStorage.getItem("system-simulation-flow:pending-recovery"))).toBeNull();
|
||||
});
|
||||
|
||||
test("旧草稿未处理时编辑并刷新,下次恢复最近的自动保存而非旧副本", async ({ page }) => {
|
||||
await page.evaluate(project => localStorage.setItem("system-simulation-flow:autosave", JSON.stringify(project)), wideProject);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible();
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "newer-work.json",
|
||||
mimeType: "application/json", buffer: Buffer.from(JSON.stringify(wideProject)) });
|
||||
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("system-simulation-flow:autosave")!).name)).toBe("newer-work");
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: /通用测试元件/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: "恢复草稿", exact: true }).click();
|
||||
await expect(projectNameField(page)).toHaveValue("newer-work");
|
||||
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(4);
|
||||
});
|
||||
|
||||
test("不能编码新版的工程不覆盖已有浏览器存档,也不下载旧格式", async ({ page }) => {
|
||||
const storageKey = `system-simulation-flow:project:${wideProject.name}`;
|
||||
await page.evaluate(({ key, project }) => localStorage.setItem(key, JSON.stringify(project)), { key: storageKey, project: wideProject });
|
||||
const invalid = structuredClone(wideProject);
|
||||
Object.assign(invalid.nodes[0].data.parameters, { obsolete: 1 });
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: `${wideProject.name}.json`,
|
||||
mimeType: "application/json", buffer: Buffer.from(JSON.stringify(invalid)) });
|
||||
await expandSimulationConsole(page);
|
||||
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||
await expect(page.getByRole("complementary", { name: "仿真控制台", exact: true })).toContainText("保存失败");
|
||||
expect(await page.evaluate(key => JSON.parse(localStorage.getItem(key)!), storageKey)).toEqual(wideProject);
|
||||
const downloads: string[] = [];
|
||||
page.on("download", download => downloads.push(download.suggestedFilename()));
|
||||
await page.getByRole("button", { name: "导出工程 JSON", exact: true }).click();
|
||||
await expect(page.getByRole("complementary", { name: "仿真控制台", exact: true })).toContainText("未生成旧格式文件");
|
||||
expect(downloads).toEqual([]);
|
||||
});
|
||||
|
||||
test("浏览器工程内容保留旧名称时,按存储名称连续加载仍能恢复工程", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expandSimulationConsole, prepareApp } from "./fixtures";
|
||||
|
||||
const report = {
|
||||
checkId: "backend-start-1", status: "running", stage: "compiling",
|
||||
message: "正在编译最小自检程序", durationMs: 100,
|
||||
compiler: "gcc", compilerVersion: "gcc test", sundialsRoot: "/native/sundials",
|
||||
error: null, failure: null,
|
||||
};
|
||||
|
||||
test("self-check progress does not block editing and completion is logged once", async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
let completed = false;
|
||||
let requests = 0;
|
||||
await page.route("**/api/simulation/runtime-check", route => {
|
||||
requests++;
|
||||
return route.fulfill({ json: completed ? { ...report, status: "completed", stage: "complete",
|
||||
message: "仿真环境自检通过:最小程序预处理、编译、链接和运行均成功", durationMs: 1250 } : report });
|
||||
});
|
||||
await page.goto("/");
|
||||
await expandSimulationConsole(page);
|
||||
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
|
||||
await expect(panel).toContainText("正在检测仿真环境");
|
||||
const name = page.getByRole("textbox", { name: "工程", exact: true });
|
||||
await name.fill("检测期间仍可编辑");
|
||||
await expect(name).toHaveValue("检测期间仍可编辑");
|
||||
await expect.poll(() => requests).toBeGreaterThanOrEqual(3);
|
||||
await expect(panel.locator(".simulation-console-dock-entry-message").filter({ hasText: "正在检测仿真环境" })).toHaveCount(1);
|
||||
completed = true;
|
||||
await expect(panel).toContainText("仿真环境自检通过");
|
||||
await expect(panel).toContainText("1.25 秒");
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect(panel.locator(".simulation-console-dock-entry-message").filter({ hasText: "仿真环境自检通过" })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("link failure shows diagnostics and a backend restart reports recovery", async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
let recovered = false;
|
||||
await page.route("**/api/simulation/runtime-check", route => route.fulfill({ json: recovered
|
||||
? { ...report, checkId: "backend-start-2", status: "completed", stage: "complete", message: "仿真环境自检通过" }
|
||||
: { ...report, status: "failed", stage: "linking", message: "仿真环境自检失败,编辑器仍可使用",
|
||||
error: "命令执行失败", failure: { command: ["gcc", "check.o", "-o", "model"], cwd: "/native/check",
|
||||
exitCode: 7, stderr: "cannot find library", stdout: "", errorType: "CalledProcessError" } } }));
|
||||
await page.goto("/");
|
||||
await expandSimulationConsole(page);
|
||||
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
|
||||
await expect(panel).toContainText("失败阶段:链接");
|
||||
await expect(panel).toContainText("退出码:7");
|
||||
await expect(panel).toContainText("cannot find library");
|
||||
await expect(panel).toContainText('"gcc" "check.o" "-o" "model"');
|
||||
await page.getByRole("textbox", { name: "工程", exact: true }).fill("失败后仍可编辑");
|
||||
recovered = true;
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect(panel).toContainText("仿真环境自检通过");
|
||||
});
|
||||
|
||||
test("backend unavailable at page load is retried without duplicate warnings", async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
let available = false;
|
||||
await page.route("**/api/simulation/runtime-check", route => available
|
||||
? route.fulfill({ json: { ...report, status: "completed", message: "仿真环境自检通过" } })
|
||||
: route.fulfill({ status: 503 }));
|
||||
await page.goto("/");
|
||||
await expandSimulationConsole(page);
|
||||
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
|
||||
await expect(panel).toContainText("暂时无法获取仿真环境自检状态");
|
||||
available = true;
|
||||
await expect(panel).toContainText("仿真环境自检通过");
|
||||
});
|
||||
|
||||
for (const finalStatus of ["completed", "failed"] as const) {
|
||||
test(`automatic retries suppress diagnostics until ${finalStatus}`, async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
let attempt = 1;
|
||||
let finished = false;
|
||||
const failure = { command: ["gcc", "check.c"], cwd: "/native/check", exitCode: 1,
|
||||
stderr: "CreateProcess: No such file or directory", stdout: "", errorType: "CalledProcessError" };
|
||||
await page.route("**/api/simulation/runtime-check", route => route.fulfill({ json: finished
|
||||
? { ...report, status: finalStatus, attempt: 6, stage: finalStatus === "failed" ? "preprocessing" : "complete",
|
||||
message: finalStatus === "failed" ? "本次启动自检未通过:首次检测及 5 轮重试均失败;编辑器仍可使用"
|
||||
: "本次启动自检通过,仿真环境可用(第 5 轮重试成功)",
|
||||
error: finalStatus === "failed" ? "命令执行失败" : null, failure: finalStatus === "failed" ? failure : null }
|
||||
: { ...report, status: "retrying", attempt, stage: "retry-wait", error: null, failure: null,
|
||||
message: `本轮检测未通过,正在准备第 ${attempt}/5 轮重试(间隔 0.5 秒);编辑器可正常使用` } }));
|
||||
await page.goto("/");
|
||||
await expandSimulationConsole(page);
|
||||
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
|
||||
await expect(panel).toContainText("第 1/5 轮重试");
|
||||
await expect(panel).not.toContainText("退出码");
|
||||
await expect(panel).not.toContainText("CreateProcess");
|
||||
await page.getByRole("textbox", { name: "工程", exact: true }).fill("重试时继续编辑");
|
||||
attempt = 2;
|
||||
await expect(panel).toContainText("第 2/5 轮重试");
|
||||
await expect(panel).not.toContainText("错误类型");
|
||||
finished = true;
|
||||
if (finalStatus === "completed") {
|
||||
await expect(panel).toContainText("仿真环境可用(第 5 轮重试成功)");
|
||||
await expect(panel).not.toContainText("CreateProcess");
|
||||
await expect(panel.locator(".simulation-console-dock-entry.error")).toHaveCount(0);
|
||||
} else {
|
||||
await expect(panel).toContainText("首次检测及 5 轮重试均失败");
|
||||
await expect(panel).toContainText("退出码:1");
|
||||
await expect(panel).toContainText("CreateProcess: No such file or directory");
|
||||
await expect(panel.locator(".simulation-console-dock-entry.error")).toHaveCount(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("finished startup report stops timed polling and is refreshed on focus", async ({ page }) => {
|
||||
await prepareApp(page);
|
||||
await page.clock.install();
|
||||
let requests = 0;
|
||||
await page.route("**/api/simulation/runtime-check", route => {
|
||||
requests++;
|
||||
return route.fulfill({ json: { ...report, status: "completed", stage: "complete",
|
||||
message: "本次启动自检通过,仿真环境可用" } });
|
||||
});
|
||||
await page.goto("/");
|
||||
await expandSimulationConsole(page);
|
||||
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
|
||||
await expect(panel).toContainText("本次启动自检通过");
|
||||
const completedRequests = requests;
|
||||
await page.clock.runFor(31000);
|
||||
expect(requests).toBe(completedRequests);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect.poll(() => requests).toBe(completedRequests + 1);
|
||||
});
|
||||
Reference in new issue
Block a user