旧版前端工程文件导入时版本对比查验、审阅与仿真时部分阻挡功能实现;前端参数输入格式统一规范

This commit is contained in:
lujingze committed 2026-09-12 14:15:40 +00:00
1 parent 22579e51c9
commit 44b6ea74ab
32 files changed
+2087 -322

No files matched your search

+84 -87
View File
@@ -1,3 +1,5 @@
import { componentIndex, componentVersionWarning, decodeProjectParameters, encodeProjectParameters,
DECIMAL_LITERAL, parameterUnitConversion, sharedUnitTable } from "./projectCompatibility";
import {
ChangeEvent,
DragEvent,
@@ -472,14 +474,15 @@ type ProjectEdgePayload = {
};
type ReactFlowProjectPayload = {
projectSchemaVersion: 1;
projectSchemaVersion: 1 | 2;
name: string;
nodes: ProjectNodePayload[];
edges: ProjectEdgePayload[];
simulation: SimulationConfig;
};
type ExecutableProjectPayload = Omit<ReactFlowProjectPayload, "simulation"> & {
type ExecutableProjectPayload = Omit<ReactFlowProjectPayload, "simulation" | "projectSchemaVersion"> & {
projectSchemaVersion: 1;
simulation: ResolvedSimulationConfig;
};
@@ -991,51 +994,14 @@ function paletteCategoryItemsId(libraryId: string, categoryId: string) {
}
const identityUnit = (value: number) => value;
const scaleUnit = (factor: number) => ({
toBase: (value: number) => value * factor,
fromBase: (value: number) => value / factor,
});
const unitOptions: Record<UnitQuantity, UnitOption[]> = {
area: [
{ value: "m2", label: "m²", toBase: identityUnit, fromBase: identityUnit },
{ value: "cm2", label: "cm²", ...scaleUnit(1e-4) },
{ value: "mm2", label: "mm²", ...scaleUnit(1e-6) },
],
heat_transfer_coefficient: [
{
value: "W/(m2*K)",
label: "W/(m²·K)",
toBase: identityUnit,
fromBase: identityUnit,
},
],
pressure: [
{ value: "Pa", label: "Pa", toBase: identityUnit, fromBase: identityUnit },
{ value: "kPa", label: "kPa", ...scaleUnit(1_000) },
{ value: "MPa", label: "MPa", ...scaleUnit(1_000_000) },
{ value: "bar", label: "bar", ...scaleUnit(100_000) },
],
volume: [
{ value: "m3", label: "m³", toBase: identityUnit, fromBase: identityUnit },
{ value: "L", label: "L", ...scaleUnit(0.001) },
{ value: "mL", label: "mL", ...scaleUnit(0.000001) },
],
temperature: [
{ value: "K", label: "K", toBase: identityUnit, fromBase: identityUnit },
{
value: "degC",
label: "°C",
toBase: (value) => value + 273.15,
fromBase: (value) => value - 273.15,
},
],
length: [
{ value: "m", label: "m", toBase: identityUnit, fromBase: identityUnit },
{ value: "cm", label: "cm", ...scaleUnit(0.01) },
{ value: "mm", label: "mm", ...scaleUnit(0.001) },
],
};
const unitOptions: Record<string, UnitOption[]> = Object.fromEntries(
Object.entries(sharedUnitTable).map(([quantity, options]) => [quantity,
Object.entries(options).map(([value, [scale, offset, label]]) => ({
value, label, toBase: (number: number) => number * scale + offset,
fromBase: (number: number) => (number - offset) / scale,
})),
]),
);
const defaultSimulationConfig: ResolvedSimulationConfig = {
t_start: 0,
@@ -6634,7 +6600,7 @@ function FlowWorkbench() {
const normalizedDisplayValue = displayValue.trim();
const numericValue = Number(normalizedDisplayValue);
const hasFiniteNumericValue =
normalizedDisplayValue !== "" && Number.isFinite(numericValue);
DECIMAL_LITERAL.test(normalizedDisplayValue) && Number.isFinite(numericValue);
const convertedBaseValue = hasFiniteNumericValue
? unit.toBase(numericValue)
: numericValue;
@@ -7385,8 +7351,14 @@ function FlowWorkbench() {
}));
};
const warnExecutionVersions = () => {
const warning = componentVersionWarning(nodesRef.current, componentDefinitions, true);
if (warning) appendConsoleEntry("warning", warning);
};
const generateXml = () => {
try {
warnExecutionVersions();
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions, nodes);
appendConsoleEntry(
"success",
@@ -7400,6 +7372,7 @@ function FlowWorkbench() {
const downloadXml = () => {
try {
warnExecutionVersions();
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions, nodes);
downloadText(`${safeFilename(projectName)}.xml`, xml);
appendConsoleEntry("success", `XML 已下载:${safeFilename(projectName)}.xml`);
@@ -7414,8 +7387,9 @@ function FlowWorkbench() {
if (addToHistory) {
recordHistory();
}
const versionWarning = componentVersionWarning(project.nodes, componentDefinitions);
const legacyMigration = migrateLegacyAmesimComponents(
project,
decodeProjectParameters(project, componentDefinitions),
componentDefinitions,
);
const normalizedNodes = legacyMigration.project.nodes.map((node) =>
@@ -7444,6 +7418,9 @@ function FlowWorkbench() {
setValidatedSignature("");
syncNextNodeNumbers(loadedNodes);
appendConsoleEntry("success", message);
if (versionWarning) appendConsoleEntry("warning", versionWarning);
if (project.projectSchemaVersion === 1) appendConsoleEntry("info",
"已按旧工程规则读取参数(数值为 SI);重新导出 JSON 后,数值和表达式均按所选单位解释。");
if (legacyMigration.migratedLmechn1NodeCount > 0) {
appendConsoleEntry(
"info",
@@ -7528,11 +7505,33 @@ function FlowWorkbench() {
const exportProject = () => {
const project = buildCurrentProject();
downloadFile(
`${safeFilename(project.name)}.json`,
JSON.stringify(project, null, 2),
"application/json;charset=utf-8",
);
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;
try {
exported = encodeProjectParameters(upgraded, componentDefinitions);
} catch (error) {
appendConsoleEntry("warning", `工程含未能转换的数据,已保留旧格式和原版本供修复:${formatError(error)}`);
}
downloadFile(`${safeFilename(project.name)}.json`, JSON.stringify(exported, null, 2),
"application/json;charset=utf-8");
const sourceParameters = new Map(project.nodes.map(node => [node.id, node.data.parameterUnits]));
const exactSiCount = exported.projectSchemaVersion === 2 ? exported.nodes.reduce((count, node) => count +
Object.entries(node.data.parameterUnits ?? {}).filter(([name, unit]) =>
unit !== sourceParameters.get(node.id)?.[name]).length, 0) : 0;
if (exactSiCount) appendConsoleEntry("info", `为保持求解输入的完整精度,${exactSiCount} 个参数已使用 SI 单位导出`);
const remaining = componentVersionWarning(exported.nodes, componentDefinitions);
if (remaining) appendConsoleEntry("warning", `部分组件未通过兼容检查,保留原版本。${remaining}`);
appendConsoleEntry("success", `工程 JSON 已导出:${safeFilename(project.name)}.json`);
};
@@ -7733,8 +7732,10 @@ function FlowWorkbench() {
appendConsoleEntry("error", simulationResolution.message);
return;
}
warnExecutionVersions();
const project: ExecutableProjectPayload = {
...buildCurrentProject(),
projectSchemaVersion: 1,
simulation: simulationResolution.value,
};
setSimulationProgress({
@@ -9676,14 +9677,13 @@ function normalizeLoadedNode(
componentDefinitions: ComponentDefinition[],
): SimulationNode {
const definition =
componentDefinitions.find((item) => item.type === node.data.componentType) ??
componentIndex(componentDefinitions).get(node.data.componentType) ??
componentDefinitions.find((item) => item.modelType === node.data.modelType);
const rawParameters = node.data.parameters;
const modelContractCompatible = Boolean(
definition &&
node.data.componentType === definition.type &&
node.data.modelType === definition.modelType &&
node.data.modelVersion === definition.modelVersion,
node.data.modelType === definition.modelType,
);
const parameters = definition && modelContractCompatible
? { ...defaultParameters(definition), ...rawParameters }
@@ -9769,13 +9769,24 @@ function loadedNodeModelContractIssues(
`组件“${label}”的工程类型与当前目录不一致(工程 ${node.data.componentType}/${node.data.modelType},目录 ${definition.type}/${definition.modelType})`,
);
}
if (!node.data.modelVersion) {
issues.push(`组件“${label}”缺少 modelVersion,无法确认方程和参数含义`);
} else if (node.data.modelVersion !== definition.modelVersion) {
issues.push(
`组件“${label}”模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`,
);
const expectedPorts = new Map(definition.ports.map(port => [port.name, port]));
const seen = new Set<string>();
for (const port of node.data.ports) {
const expected = expectedPorts.get(port.name);
if (seen.has(port.name) || !expected || port.kind !== expected.kind ||
port.domain !== expected.domain || port.nominalRole !== expected.nominalRole) {
issues.push(`组件“${label}”端口 ${port.name} 与当前目录不兼容`);
}
seen.add(port.name);
}
for (const port of definition.ports) {
if (!seen.has(port.name)) issues.push(`组件“${label}”缺少当前端口 ${port.name}`);
}
for (const name of Object.keys(node.data.parameters)) {
if (!definition.parameters[name]) issues.push(`组件“${label}”包含目录未定义的参数:${name}`);
}
return issues;
}
@@ -10208,7 +10219,7 @@ function resolveNumericInput(value: ParameterValue): NumericInputResolution {
return { ok: false, message: "参数不能为空", expression: false };
}
const numericValue = Number(normalizedValue);
if (Number.isFinite(numericValue)) {
if (DECIMAL_LITERAL.test(normalizedValue) && Number.isFinite(numericValue)) {
return { ok: true, value: numericValue, expression: false };
}
@@ -10227,6 +10238,11 @@ function resolveParameterValue(
definition: ParameterDefinition,
selectedUnit: string,
): NumericInputResolution {
try {
parameterUnitConversion(definition, selectedUnit);
} catch (error) {
return { ok: false, message: formatError(error), expression: false };
}
const resolved = resolveNumericInput(value);
if (!resolved.ok) {
return resolved;
@@ -10505,15 +10521,6 @@ function validateModel(
`${node.data.label}:工程组件类型与当前目录定义不一致`,
);
}
if (!node.data.modelVersion) {
modelContractIssues.add(
`${node.data.label}:缺少 modelVersion,无法确认方程和参数含义`,
);
} else if (node.data.modelVersion !== definition.modelVersion) {
modelContractIssues.add(
`${node.data.label}:模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`,
);
}
if (definition.modelType === "amesim_mecmas21") {
(["useFriction", "strib"] as const).forEach((name) => {
const value = numericChoiceValue(node.data.parameters[name]);
@@ -10915,7 +10922,7 @@ function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null
return null;
}
if (
value.projectSchemaVersion !== 1 ||
(value.projectSchemaVersion !== 1 && value.projectSchemaVersion !== 2) ||
typeof value.name !== "string" ||
!Array.isArray(value.nodes) ||
!value.nodes.every(isProjectNodePayloadValue) ||
@@ -11005,9 +11012,7 @@ function projectExecutionContractIssues(
);
project.nodes.forEach((node) => {
const label = node.data.label || node.id;
const definition = componentDefinitions.find(
(candidate) => candidate.type === node.data.componentType,
);
const definition = componentIndex(componentDefinitions).get(node.data.componentType);
if (!definition) {
issues.add(`组件“${label}”找不到当前组件目录定义`);
return;
@@ -11017,13 +11022,6 @@ function projectExecutionContractIssues(
`组件“${label}”模型类型不匹配(工程 ${node.data.modelType},当前 ${definition.modelType})`,
);
}
if (!node.data.modelVersion) {
issues.add(`组件“${label}”缺少 modelVersion,无法确认方程和参数含义`);
} else if (node.data.modelVersion !== definition.modelVersion) {
issues.add(
`组件“${label}”模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`,
);
}
if (definition.modelType !== "amesim_mecmas21") {
return;
}
@@ -11044,6 +11042,7 @@ function buildSystemXml(
componentDefinitions: ComponentDefinition[],
sourceNodes: SimulationNode[] = [],
) {
if (project.projectSchemaVersion !== 1) throw new Error("生成 XML 前必须将工程参数转换为 SI");
const contractIssues = projectExecutionContractIssues(
project,
componentDefinitions,
@@ -11083,9 +11082,7 @@ function buildSystemXml(
if (registeredPortsByNodeId.has(node.id)) {
throw new Error(`工程包含重复的组件 ID:${node.id}`);
}
const definitionByComponentType = componentDefinitions.find(
(candidate) => candidate.type === node.data.componentType,
);
const definitionByComponentType = componentIndex(componentDefinitions).get(node.data.componentType);
const definitionByModelType = componentDefinitions.find(
(candidate) => candidate.modelType === node.data.modelType,
);
+92
View File
@@ -0,0 +1,92 @@
import unitTable from "../../schemas/parameter-units.json" with { type: "json" };
export const DECIMAL_LITERAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
export const sharedUnitTable = unitTable as unknown as Record<string, Record<string, [number, number, string]>>;
type Parameter = { unit?: string; quantity?: string };
type Definition = { type: string; modelType: string; modelVersion: string; parameters: Record<string, Parameter> };
type Node = { id: string; data: { label: string; componentType: string; modelType: string; modelVersion?: string;
parameters: Record<string, number | string>; parameterUnits?: Record<string, string> } };
type Project = { projectSchemaVersion: 1 | 2; nodes: Node[] };
const indices = new WeakMap<readonly Definition[], Map<string, Definition>>();
export function componentIndex<D extends Definition>(definitions: readonly D[]): Map<string, D> {
let index = indices.get(definitions);
if (!index) {
index = new Map(definitions.map(definition => [definition.type, definition]));
indices.set(definitions, index);
}
return index as Map<string, D>;
}
export function componentVersionNotices(nodes: readonly Node[], definitions: readonly Definition[]) {
const index = componentIndex(definitions);
return nodes.flatMap(node => {
const definition = index.get(node.data.componentType);
if (!definition || definition.modelType !== node.data.modelType ||
definition.modelVersion === node.data.modelVersion) return [];
return [{ componentId: node.id, label: node.data.label || node.id,
storedVersion: node.data.modelVersion, currentVersion: definition.modelVersion }];
});
}
export function componentVersionWarning(nodes: readonly Node[], definitions: readonly Definition[], running = false) {
const notices = componentVersionNotices(nodes, definitions);
if (!notices.length) return null;
return `${running ? "正在使用旧版或版本未知的工程组件,将按当前模型执行,可能出现仿真失败或仿真结果与实际不符" : "工程包含旧版或版本未知的组件"}:` +
notices.map(n => `${n.label}(${n.storedVersion || "版本未知"} → ${n.currentVersion})`).join(";");
}
export function parameterUnitConversion(definition: Parameter, unit: string) {
const conversion = definition.unit && definition.quantity ? sharedUnitTable[definition.quantity]?.[unit] : undefined;
if (conversion) return { scale: conversion[0], offset: conversion[1] };
if (unit === (definition.unit ?? "")) return { scale: 1, offset: 0 };
throw new Error(`不支持的参数单位“${unit}”(${definition.quantity ?? definition.unit})`);
}
/** Editor numbers stay SI. v2 wire numbers and expressions both use selected units. */
export function decodeProjectParameters<P extends Project>(project: P, definitions: readonly Definition[]): P {
if (project.projectSchemaVersion === 1) return project;
const index = componentIndex(definitions);
return { ...project, projectSchemaVersion: 1, nodes: project.nodes.map(node => {
const definition = index.get(node.data.componentType);
if (!definition) throw new Error(`无法换算未知组件 ${node.data.label} 的单位`);
const parameters = Object.fromEntries(Object.entries(node.data.parameters).map(([name, value]) => {
const parameter = definition.parameters[name];
if (!parameter) throw new Error(`组件 ${node.data.label} 包含未知参数 ${name}`);
const { scale, offset } = parameterUnitConversion(parameter, node.data.parameterUnits?.[name] ?? parameter.unit ?? "");
const isNumber = typeof value === "number" || DECIMAL_LITERAL.test(value.trim());
const si = isNumber ? Number(value) * scale + offset : value;
if (typeof si === "number" && !Number.isFinite(si)) throw new Error(`${node.id}.${name} 必须为有限数值`);
return [name, si];
}));
return { ...node, data: { ...node.data, parameters } };
}) } as P;
}
/** Call only for editor SI data, never on an already-encoded external project. */
export function encodeProjectParameters<P extends Project>(project: P, definitions: readonly Definition[]): P {
if (project.projectSchemaVersion !== 1) throw new Error("工程已按显示单位编码,不能重复换算");
const index = componentIndex(definitions);
return { ...project, projectSchemaVersion: 2, nodes: project.nodes.map(node => {
const definition = index.get(node.data.componentType);
if (!definition) throw new Error(`无法导出未知组件 ${node.data.label} 的新版单位格式`);
const parameterUnits = { ...node.data.parameterUnits };
const parameters = Object.fromEntries(Object.entries(node.data.parameters).map(([name, value]) => {
const parameter = definition.parameters[name];
if (!parameter) throw new Error(`组件 ${node.data.label} 包含未知参数 ${name}`);
const { scale, offset } = parameterUnitConversion(parameter, node.data.parameterUnits?.[name] ?? parameter.unit ?? "");
const isNumber = typeof value === "number" || DECIMAL_LITERAL.test(value.trim());
let display = isNumber ? (Number(value) - offset) / scale : value;
// Some SI doubles have no exact representation after a unit round trip.
// Preserve the numerical input by writing that parameter in its SI unit.
// Never round the physical value or add a hidden second source of truth.
if (typeof display === "number" && display * scale + offset !== Number(value)) {
display = Number(value);
parameterUnits[name] = parameter.unit ?? "";
}
if (typeof display === "number" && !Number.isFinite(display)) throw new Error(`${node.id}.${name} 必须为有限数值`);
return [name, display];
}));
return { ...node, data: { ...node.data, parameters, parameterUnits } };
}) } as P;
}
+1 -1
View File
@@ -2864,7 +2864,7 @@ test("缺失或错误工程版本、字符串端口和缺失 Handle 均拒绝恢
if (invalidVariant === "missing-version") {
delete project.projectSchemaVersion;
} else if (invalidVariant === "wrong-version") {
project.projectSchemaVersion = 2;
project.projectSchemaVersion = 3;
} else if (invalidVariant === "string-port") {
project.nodes[0].data.ports = ["port_1"];
} else {
+8 -15
View File
@@ -883,7 +883,7 @@ test("MECMAS21 旧的 0/1 编码不转换并阻止执行", async ({
await expect(consolePanel).toContainText("模型检查发现");
});
test("缺失模型版本的工程可查看但不补默认参数且不能执行", async ({
test("缺失模型版本保留来源并补当前默认值,生成 XML 只警告", async ({
page,
}) => {
await page.goto("/");
@@ -923,8 +923,8 @@ test("缺失模型版本的工程可查看但不补默认参数且不能执行",
const node = page.locator(
'.flow-canvas .react-flow__node[data-id="amesim_mecmas21_1"]',
);
await expect(node.locator('[data-model-contract-compatible="false"]')).toBeVisible();
await expect(node.getByLabel(/模型合同不兼容/)).toBeVisible();
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 key = Object.keys(window.localStorage).find((candidate) =>
@@ -950,7 +950,7 @@ test("缺失模型版本的工程可查看但不补默认参数且不能执行",
data &&
data.modelVersion === undefined &&
data.parameters &&
!("strib" in data.parameters),
("strib" in data.parameters),
);
});
expect(persistedWithoutDefaults).toBe(true);
@@ -959,20 +959,13 @@ test("缺失模型版本的工程可查看但不补默认参数且不能执行",
name: "仿真控制台",
exact: true,
});
await page.getByRole("button", { name: "检查模型", exact: true }).click();
await expect(consolePanel).toHaveClass(/minimized/);
await expect(
consolePanel.locator(".simulation-console-dock-summary"),
).toContainText("模型检查发现");
await expandSimulationConsole(page);
await expect(consolePanel.locator(".simulation-console-dock-log")).toContainText(
"缺少 modelVersion",
);
await expect(consolePanel).toContainText("工程包含旧版或版本未知的组件");
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
await expect(consolePanel).toContainText("不能生成执行 XML");
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expect(consolePanel).toContainText("模型检查发现");
await expect(consolePanel).toContainText("XML 已生成");
await expect(consolePanel).toContainText("可能出现仿真失败或仿真结果与实际不符");
await expect(consolePanel).not.toContainText("不能生成执行 XML");
});
test("详情浮层只在参数列悬停且下拉选择期间不触发", async ({ page }) => {
+1 -1
View File
@@ -103,7 +103,7 @@ test("压力默认显示 Pa,bar 与 Pa 换算保持绝压且无大气压偏移
await expectPressureXml(page, 250000);
}
const exported = await exportProject(page);
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(250000);
expect(exported.project.nodes[0].data.parameters.reference_pressure).toBe(2.5);
expect(exported.project.nodes[0].data.parameterUnits.reference_pressure).toBe("bar");
});
@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
import cases from "../../../tests/fixtures/parameter-expressions.json" with { type: "json" };
import { evaluateParameterExpression } from "../../src/parameterExpression";
import { componentVersionNotices, decodeProjectParameters, encodeProjectParameters } from "../../src/projectCompatibility";
const definitions = [{ type: "chamber", modelType: "chamber", modelVersion: "2", parameters: {
p0: { unit: "Pa", quantity: "pressure" }, T0: { unit: "K", quantity: "temperature" },
} }];
function project(value: number | string, version: 1 | 2 = 2) {
return { projectSchemaVersion: version, nodes: [{ id: "c", data: { label: "c", componentType: "chamber",
modelType: "chamber", modelVersion: "1", parameters: { p0: value, T0: 20 }, parameterUnits: { p0: "bar", T0: "degC" } } }] };
}
test("shared expression grammar and resource limits", () => {
for (const [source, expected] of cases.valid) {
const actual = evaluateParameterExpression(String(source));
expect(actual.ok, String(source)).toBe(true);
if (actual.ok) expect(actual.value).toBeCloseTo(Number(expected), 12);
}
for (const source of [...cases.invalid, "(".repeat(34) + "1" + ")".repeat(34), "1+".repeat(256) + "1", "1".repeat(513), `min(${Array(17).fill("1").join(",")})`]) {
expect(evaluateParameterExpression(source).ok, source).toBe(false);
}
});
test("v2 selected-unit numbers and expression values agree; legacy numbers retain SI", () => {
for (const value of [2.5, "2.5", "=2.5", "2+0.5"]) {
const input = project(value);
const original = structuredClone(input);
const internal = decodeProjectParameters(input, definitions);
const parameter = internal.nodes[0].data.parameters.p0;
const actual = typeof parameter === "number" ? parameter :
(() => { const e = evaluateParameterExpression(parameter); return e.ok ? e.value * 1e5 : NaN; })();
expect(actual).toBe(250000);
expect(internal.nodes[0].data.parameters.T0).toBe(293.15);
const exported = encodeProjectParameters(internal, definitions);
expect(exported.projectSchemaVersion).toBe(2);
expect(decodeProjectParameters(exported, definitions)).toEqual(internal);
expect(input).toEqual(original);
}
expect(decodeProjectParameters(project(250000, 1), definitions).nodes[0].data.parameters.p0).toBe(250000);
expect(() => encodeProjectParameters(project(2.5), definitions)).toThrow(/重复换算/);
});
test("version index finds stale and missing versions without mutating or per-node fetch", () => {
const input = project(2.5);
const original = structuredClone(input);
expect(componentVersionNotices(input.nodes, definitions)).toEqual([
{ componentId: "c", label: "c", storedVersion: "1", currentVersion: "2" },
]);
expect(input).toEqual(original);
input.nodes[0].data.modelVersion = "2";
expect(componentVersionNotices(input.nodes, definitions)).toEqual([]);
});
test("export preserves exact SI doubles when display-unit round trip loses a bit", () => {
const input = project(15299999.999999998, 1);
input.nodes[0].data.parameterUnits.p0 = "bar";
input.nodes[0].data.parameters.T0 = 293.15;
const encoded = encodeProjectParameters(input, definitions);
expect(encoded.nodes[0].data.parameterUnits.p0).toBe("Pa");
expect(encoded.nodes[0].data.parameters.p0).toBe(15299999.999999998);
expect(decodeProjectParameters(encoded, definitions).nodes[0].data.parameters).toEqual(input.nodes[0].data.parameters);
});