现有组件功能与Amesim组件比较对齐,补全功能;仿真服务启动环境检测,旧版json工程文件适配读取

This commit is contained in:
ljz committed 2026-09-13 20:01:56 +08:00
1 parent b6c22a54f8
commit 6fc9afe41d
119 files changed
+260114 -351

No files matched your search

+107 -60
View File
@@ -1,3 +1,4 @@
import { RecoveryNotice } from "./RecoveryNotice";
import { componentIndex, componentVersionWarning, decodeProjectParameters, encodeProjectParameters,
DECIMAL_LITERAL, parameterUnitConversion, sharedUnitTable } from "./projectCompatibility";
import {
@@ -97,6 +98,7 @@ import {
} from "./portComputation";
import { useScrollbarViewport } from "./useScrollbarViewport";
import { DockedSimulationConsole } from "./DockedSimulationConsole";
import { useSimulationRuntimeCheck } from "./useSimulationRuntimeCheck";
import { createNdjsonLineDecoder } from "./ndjsonStream";
import {
loadStoredResultSnapshot,
@@ -594,6 +596,7 @@ type PaletteIconPreview = {
};
const AUTOSAVE_KEY = "system-simulation-flow:autosave";
const RECOVERY_KEY = "system-simulation-flow:pending-recovery";
const PROJECT_KEY_PREFIX = "system-simulation-flow:project:";
const CONSOLE_PLACEMENT_KEY = "system-simulation-flow:console-positions";
const MODELING_PANE_LAYOUT_KEY = "system-simulation-flow:modeling-pane-layout";
@@ -4222,6 +4225,11 @@ function FlowWorkbench() {
workspaceViewFromLocation,
);
const [modelingFitRequest, setModelingFitRequest] = useState(1);
const [completedModelingFitRequest, setCompletedModelingFitRequest] = useState(1);
const modelingFitPending = completedModelingFitRequest !== modelingFitRequest;
const completeModelingFit = useCallback((request: number | string) => {
setCompletedModelingFitRequest(Number(request));
}, []);
const [busy, setBusy] = useState(false);
const [consoleMode, setConsoleMode] = useState<SimulationConsoleMode>("minimized");
const [pendingPaste, setPendingPaste] = useState<PendingPaste | null>(null);
@@ -4261,7 +4269,8 @@ function FlowWorkbench() {
const [validatedSignature, setValidatedSignature] = useState("");
const projectChangeSignatureRef = useRef("");
const validationSignatureRef = useRef("");
const [saveState, setSaveState] = useState("正在检查本地草稿...");
const pendingAutosaveNoticeRef = useRef<{ name: string; savedAt: Date } | null>(null);
const lastAutosaveErrorRef = useRef("");
const [autosaveReady, setAutosaveReady] = useState(false);
const [recoveryProject, setRecoveryProject] = useState<ReactFlowProjectPayload | null>(null);
const [historyAvailability, setHistoryAvailability] = useState({
@@ -5010,6 +5019,8 @@ function FlowWorkbench() {
setModelingFitRequest((request) => request + 1);
}, []);
useSimulationRuntimeCheck(appendConsoleEntry);
useEffect(() => {
let disposed = false;
const revision = resultRevisionRef.current;
@@ -7467,12 +7478,29 @@ function FlowWorkbench() {
],
);
// All durable project saves share the same selected-unit v2 contract.
// Editor state and simulation snapshots remain SI and are never relabelled v2.
const prepareProjectForStorage = useCallback((project: ReactFlowProjectPayload) => {
const index = componentIndex(componentDefinitions);
const sourceById = new Map(nodesRef.current.map(node => [node.id, node]));
const upgraded = { ...project, nodes: project.nodes.map((node) => {
const definition = index.get(node.data.componentType);
const source = sourceById.get(node.id);
const compatible = definition && !(source?.data.modelContractIssues?.length) &&
loadedNodeModelContractIssues(node, definition).length === 0 &&
Object.entries(definition.parameters).every(([name, parameter]) =>
!componentParameterValidationMessage(parameter, node.data.parameters[name] ?? parameter.default,
node.data.parameterUnits?.[name] ?? parameter.unit ?? "", undefined, definition.type, name));
return compatible ? { ...node, data: { ...node.data, modelVersion: definition.modelVersion } } : node;
}) };
return encodeProjectParameters(upgraded, componentDefinitions);
}, [componentDefinitions]);
const saveProject = () => {
try {
const project = buildCurrentProject();
const project = prepareProjectForStorage(buildCurrentProject());
localStorage.setItem(localProjectKey(project.name), JSON.stringify(project));
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(project));
setSaveState(`已保存 ${formatTime(new Date())}`);
appendConsoleEntry(
"success",
`工程已保存到浏览器:${project.nodes.length} 个组件,${project.edges.length} 条连接`,
@@ -7505,23 +7533,12 @@ function FlowWorkbench() {
const exportProject = () => {
const project = buildCurrentProject();
const index = componentIndex(componentDefinitions);
const sourceById = new Map(nodesRef.current.map(node => [node.id, node]));
const upgraded = { ...project, nodes: project.nodes.map((node) => {
const definition = index.get(node.data.componentType);
const source = sourceById.get(node.id);
const compatible = definition && !(source?.data.modelContractIssues?.length) &&
loadedNodeModelContractIssues(node, definition).length === 0 &&
Object.entries(definition.parameters).every(([name, parameter]) =>
!componentParameterValidationMessage(parameter, node.data.parameters[name] ?? parameter.default,
node.data.parameterUnits?.[name] ?? parameter.unit ?? "", undefined, definition.type, name));
return compatible ? { ...node, data: { ...node.data, modelVersion: definition.modelVersion } } : node;
}) };
let exported = project;
let exported: ReactFlowProjectPayload;
try {
exported = encodeProjectParameters(upgraded, componentDefinitions);
exported = prepareProjectForStorage(project);
} catch (error) {
appendConsoleEntry("warning", `工程含未能转换的数据,已保留旧格式和原版本供修复:${formatError(error)}`);
appendConsoleEntry("error", `工程 JSON 导出失败,未生成旧格式文件;请修复后重试:${formatError(error)}`);
return;
}
downloadFile(`${safeFilename(project.name)}.json`, JSON.stringify(exported, null, 2),
"application/json;charset=utf-8");
@@ -7904,16 +7921,20 @@ function FlowWorkbench() {
if (!recoveryProject || pendingPasteRef.current) {
return;
}
applyProject(recoveryProject, "已恢复上次自动保存的工程", false);
setRecoveryProject(null);
setAutosaveReady(true);
try {
applyProject(recoveryProject, "已恢复上次自动保存的工程", false);
localStorage.removeItem(RECOVERY_KEY);
setRecoveryProject(null);
setAutosaveReady(true);
} catch (error) {
appendConsoleEntry("error", `草稿恢复失败,原存档已保留:${formatError(error)}`);
}
};
const discardRecoveryProject = () => {
localStorage.removeItem(AUTOSAVE_KEY);
localStorage.removeItem(RECOVERY_KEY);
setRecoveryProject(null);
setAutosaveReady(true);
setSaveState("尚未保存");
appendConsoleEntry("info", "已忽略上次的自动保存草稿");
};
@@ -8008,26 +8029,42 @@ function FlowWorkbench() {
useEffect(() => {
try {
const project = parseProjectPayload(localStorage.getItem(AUTOSAVE_KEY));
const rawRecovery = localStorage.getItem(RECOVERY_KEY);
const currentDraft = parseProjectPayload(localStorage.getItem(AUTOSAVE_KEY));
// On a later visit, prefer work autosaved during the previous session,
// not an older unhandled recovery snapshot. An empty startup canvas must
// not displace the preserved non-empty draft.
const project = currentDraft && (currentDraft.nodes.length || currentDraft.edges.length)
? currentDraft : parseProjectPayload(rawRecovery);
if (project && (project.nodes.length > 0 || project.edges.length > 0)) {
// Preserve the unhandled previous draft before allowing the current
// editor to autosave. Hiding the banner is not permission to overwrite it.
const preserved = JSON.stringify(project);
if (rawRecovery !== preserved) localStorage.setItem(RECOVERY_KEY, preserved);
setRecoveryProject(project);
setSaveState("发现可恢复的本地草稿");
} else {
setAutosaveReady(true);
setSaveState("尚未保存");
}
} catch {
localStorage.removeItem(AUTOSAVE_KEY);
setAutosaveReady(true);
setSaveState("尚未保存");
} catch (error) {
// In particular, a quota failure while preserving the draft must not
// enable autosave and overwrite the only remaining copy.
appendConsoleEntry("error", `本地草稿检查失败,已暂停自动保存并保留原存档:${formatError(error)}`);
}
}, []);
}, [appendConsoleEntry]);
useEffect(() => {
if (!autosaveReady || nodeDragActive || recoveryProject) {
const timer = window.setInterval(() => {
const saved = pendingAutosaveNoticeRef.current;
if (!saved) return;
pendingAutosaveNoticeRef.current = null;
appendConsoleEntry("info", `工程已自动保存:${saved.name}(${formatTime(saved.savedAt)})`);
}, 30_000);
return () => window.clearInterval(timer);
}, [appendConsoleEntry]);
useEffect(() => {
if (!autosaveReady || nodeDragActive) {
return;
}
setSaveState("有未保存更改");
const timer = window.setTimeout(() => {
try {
const project = buildProjectPayload(
@@ -8036,14 +8073,21 @@ function FlowWorkbench() {
edgesRef.current,
simulationConfigRef.current,
);
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(project));
setSaveState(`已自动保存 ${formatTime(new Date())}`);
const storedProject = prepareProjectForStorage(project);
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(storedProject));
lastAutosaveErrorRef.current = "";
if (project.nodes.length || project.edges.length) {
pendingAutosaveNoticeRef.current = { name: project.name, savedAt: new Date() };
}
} catch (error) {
setSaveState(`自动保存失败:${formatError(error)}`);
pendingAutosaveNoticeRef.current = null;
const message = `自动保存失败,原存档已保留:${formatError(error)}`;
if (lastAutosaveErrorRef.current !== message) appendConsoleEntry("error", message);
lastAutosaveErrorRef.current = message;
}
}, 600);
return () => window.clearTimeout(timer);
}, [autosaveReady, nodeDragActive, projectChangeSignature, recoveryProject]);
}, [autosaveReady, nodeDragActive, projectChangeSignature, prepareProjectForStorage, appendConsoleEntry]);
useEffect(() => {
const syncViewFromLocation = () => {
@@ -8320,8 +8364,8 @@ function FlowWorkbench() {
description: "系统为画布节点分配的稳定标识,用于工程数据和连接关系。",
},
...orderedParameterEntries(selectedDefinition)
.filter(([, definition]) =>
parameterIsVisible(definition, selectedNode.data.parameters),
.filter(([name, definition]) =>
parameterIsVisible(definition, selectedNode.data.parameters, selectedDefinition.modelType, name),
)
.map(([key, definition]): ParameterTableRow => {
const selectedUnit =
@@ -8505,7 +8549,7 @@ function FlowWorkbench() {
const rowIds = group.parameters.flatMap((name) => {
const definition = selectedDefinition.parameters[name];
return definition &&
parameterIsVisible(definition, selectedNode.data.parameters)
parameterIsVisible(definition, selectedNode.data.parameters, selectedDefinition.modelType, name)
? [`component-parameter-${name}`]
: [];
});
@@ -8536,7 +8580,7 @@ function FlowWorkbench() {
const definition = selectedDefinition.parameters[name];
return (
definition &&
!parameterIsVisible(definition, selectedNode.data.parameters)
!parameterIsVisible(definition, selectedNode.data.parameters, selectedDefinition.modelType, name)
);
}).length;
return rowIds.length > 0 || invalidParameters.length > 0
@@ -8709,7 +8753,12 @@ function FlowWorkbench() {
onFocus={recordHistory}
/>
</label>
<span className="save-indicator">{saveState}</span>
<button
className="recovery-reminder"
disabled={!recoveryProject || Boolean(pendingPaste) || Boolean(activePortConnection)}
onClick={restoreRecoveryProject}
type="button"
>恢复草稿</button>
<input
accept="application/json,.json"
className="visually-hidden"
@@ -9084,6 +9133,7 @@ function FlowWorkbench() {
<section
className={[
"flow-canvas",
modelingFitPending ? "is-fitting-project" : "",
activePortConnection ? "connection-active" : "",
activePortConnection?.mode === "click" ? "connection-planning" : "",
].filter(Boolean).join(" ")}
@@ -9187,6 +9237,8 @@ function FlowWorkbench() {
<AutoFitView
enabled={renderedNodes.length > 0}
expectedNodeCount={renderedNodes.length}
fitOnMount={modelingFitPending}
onFitComplete={completeModelingFit}
requestKey={modelingFitRequest}
/>
<PortGeometryRefresh
@@ -9210,23 +9262,11 @@ function FlowWorkbench() {
</Panel>
) : null}
{recoveryProject ? (
<Panel className="recovery-banner" position="top-center">
<span>检测到上次自动保存的工程</span>
<button
disabled={Boolean(pendingPaste)}
onClick={restoreRecoveryProject}
type="button"
>
恢复
</button>
<button
disabled={Boolean(pendingPaste)}
onClick={discardRecoveryProject}
type="button"
>
忽略
</button>
</Panel>
<RecoveryNotice
suppressed={Boolean(pendingPaste) || Boolean(activePortConnection)}
onRestore={restoreRecoveryProject}
onDiscard={discardRecoveryProject}
/>
) : null}
<FlowViewportScrollbars
deferBounds={nodeDragActive}
@@ -9453,7 +9493,14 @@ function numericChoiceValue(value: ParameterValue | undefined) {
function parameterIsVisible(
definition: ParameterDefinition,
parameters: Record<string, ParameterValue>,
modelType: string,
name: string,
) {
// Amesim exposes these as port inputs, not editable chamber settings.
// Hide only the UI rows; preserve legacy values, serialization and equations.
if (modelType === "amesim_pnch012" && /^(?:vol|dvol)[1-4]$/.test(name)) {
return false;
}
// 只有一个固定选项的参数不是用户可配置项,不在右侧参数表制造无效行。
if (definition.editor === "choice" && definition.options?.length === 1) {
return false;
+14 -3
View File
@@ -8,6 +8,7 @@ type AutoFitViewProps = {
minZoom?: number;
padding?: number;
requestKey: number | string;
onFitComplete?: (requestKey: number | string) => void;
};
export function AutoFitView({
@@ -17,6 +18,7 @@ export function AutoFitView({
minZoom = 0.1,
padding = 0.18,
requestKey,
onFitComplete,
}: AutoFitViewProps) {
const lastHandledRequestKey = useRef<number | string | null>(
fitOnMount ? null : requestKey,
@@ -26,7 +28,8 @@ export function AutoFitView({
if (Object.is(lastHandledRequestKey.current, requestKey) || !enabled) {
return false;
}
if (expectedNodeCount <= 0 || state.nodeLookup.size !== expectedNodeCount) {
if (expectedNodeCount <= 0 || state.nodeLookup.size !== expectedNodeCount ||
state.width <= 0 || state.height <= 0 || !state.panZoom) {
return false;
}
for (const node of state.nodeLookup.values()) {
@@ -50,6 +53,7 @@ export function AutoFitView({
if (!enabled || expectedNodeCount <= 0) {
lastHandledRequestKey.current = requestKey;
onFitComplete?.(requestKey);
return;
}
@@ -57,15 +61,21 @@ export function AutoFitView({
return;
}
let cancelled = false;
let secondFrame = 0;
const firstFrame = window.requestAnimationFrame(() => {
secondFrame = window.requestAnimationFrame(() => {
lastHandledRequestKey.current = requestKey;
void fitView({ duration: 0, minZoom, padding });
void fitView({ duration: 0, minZoom, padding }).then(() => {
if (!cancelled) {
lastHandledRequestKey.current = requestKey;
onFitComplete?.(requestKey);
}
});
});
});
return () => {
cancelled = true;
window.cancelAnimationFrame(firstFrame);
if (secondFrame !== 0) {
window.cancelAnimationFrame(secondFrame);
@@ -79,6 +89,7 @@ export function AutoFitView({
minZoom,
padding,
requestKey,
onFitComplete,
]);
return null;
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { Panel } from "@xyflow/react";
/** Dismissing the notice never discards the saved project. */
export function RecoveryNotice({ suppressed, onRestore, onDiscard }: {
suppressed: boolean;
onRestore: () => void;
onDiscard: () => void;
}) {
const [phase, setPhase] = useState<"visible" | "fading" | "hidden">("visible");
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
useEffect(() => {
if (suppressed) setPhase("hidden");
}, [suppressed]);
useEffect(() => {
if (phase === "hidden" || hovered || focused) return;
const timer = window.setTimeout(() => {
setPhase(phase === "visible" ? "fading" : "hidden");
}, phase === "visible" ? 5000 : 200);
return () => window.clearTimeout(timer);
}, [phase, hovered, focused]);
if (phase === "hidden" || suppressed) return null;
return <Panel
className={`recovery-banner${phase === "fading" ? " is-fading" : ""}`}
position="top-center"
onPointerEnter={() => { setHovered(true); setPhase("visible"); }}
onPointerLeave={() => setHovered(false)}
onFocusCapture={() => { setFocused(true); setPhase("visible"); }}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
}}
>
<span>检测到上次自动保存的工程</span>
<button onClick={onRestore} type="button">恢复</button>
<button onClick={onDiscard} type="button">忽略</button>
</Panel>;
}
+33 -8
View File
@@ -111,13 +111,6 @@ textarea {
gap: 8px;
}
.save-indicator {
min-width: 112px;
color: #64748b;
font-size: 11px;
text-align: center;
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -165,7 +158,7 @@ textarea {
}
.project-name-field input {
width: 150px;
width: 240px;
height: 32px;
padding: 0 9px;
border: 1px solid #c2ccda;
@@ -1443,6 +1436,8 @@ textarea {
}
.recovery-banner {
opacity: 1;
transition: opacity 180ms ease-out;
display: flex;
align-items: center;
gap: 8px;
@@ -1463,6 +1458,35 @@ textarea {
background: #ffffff;
}
.recovery-banner.is-fading {
opacity: 0;
pointer-events: none;
}
/* Keep nodes measurable, but never paint the previous viewport for a new project.
Reveal only after the zero-duration fit has committed, without a transition. */
.flow-canvas.is-fitting-project .react-flow__viewport {
opacity: 0;
pointer-events: none;
}
.recovery-reminder {
border: 0;
background: transparent;
color: #8a6116;
font-size: 12px;
cursor: pointer;
}
.recovery-reminder:disabled {
color: #9aa3ae;
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.recovery-banner { transition: none; }
}
.flow-canvas .react-flow__pane,
.flow-canvas .react-flow__pane.selection,
.flow-canvas .react-flow__node.selectable,
@@ -3595,6 +3619,7 @@ textarea {
.simulation-console-entry-message {
min-width: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.simulation-console-entry.xml {
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useRef } from "react";
import type { SimulationConsoleLevel } from "./simulationConsoleStore";
type RuntimeCheck = {
checkId: string;
status: "pending" | "running" | "retrying" | "completed" | "failed" | "disabled" | "cancelled";
attempt?: number;
finishedAt?: string | null;
stage: string;
message: string;
durationMs: number;
compiler: string | null;
compilerVersion: string | null;
sundialsRoot: string | null;
error: string | null;
failure: {
command: string[];
cwd: string | null;
exitCode: number | null;
stdout: string;
stderr: string;
errorType: string;
} | null;
};
const stageLabels: Record<string, string> = {
queued: "等待检测", toolchain: "查找工具链", preparing: "准备程序",
preprocessing: "预处理", compiling: "编译", linking: "链接",
running: "运行", schema: "XML 环境检查", complete: "完成",
"retry-wait": "等待重试",
};
/** Write only status transitions to the console; never change simulation progress. */
export function useSimulationRuntimeCheck(
append: (level: SimulationConsoleLevel, message: string) => void,
) {
const lastState = useRef("");
const unavailable = useRef(false);
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let request: AbortController | undefined;
const poll = async () => {
if (disposed || request) return;
request = new AbortController();
const abortTimer = setTimeout(() => request?.abort(), 5000);
let nextPollMs: number | null = null;
try {
const response = await fetch("/api/simulation/runtime-check", {
signal: request.signal, cache: "no-store",
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const report: RuntimeCheck = await response.json();
if (!report.checkId || !report.status || typeof report.message !== "string") {
throw new Error("自检响应格式不正确");
}
if (disposed) return;
const restored = unavailable.current;
unavailable.current = false;
const state = `${report.checkId}:${report.status}:${report.stage}:${report.attempt ?? 0}`;
if (state !== lastState.current || restored) {
lastState.current = state;
if (report.status === "failed") {
const failure = report.failure;
const details = [
`${report.message};失败阶段:${stageLabels[report.stage] ?? report.stage}`,
report.error ? `原因:${report.error}` : "",
report.compiler ? `编译器:${report.compiler}` : "",
failure?.errorType ? `错误类型:${failure.errorType}` : "",
failure?.command.length ? `命令:${failure.command.map(arg => JSON.stringify(arg)).join(" ")}` : "",
failure?.cwd ? `工作目录:${failure.cwd}` : "",
failure ? `退出码:${failure.exitCode ?? "未取得(进程未启动或非命令阶段失败)"}` : "",
failure?.stderr ? `错误输出:\n${failure.stderr}` : "",
failure?.stdout ? `标准输出:\n${failure.stdout}` : "",
].filter(Boolean).join("\n");
append("error", details);
} else if (report.status === "completed") {
append("success", `${report.message}(${(report.durationMs / 1000).toFixed(2)} 秒)`);
} else if (report.status === "disabled" || report.status === "cancelled") {
append("warning", report.message);
} else if (report.status === "retrying") {
append("warning", `启动自检:${report.message}`);
} else {
append("info", `正在检测仿真环境(启动自检):${report.message};编辑器可正常使用`);
}
}
if (["running", "pending", "retrying"].includes(report.status)) nextPollMs = 500;
} catch (error) {
if (!disposed && !unavailable.current) {
unavailable.current = true;
append("warning", `暂时无法获取仿真环境自检状态,稍后自动重试;编辑器仍可使用(${error instanceof Error ? error.message : String(error)})`);
}
nextPollMs = 3000;
} finally {
clearTimeout(abortTimer);
request = undefined;
// A final result is a startup record, not a continuously running check.
if (!disposed && nextPollMs !== null) timer = setTimeout(() => void poll(), nextPollMs);
}
};
const onFocus = () => {
if (request) return;
clearTimeout(timer);
void poll();
};
void poll();
window.addEventListener("focus", onFocus);
return () => {
disposed = true;
clearTimeout(timer);
request?.abort();
window.removeEventListener("focus", onFocus);
};
}, [append]);
}
+1 -1
View File
@@ -4119,7 +4119,7 @@ test("当前工程按组件目录刷新图标且保持保存位置", async ({ pa
)
.toEqual({
position: { x: 200, y: 160 },
version: 1,
version: 2,
hasNodeSize: false,
});
+1 -1
View File
@@ -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", {
+5 -4
View File
@@ -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 }) => {
+5 -5
View File
@@ -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);
});
}
+152 -2
View File
@@ -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,
}) => {
+127
View File
@@ -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);
});