现有组件功能与Amesim组件比较对齐,补全功能;仿真服务启动环境检测,旧版json工程文件适配读取
This commit is contained in:
1 parent
b6c22a54f8
commit
6fc9afe41d
119 files changed
+260114
-351
No files matched your search
+107
-60
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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