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

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

+10
View File
@@ -0,0 +1,10 @@
{
"valid": [
["=2.5", 2.5], ["2^3^2", 512], ["-2^2", -4], ["2**-3", 0.125],
["2.5E-3", 0.0025], ["sqrt(16)+abs(-2)", 6], ["SIN(pi/2)+ln(e)", 2],
["log(e)+log10(100)", 3], ["max(1,5,3)+pow(2,3)", 13],
["min(-1,0,2)", -1], ["cos(0)+tan(0)+asin(0)+acos(1)+atan(0)+exp(0)", 2],
["0.045/14", 0.0032142857142857142], ["2^20", 1048576]
],
"invalid": ["", "=", "1/0", "sqrt(-1)", "log(0)", "asin(2)", "1e309", "1e308*10", "2^100000000", "unknown+1", "__import__('os')", "window.alert(1)", "2(3)", "min()", "pow(2)", "sqrt(1,2)", "min(1,)", "[1][0]", "0x10", "1_000", "1%2", "True", "null", "1+*2"]
}
+49
View File
@@ -0,0 +1,49 @@
"""Measure JSON validation, input normalization and strict XML construction separately.
Run from the repository root with its Python environment; artifacts are ignored.
"""
import json
from pathlib import Path
from statistics import median
from time import perf_counter_ns
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
from app.project_parameters import prepare_project
def measure(fn, repeat=200):
for _ in range(10):
fn()
samples = []
for _ in range(repeat):
start = perf_counter_ns()
fn()
samples.append((perf_counter_ns() - start) / 1e6)
samples.sort()
return {"medianMs": median(samples), "p95Ms": samples[int(.95 * len(samples))], "samples": repeat}
def main():
source = Path("tests/data/test-mql-8-corrected.json")
raw = json.loads(source.read_text(encoding="utf-8"))
current = ReactFlowProjectPayload.model_validate(raw)
old = current.model_copy(deep=True)
for node in old.nodes:
node.data.modelVersion = "0.0.1"
normalized, _ = prepare_project(current)
metrics = {
"source": str(source), "nodes": len(current.nodes),
"parameters": sum(len(n.data.parameters) for n in current.nodes),
"pydanticInput": measure(lambda: ReactFlowProjectPayload.model_validate(raw)),
"prepareCurrent": measure(lambda: prepare_project(current)),
"prepareAllOld": measure(lambda: prepare_project(old)),
"strictXmlAfterPreparation": measure(lambda: build_reactflow_system_xml(normalized)),
"notes": "prepare includes a defensive copy, parameter/expression/unit conversion, version comparison and simulation time normalization; not version checking alone.",
}
destination = Path("test/project-contract-20260912/backend-input-timing.json")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
print(json.dumps(metrics, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,125 @@
// Real catalog, project UI, HTTP and native execution in the rehearsal sandbox.
import { chromium } from '../../frontend/node_modules/playwright/index.mjs';
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
const [input, output, url = 'http://127.0.0.1:8036'] = process.argv.slice(2);
if (!input || !output) throw new Error('Usage: node browser_component_registration.mjs PROJECT.json OUTPUT_DIR [URL]');
await fs.mkdir(output, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
page.setDefaultTimeout(15000);
const errors = [];
page.on('pageerror', e => errors.push(String(e)));
async function download(label, file) {
const promise = page.waitForEvent('download');
await page.getByRole('button', { name: label, exact: true }).click();
await (await promise).saveAs(path.join(output, file));
return path.join(output, file);
}
try {
await page.goto(url);
const catalog = await (await page.request.get(`${url}/api/components/catalog`)).json();
assert(catalog.libraries.some(x => x.id === 'registration_demo'));
const palette = page.getByRole('button', { name: '注册演练斜坡信号', exact: true });
await palette.waitFor();
await palette.dragTo(page.locator('.react-flow__pane').first(), { targetPosition: { x: 500, y: 300 } });
await page.locator('.react-flow__node').first().waitFor();
const paletteProject = JSON.parse(await fs.readFile(await download('导出工程 JSON', 'palette-created.json'), 'utf8'));
assert.equal(paletteProject.nodes.length, 1);
assert.equal(paletteProject.nodes[0].data.modelType, 'registration_demo_ramp');
assert.deepEqual(paletteProject.nodes[0].data.parameters, { offset: 2, amplitude: 3, duration: 1 });
const validProject = JSON.parse(await fs.readFile(input, 'utf8'));
const invalidProject = structuredClone(validProject);
invalidProject.nodes[0].data.ports[0].positiveFlowDirection = null;
await page.locator('input[type="file"][accept*=".json"]').setInputFiles({
name: 'invalid-catalog-port.json', mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify(invalidProject)),
});
await page.getByText('导入失败:文件不是有效的 ReactFlow 工程 JSON', { exact: true }).waitFor();
assert.equal(await page.locator('.react-flow__node').count(), 1);
await page.locator('input[type="file"][accept*=".json"]').setInputFiles(path.resolve(input));
await page.locator('.react-flow__node[data-id="ramp_1"]').click();
const timeUnits = await page.getByRole('combobox', { name: '时长单位', exact: true }).locator('option').evaluateAll(options => options.map(o => o.value));
assert.deepEqual(timeUnits, ['s']);
const duration = page.getByRole('textbox', { name: '时长', exact: true });
assert.equal(await duration.inputValue(), '1');
await duration.fill('2');
await duration.press('Tab');
const projectPath = await download('导出工程 JSON', 'edited-project.json');
const project = JSON.parse(await fs.readFile(projectPath, 'utf8'));
assert.equal(project.nodes[0].data.parameters.duration, 2);
assert.equal(project.nodes[0].data.parameterUnits.duration, 's');
assert.equal(project.nodes[0].data.ports[0].name, 'out');
assert.equal(project.nodes.length, 4);
assert.equal(project.edges.length, 3);
await page.getByRole('button', { name: '生成系统 XML', exact: true }).click();
const xml = await fs.readFile(await download('下载系统 XML', 'browser-input.xml'), 'utf8');
assert.match(xml, /name="duration" value="2"/);
assert.match(xml, /type="registration_demo_ramp"/);
await page.screenshot({ path: path.join(output, 'model.png'), fullPage: true });
const rows = [];
let lastResult;
for (let index = 0; index < 2; index++) {
const marker = await page.evaluate(() => sessionStorage.getItem('system-simulation-flow:latest-result'));
const responsePromise = page.waitForResponse(r => r.url().endsWith('/api/system-xml/simulate-stream'));
await page.getByRole('button', { name: '运行仿真', exact: true }).click();
const response = await responsePromise;
await response.finished();
assert(response.ok());
await page.waitForFunction(old => {
const value = sessionStorage.getItem('system-simulation-flow:latest-result');
return value && value !== old && JSON.parse(value).storage === 'indexeddb';
}, marker);
await page.getByRole('tab', { name: /^结果/ }).click();
const data = JSON.parse(await fs.readFile(await download('下载结果文件', `run-${index}.simresult`), 'utf8'));
const result = data.snapshot.result;
assert(result.success);
assert.equal(result.simulatedUntil, 1);
assert.equal(result.series.time.length, 11);
assert(result.series['force_1.force']);
result.series.time.forEach((t, i) => assert(Math.abs(result.series['force_1.force'][i] - (2 + 1.5*t)) < 1e-12));
result.series.time.forEach((t, i) => {
assert(Math.abs(result.series['ramp_1.y'][i] - (2 + 1.5*t)) < 1e-12);
assert.equal(result.series['ramp_1.y'][i], result.series['ramp_1.out.signal'][i]);
});
const massErrors = { x: 0, v: 0 };
result.series.time.forEach((t, i) => {
// m=10, F=2+1.5t, x0=v0=0; analytic free-mass motion.
massErrors.x = Math.max(massErrors.x, Math.abs(result.series['mass_1.x'][i] - (.1*t*t + .025*t*t*t)));
massErrors.v = Math.max(massErrors.v, Math.abs(result.series['mass_1.v'][i] - (.2*t + .075*t*t)));
});
assert(massErrors.x < 1e-7 && massErrors.v < 1e-7);
if (index === 1) assert(result.diagnostics.native.cacheHit);
rows.push({ run: index, samples: 11, cacheHit: result.diagnostics.native.cacheHit, analyticPassed: true, massErrors });
lastResult = result;
if (!index) await page.getByRole('tab', { name: '建模', exact: true }).click();
}
await page.getByRole('button', { name: '适应系统图窗口', exact: true }).click();
await page.locator('.results-system-panel .react-flow__node[data-id="ramp_1"]').click();
await page.locator('.results-variable-list button').first().click();
await page.locator('.results-chart-panel .result-chart-window svg').first().waitFor();
const csv = await fs.readFile(await download('下载结果 CSV', 'result.csv'), 'utf8');
const records = csv.replace(/^\uFEFF/, '').trim().split(/\r?\n/).map(s => s.split(','));
assert.equal(records.length, 12);
const signalColumns = records[0].flatMap((key, i) => /ramp_1[.](y|out[.]signal)/.test(key) ? [i] : []);
assert.equal(signalColumns.length, 2);
for (let i = 1; i < records.length; i++) {
const values = records[i].map(Number);
for (const j of signalColumns) assert(Math.abs(values[j] - (2 + 1.5*values[0])) < 1e-12);
}
await page.screenshot({ path: path.join(output, 'result.png'), fullPage: true });
await page.reload();
await page.getByRole('tab', { name: /^结果/ }).click();
const restored = JSON.parse(await fs.readFile(await download('下载结果文件', 'restored.simresult'), 'utf8'));
assert.deepEqual(restored.snapshot.result, lastResult);
assert.deepEqual(errors, []);
await fs.writeFile(path.join(output, 'summary.json'), JSON.stringify({ url, browser: browser.version(),
mocked: false, paletteCreation: true, catalogDrivenParameters: true, timeUnits, siParameterEdit: true,
invalidCatalogSnapshotRejected: true, projectExport: true, xmlExport: true, chartVisible: true, rows, csvAnalyticPassed: true, restoredIdentical: true, errors }, null, 2));
console.log('Browser registration rehearsal passed.');
} catch (error) {
await page.screenshot({ path: path.join(output, 'failure.png'), fullPage: true });
await fs.writeFile(path.join(output, 'failure.txt'), `${error.stack}\n${await page.locator('body').innerText()}`);
throw error;
} finally { await browser.close(); }
+122
View File
@@ -0,0 +1,122 @@
// Acceptance against the real built frontend, HTTP adapter, and native eight-branch solver.
import { chromium } from '../../frontend/node_modules/playwright/index.mjs';
import { stripTypeScriptTypes } from 'node:module';
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
const [baseURL='http://127.0.0.1:8036', output='test/project-contract-20260912/browser'] = process.argv.slice(2);
await fs.mkdir(output,{recursive:true});
const original=JSON.parse(await fs.readFile('tests/data/test-mql-8-corrected.json','utf8'));
const catalog=await (await fetch(`${baseURL}/api/components/catalog`)).json();
const definitions=catalog.libraries.flatMap(l=>l.components).map(d=>({...d,parameters:Object.fromEntries(d.parameters.map(p=>[p.name,p]))}));
const index=new Map(definitions.map(d=>[d.type,d]));
const browser=await chromium.launch({headless:true});
const page=await browser.newPage({viewport:{width:1600,height:1000},acceptDownloads:true});
page.setDefaultTimeout(30000);
const errors=[];page.on('pageerror',e=>errors.push(String(e)));
const unitsOnly=process.argv.includes('--units-only');
const summary=unitsOnly ? JSON.parse(await fs.readFile(path.join(output,'partial-summary.json'),'utf8')) :
{rows:[],unitCases:[],browser:browser.version(),nodeCount:original.nodes.length};
async function expand(){const button=page.getByRole('button',{name:'展开仿真控制台',exact:true});if(await button.count())await button.click();}
async function importProject(project,name){
await page.locator('input[type="file"][accept*=".json"]').setInputFiles({name:`${name}.json`,mimeType:'application/json',buffer:Buffer.from(JSON.stringify(project))});
await page.waitForFunction(name=>[...document.querySelectorAll('input')].some(input=>input.value===name),name);
}
async function exportJson(filename){
const waiting=page.waitForEvent('download');
await page.getByRole('button',{name:'导出工程 JSON',exact:true}).click();
await(await waiting).saveAs(path.join(output,filename));
return JSON.parse(await fs.readFile(path.join(output,filename),'utf8'));
}
try{
await page.goto(baseURL);
await page.getByRole('button',{name:'运行仿真',exact:true}).waitFor();
if(!unitsOnly){
// Run the exact source function in Chromium, separately from React rendering and XML validation.
let source=await fs.readFile('frontend/src/projectCompatibility.ts','utf8');
source=source.replace(/^import unitTable[^\n]+/m,`const unitTable=${await fs.readFile('schemas/parameter-units.json','utf8')};`);
const js=stripTypeScriptTypes(source);
summary.versionCheck=await page.evaluate(async({js,definitions,original})=>{
const api=await import(`data:text/javascript;base64,${btoa(unescape(encodeURIComponent(js)))}`);
const stats=(fn)=>{const samples=[];for(let i=0;i<100;i++)fn();for(let i=0;i<200;i++){const start=performance.now();for(let j=0;j<10;j++)fn();samples.push((performance.now()-start)/10);}samples.sort((a,b)=>a-b);return{medianMs:samples[100],p95Ms:samples[190]};};
const start=performance.now();api.componentIndex(definitions);const coldIndexMs=performance.now()-start;
return{coldIndexMs,cases:[157,1000,10000].map(count=>{
const current=Array.from({length:count},(_,i)=>({...original.nodes[i%original.nodes.length],id:`node-${i}`}));
const old=current.map(n=>({...n,data:{...n.data,modelVersion:'0.0.1'}}));
return{count,current:stats(()=>api.componentVersionNotices(current,definitions)),old:stats(()=>api.componentVersionNotices(old,definitions)),oldWithMessage:stats(()=>api.componentVersionWarning(old,definitions,true))};
})};
},{js,definitions,original});
await fs.writeFile(path.join(output,'version-check.json'),JSON.stringify(summary.versionCheck,null,2));
// Full eight-branch run and result persistence; repeat with old model versions, then exported v2.
let baseline=null;
const stale=structuredClone(original);stale.nodes.forEach(n=>n.data.modelVersion='0.0.1');
const projects=[['current',original],['old',stale]];
for(let i=0;i<3;i++){
const [name,project]=projects[i];
await page.getByRole('tab',{name:'建模',exact:true}).click();
const importedAt=performance.now();await importProject(project,name);const importMs=performance.now()-importedAt;
await expand();
const consolePanel=page.getByRole('complementary',{name:'仿真控制台',exact:true});
if(name==='old')assert((await consolePanel.innerText()).includes('工程包含旧版或版本未知的组件'));
const previous=await page.evaluate(()=>sessionStorage.getItem('system-simulation-flow:latest-result'));
let xml;
const requestPromise=page.waitForRequest(r=>r.url().endsWith('/api/system-xml/simulate-stream'));
const responsePromise=page.waitForResponse(r=>r.url().endsWith('/api/system-xml/simulate-stream'),{timeout:180000});
const started=performance.now();await page.getByRole('button',{name:'运行仿真',exact:true}).click();
xml=(await requestPromise).postData();
const response=await responsePromise;await response.finished();assert(response.ok());
const receiveMs=performance.now()-started;
await page.waitForFunction(previous=>{const current=sessionStorage.getItem('system-simulation-flow:latest-result');return current&&current!==previous&&JSON.parse(current).storage==='indexeddb';},previous,{timeout:180000});
const savedMs=performance.now()-started;
await page.getByRole('tab',{name:/^结果/}).click();
const resultDownload=page.waitForEvent('download');await page.getByRole('button',{name:'下载结果文件',exact:true}).click();
const resultFile=path.join(output,`${name}.simresult`);await(await resultDownload).saveAs(resultFile);
const result=JSON.parse(await fs.readFile(resultFile,'utf8')).snapshot.result;
assert(result.success,result.message);assert.equal(result.simulatedUntil,original.simulation.t_stop);
if(!baseline)baseline=result;
else {
let differences=0;
assert.equal(Object.keys(result.series).length,Object.keys(baseline.series).length);
for(const [key,values] of Object.entries(result.series)) {
assert.equal(values.length,baseline.series[key].length,key);
for(let j=0;j<values.length;j++) if(values[j]!==baseline.series[key][j]) differences++;
}
assert.equal(differences,0,`${name}: ${differences} result values differ`);
}
await fs.writeFile(path.join(output,`${name}.xml`),xml);
summary.rows.push({name,importMs,receiveMs,savedMs,sampleCount:result.series.time.length,identicalSeries:true,native:result.diagnostics.native});
await page.getByRole('tab',{name:'建模',exact:true}).click();await expand();
if(name==='old'){
assert((await consolePanel.innerText()).includes('可能出现仿真失败或仿真结果与实际不符'));
const exported=await exportJson('upgraded-eight.json');assert.equal(exported.projectSchemaVersion,2);
exported.nodes.forEach(n=>assert.equal(n.data.modelVersion,index.get(n.data.componentType).modelVersion));
projects.push(['exported-v2',exported]);
}
}
await fs.writeFile(path.join(output,'partial-summary.json'),JSON.stringify(summary,null,2));
}
// Lightweight real browser + HTTP checks isolate p0 numbers, strings, expression, and degC.
const chamber=definitions.find(d=>d.modelType==='amesim_pnch012');
assert(chamber);
for(const value of [2.5,'2.5','=2.5','2+0.5','sqrt(6.25)']){
const project={projectSchemaVersion:2,name:'units',simulation:{t_start:0,t_stop:0.002,step:0.001,max_step:0.001,method:'BDF'},edges:[],nodes:[{id:'chamber_1',type:'simulationComponent',position:{x:0,y:0},data:{label:'chamber_1',rotation:0,mirrored:false,componentType:chamber.type,modelType:chamber.modelType,modelVersion:chamber.modelVersion,ports:chamber.ports,parameters:{p0:value,T0:'=10+10'},parameterUnits:{p0:'bar',T0:'degC'}}}]};
await importProject(project,`units-${summary.unitCases.length}`);
await page.getByRole('button',{name:'生成系统 XML',exact:true}).click();await expand();
const blocks=page.getByLabel('生成的系统 XML');
const browserXml=await blocks.last().innerText();
const response=await fetch(`${baseURL}/api/reactflow/system-xml`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(project)});
assert(response.ok,await response.clone().text());const httpXml=await response.text();
const read=xml=>[...xml.matchAll(/<Parameter name="([^"]+)" value="([^"]+)"/g)].map(m=>[m[1],Number(m[2])]);
const webParams=Object.fromEntries(read(browserXml));const httpParams=Object.fromEntries(read(httpXml));
assert.equal(webParams.p0,250000);assert.equal(webParams.T0,293.15);assert.deepEqual(webParams,httpParams);
const exported=await exportJson(`units-${summary.unitCases.length}.json`);assert.equal(exported.projectSchemaVersion,2);
summary.unitCases.push({value,siPressure:webParams.p0,siTemperature:webParams.T0,exportedPressure:exported.nodes[0].data.parameters.p0});
}
summary.errors=errors;assert.deepEqual(errors,[]);
await page.screenshot({path:path.join(output,'acceptance.png'),fullPage:true});
await fs.writeFile(path.join(output,'summary.json'),JSON.stringify(summary,null,2));
await fs.rm(path.join(output,'failure.txt'),{force:true});
await fs.rm(path.join(output,'failure.png'),{force:true});
console.log(JSON.stringify(summary,null,2));
}catch(error){await fs.writeFile(path.join(output,'failure.txt'),`${error.stack}\n${await page.locator('body').innerText()}`);await page.screenshot({path:path.join(output,'failure.png'),fullPage:true});throw error;}
finally{await browser.close();}
@@ -0,0 +1,271 @@
"""Rehearse registration in an isolated source tree; never register the demo in production.
Run with the repository Python environment and a fresh --output-dir. The final
sandbox can be served with uvicorn for a real browser check. No API is mocked.
"""
from __future__ import annotations
import argparse
from dataclasses import replace
from hashlib import sha256
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
MODEL = '''from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.ports import PortDefinition
class DemoRamp(AlgebraicComponent):
MODEL_TYPE = 'registration_demo_ramp'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.signal('out', nominal_role='output'),)
PARAMETERS = (
ParameterDefinition('offset', 2.0, label='初始值'),
ParameterDefinition('amplitude', 3.0, label='幅值'),
ParameterDefinition('duration', 1.0, label='时长', quantity='time', unit='s',
minimum=0.0, minimum_exclusive=True),
)
RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),)
DISPLAY = ComponentDisplaySpec(label='注册演练斜坡信号', library_id='registration_demo',
category_id='signals', symbol='amesim_step0',
ports=(PortDisplaySpec('out', 'right', order=10),), order=10)
EQUATIONS = ()
def __init__(self, name, medium, *, offset, amplitude, duration):
super().__init__(name)
self.set_parameter_values(dict(offset=offset, amplitude=amplitude, duration=duration))
self.offset, self.amplitude, self.duration = offset, amplitude, duration
self.out = self.register_declared_port('out')
@classmethod
def create(cls, *, name, medium, parameters):
return cls(name, medium, **parameters)
'''
LIBRARY = '''from app.simulation.core.catalog import ComponentLibrarySpec, ComponentCategorySpec
LIBRARY = ComponentLibrarySpec(id='registration_demo', label='组件注册演练库',
version='1.0.0', source_package='app.simulation.components.registration_demo',
categories=(ComponentCategorySpec(id='signals', label='信号元件', order=10),),
models=('app.simulation.components.registration_demo.signals.ramp:DemoRamp',), order=300)
'''
STAGES = ['unlisted', 'catalog_only', 'native_discovery_only', 'contract_only',
'lowered_without_module_export', 'complete']
def write(root, name, value):
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(value, encoding='utf-8', newline='\n')
def edit(root, name, before, after):
path = root / name
value = path.read_text(encoding='utf-8')
if value.count(before) != 1:
raise RuntimeError(f'Rehearsal source anchor changed: {name}: {before!r}')
write(root, name, value.replace(before, after))
def worker(sandbox, output, stage):
sys.path.insert(0, str(sandbox))
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, build_component_catalog
from app.simulation.native_codegen.compiler import compile_native_program
from app.simulation.native_codegen.build import build_native
from app.simulation.native_codegen.input import project_xml
from app.simulation.native_codegen.runner import execute_native
from app.simulation.backends import simulation_config
from app.main import compile_system_xml_network
from app.system_xml import validate_system_xml_document
record = {'stage': stage, 'registered': 'registration_demo_ramp' in COMPONENT_MODEL_REGISTRY}
if stage == 'unlisted':
assert not record['registered']
return record
catalog = build_component_catalog()
model = next(c for lib in catalog['libraries'] for c in lib['components']
if c['modelType'] == 'registration_demo_ramp')
record['catalog'] = model
project = {'projectSchemaVersion': 1, 'name': 'registration-demo',
'simulation': {'t_start': 0, 't_stop': 1, 'step': .1, 'max_step': .05, 'method': 'BDF'},
'edges': [], 'nodes': [{'id': 'ramp_1', 'type': 'simulationComponent',
'position': {'x': 200, 'y': 200}, 'data': {'label': '注册演练斜坡信号',
'componentType': model['type'], 'modelType': model['modelType'],
'modelVersion': model['modelVersion'], 'symbol': model['symbol'],
# Project snapshots use the browser's normalized port shape, not raw catalog objects.
'ports': [{k: v for k, v in port.items()
if k in ('name', 'kind', 'domain', 'nominalRole', 'side')
or (k == 'positiveFlowDirection' and v == 'intoComponent')}
for port in model['ports']],
'parameters': {p['name']: p['default'] for p in model['parameters']},
'parameterUnits': {'duration': 's'}, 'rotation': 0, 'mirrored': False}}]}
xml = project_xml(project)
report = validate_system_xml_document(xml)
assert report.valid, report.as_dict()
write(output, 'project.json', json.dumps(project, ensure_ascii=False, indent=2))
write(output, 'catalog.json', json.dumps(catalog, ensure_ascii=False, indent=2))
(output / 'input.xml').write_bytes(xml)
# The browser requires all displayed ports to be connected, unlike the
# backend's warning-only standalone signal fixture.
import copy
browser_project = copy.deepcopy(project)
for index, (kind, name) in enumerate((('amesim_forc', 'force_1'),
('amesim_mecmas21', 'mass_1'),
('amesim_f000', 'zero_1')), 1):
item = next(c for lib in catalog['libraries'] for c in lib['components'] if c['modelType'] == kind)
node = copy.deepcopy(project['nodes'][0])
node.update(id=name, position={'x': 200 + index * 220, 'y': 200})
node['data'].update(label=name, componentType=kind, modelType=kind,
modelVersion=item['modelVersion'], symbol=item['symbol'],
ports=[{k: v for k, v in port.items()
if k in ('name', 'kind', 'domain', 'nominalRole', 'side')
or (k == 'positiveFlowDirection' and v == 'intoComponent')}
for port in item['ports']],
parameters={param['name']: param['default'] for param in item['parameters']},
parameterUnits={})
if kind == 'amesim_mecmas21':
node['data']['parameters'].update(stoptype=4, useFriction=1, mass=10, x0=0, v0=0)
browser_project['nodes'].append(node)
for index, (a, pa, b, pb) in enumerate((('ramp_1', 'out', 'force_1', 'res'),
('force_1', 'port_2', 'mass_1', 'port_2'), ('mass_1', 'port_1', 'zero_1', 'port_1'))):
browser_project['edges'].append(dict(id=f'edge_{index}', source=a, sourceHandle=pa,
target=b, targetHandle=pb, data={'isContactEdge': False}))
browser_report = validate_system_xml_document(project_xml(browser_project))
assert browser_report.valid, browser_report.as_dict()
write(output, 'browser-project.json', json.dumps(browser_project, ensure_ascii=False, indent=2))
network = compile_system_xml_network(report.document)
try:
program = compile_native_program(network)
record['generated'] = True
build = build_native(program, cache_dir=output / 'cache')
except (ValueError, RuntimeError) as exc:
record['error'] = str(exc)
if stage == 'complete':
raise
expected = {'catalog_only': 'no native contract',
'native_discovery_only': 'version does not match',
'contract_only': 'output mapping incomplete',
'lowered_without_module_export': 'undefined reference'}[stage]
assert expected.lower() in str(exc).lower(), str(exc)
return record
try:
assert stage == 'complete'
assert not build.cache_hit
assert 'demo_signal' in build.details['selectedModules']
record['firstCompleteBuild'] = build.details # Earlier failed link may warm shared objects.
record['runs'] = []
for method in ('RK45', 'BDF'):
config = replace(simulation_config(report.document.simulation), method=method)
result = execute_native(build, config, .1, run_dir=output / method)
assert result['success'] and result['simulatedUntil'] == 1
errors = [abs(y - (2 + 3*t)) for t, y in zip(result['series']['time'], result['series']['ramp_1.y'])]
assert max(errors) < 1e-12
assert result['series']['ramp_1.y'] == result['series']['ramp_1.out.signal']
record['runs'].append({'method': method, 'samples': len(errors), 'maxAnalyticError': max(errors)})
cached = build_native(program, cache_dir=output / 'cache')
try:
assert cached.cache_hit and cached.details['objectCompilations'] == 0
record['warmBuild'] = cached.details
finally:
cached.close()
project['nodes'][0]['data']['parameters']['duration'] = 2
changed_report = validate_system_xml_document(project_xml(project))
changed = compile_native_program(compile_system_xml_network(changed_report.document))
new_build = build_native(changed, cache_dir=output / 'cache')
try:
assert new_build.details['objectCompilations'] == 1
assert new_build.details['objectCacheHits'] > 0
result = execute_native(new_build, config, .1, run_dir=output / 'parameter-change')
assert result['success'] and abs(result['final']['ramp_1.y'] - 3.5) < 1e-12
record['parameterBuild'] = new_build.details
finally:
new_build.close()
invalid = {'wrong_version': xml.replace(b'modelVersion="1.0.0"', b'modelVersion="9.0.0"')}
# Build semantic mutations from the parsed tree; serializer whitespace
# and float spellings are not an input contract.
import xml.etree.ElementTree as ET
tree = ET.fromstring(xml)
comp = tree.find('./Components/Component')
duration = comp.find("Parameter[@name='duration']")
duration.set('value', '0')
invalid['zero_duration'] = ET.tostring(tree)
comp.remove(duration)
invalid['missing_parameter'] = ET.tostring(tree)
record['rejections'] = {}
for name, payload in invalid.items():
invalid_report = validate_system_xml_document(payload)
assert not invalid_report.valid, name
record['rejections'][name] = invalid_report.as_dict()
record['sourceSha256'] = sha256(program.source.encode()).hexdigest()
return record
finally:
build.close()
def prepare(output):
output.mkdir(parents=True, exist_ok=False)
sandbox = output / 'sandbox'
for name in ('app', 'native', 'schemas'):
shutil.copytree(ROOT / name, sandbox / name,
ignore=shutil.ignore_patterns('__pycache__', '*.pyc', 'data'))
if (ROOT / 'frontend/dist').exists():
shutil.copytree(ROOT / 'frontend/dist', sandbox / 'frontend/dist')
write(sandbox, 'app/simulation/components/registration_demo/__init__.py', '')
write(sandbox, 'app/simulation/components/registration_demo/signals/__init__.py', '')
write(sandbox, 'app/simulation/components/registration_demo/signals/ramp.py', MODEL)
write(sandbox, 'app/simulation/components/registration_demo/library.py', LIBRARY)
rows = []
for stage in STAGES:
if stage == 'catalog_only':
edit(sandbox, 'app/simulation/registry.py', 'ENABLED_COMPONENT_LIBRARIES = (',
'ENABLED_COMPONENT_LIBRARIES = (\n "app.simulation.components.registration_demo.library:LIBRARY",')
elif stage == 'native_discovery_only':
edit(sandbox, 'app/simulation/native_codegen/extended.py', 'for entry in (*a.models, *e.models):',
'from app.simulation.components.registration_demo.library import LIBRARY as demo\n for entry in (*a.models, *e.models, *demo.models):')
elif stage == 'contract_only':
edit(sandbox, 'app/simulation/native_codegen/contracts.py', 'SUPPORTED_VERSIONS = {',
"SUPPORTED_VERSIONS = {\n 'registration_demo_ramp': '1.0.0',")
elif stage == 'lowered_without_module_export':
edit(sandbox, 'app/simulation/native_codegen/extended.py', " elif c.model_type == 'amesim_ud00':",
" elif c.model_type == 'registration_demo_ramp':\n put(c, 'y', f'native_demo_ramp(t,{num(c.offset)},{num(c.amplitude)},{num(c.duration)})')\n elif c.model_type == 'amesim_ud00':")
edit(sandbox, 'native/include/kernels.h', '#include <stddef.h>',
'#include <stddef.h>\ndouble native_demo_ramp(double t, double offset, double amplitude, double duration);')
write(sandbox, 'native/components/modules/demo_signal.c',
'#include "kernels.h"\ndouble native_demo_ramp(double t, double offset, double amplitude, double duration) { return offset + amplitude * t / duration; }\n')
elif stage == 'complete':
edit(sandbox, 'app/simulation/native_codegen/modules.py', 'EXPORTS = {',
"EXPORTS = {\n 'demo_signal': frozenset({'native_demo_ramp'}),")
edit(sandbox, 'native/components/kernels.c', '#define NATIVE_COMPONENT_AMALGAMATION 1',
'#define NATIVE_COMPONENT_AMALGAMATION 1\n#include "../components/modules/demo_signal.c"')
edit(sandbox, 'app/simulation/native_codegen/jacobian.py', '_FUNCTIONS = frozenset({',
"_FUNCTIONS = frozenset({\n 'native_demo_ramp',")
record_path = output / f'{stage}.json'
proc = subprocess.run([sys.executable, '-B', str(Path(__file__).resolve()),
'--worker', stage, '--sandbox', str(sandbox), '--output-dir', str(output)],
capture_output=True, text=True, encoding='utf-8', timeout=180)
write(output, f'{stage}.log', proc.stdout + proc.stderr)
if proc.returncode:
raise RuntimeError(f'{stage} failed; see {output / (stage + ".log")}')
rows.append(json.loads(record_path.read_text(encoding='utf-8')))
summary = {'baselineHead': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
'platform': sys.platform, 'windowsExecuted': os.name == 'nt', 'stages': rows,
'exampleScope': 'Stateless signal source, third library, new C module, existing symbol and parameter controls. No new physical domain, dynamic ports or nontrivial Jacobian coloring.',
'sandbox': str(sandbox), 'productionModelRegistered': False}
write(output, 'summary.json', json.dumps(summary, ensure_ascii=False, indent=2))
print(json.dumps({'output': str(output), 'stages': len(rows), 'complete': True}))
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--output-dir', type=Path, required=True)
parser.add_argument('--worker', choices=STAGES)
parser.add_argument('--sandbox', type=Path)
args = parser.parse_args()
out = args.output_dir.resolve()
if args.worker:
result = worker(args.sandbox.resolve(), out, args.worker)
write(out, f'{args.worker}.json', json.dumps(result, ensure_ascii=False, indent=2))
else:
prepare(out)
+3 -1
View File
@@ -37,7 +37,9 @@ class AsgiClient:
await self.application(scope,receive,send)
status = next(m['status'] for m in messages if m['type']=='http.response.start')
body = b''.join(m.get('body',b'') for m in messages if m['type']=='http.response.body')
return SimpleNamespace(status_code=status,content=body,json=lambda:json.loads(body))
response_headers = next(m.get("headers", []) for m in messages if m["type"] == "http.response.start")
return SimpleNamespace(status_code=status,content=body,json=lambda:json.loads(body),
headers={k.decode():v.decode() for k,v in response_headers})
return asyncio.run(run())
+151
View File
@@ -0,0 +1,151 @@
from copy import deepcopy
import json
from pathlib import Path
import unittest
from urllib.parse import unquote
from xml.etree import ElementTree as ET
from tests.test_native_result_transport import AsgiClient
from app.main import app, ReactFlowProjectPayload, build_reactflow_system_xml
from app.project_parameters import expression_value, prepare_project
from app.simulation.native_codegen.input import project_xml
from tests.test_pressure_units import pressure_project
ROOT = Path(__file__).resolve().parents[1]
def param(xml, name="p0"):
return float(ET.fromstring(xml).find(f"./Components/Component[@id='chamber_1']/Parameter[@name='{name}']").get("value"))
class ProjectInputContractTests(unittest.TestCase):
def test_shared_expression_grammar(self):
cases = json.loads((ROOT / "tests/fixtures/parameter-expressions.json").read_text())
for source, expected in cases["valid"]:
with self.subTest(source=source):
self.assertAlmostEqual(expression_value(source), expected, places=12)
for source in cases["invalid"] + ["(" * 34 + "1" + ")" * 34, "1+" * 256 + "1", "1" * 513, "min(" + ",".join(["1"] * 17) + ")"]:
with self.subTest(source=source):
with self.assertRaises(ValueError):
expression_value(source)
def test_v2_numeric_string_and_expression_have_same_si_value_on_http_and_cli(self):
client = AsgiClient(app)
for unit, magnitude in (("bar", 2.5), ("kPa", 250), ("MPa", .25), ("Pa", 250000)):
for value in (magnitude, str(magnitude), f"={magnitude}", f"sqrt({magnitude}^2)"):
with self.subTest(unit=unit, value=value):
project = pressure_project(value, unit)
project["projectSchemaVersion"] = 2
original = deepcopy(project)
xml = project_xml(project)
response = client.post("/api/reactflow/system-xml", content=json.dumps(project).encode(), headers={"content-type": "application/json"})
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.content, xml)
self.assertAlmostEqual(param(xml), 250000)
self.assertEqual(project, original)
def test_affine_temperature_and_si_defaults_are_not_double_converted(self):
for value in (20, "20", "=10+10"):
project = pressure_project(2.5)
project["projectSchemaVersion"] = 2
data = project["nodes"][0]["data"]
data["parameters"]["T0"] = value
data["parameterUnits"]["T0"] = "degC"
self.assertEqual(param(project_xml(project), "T0"), 293.15)
del data["parameters"]["T0"]
# Omitted defaults remain defined in SI, independent of display metadata.
self.assertEqual(param(project_xml(project), "T0"), 293.15)
def test_old_versions_warn_at_adapter_but_core_remains_strict(self):
client = AsgiClient(app)
for version in (None, "0.0.1", "99.0.0"):
project = pressure_project(250000)
project["nodes"][0]["data"]["modelVersion"] = version
original = deepcopy(project)
response = client.post("/api/reactflow/system-xml", content=json.dumps(project).encode(), headers={"content-type": "application/json"})
self.assertEqual(response.status_code, 200, response.content)
warning = json.loads(unquote(response.headers["x-component-version-warnings"]))
self.assertEqual(warning["components"][0]["storedVersion"], version)
self.assertEqual(param(response.content), 250000)
self.assertEqual(project, original)
with self.assertRaisesRegex(ValueError, "MODEL_VERSION"):
build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(project))
def test_compile_http_adapter_uses_current_version_and_si(self):
client = AsgiClient(app)
project = pressure_project("sqrt(6.25)")
project["projectSchemaVersion"] = 2
project["nodes"][0]["data"]["modelVersion"] = "0.0.1"
response = client.post("/api/reactflow/compile-model", content=json.dumps(project).encode(),
headers={"content-type": "application/json"})
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["warnings"][0]["components"][0]["storedVersion"], "0.0.1")
def test_header_warning_stays_bounded_for_large_old_projects(self):
data = json.loads((ROOT / "tests/data/test-mql-8-corrected.json").read_text())
for node in data["nodes"]:
node["data"]["modelVersion"] = "0.0.1"
response = AsgiClient(app).post("/api/reactflow/system-xml", content=json.dumps(data).encode(),
headers={"content-type": "application/json"})
self.assertEqual(response.status_code, 200, response.content)
header = response.headers["x-component-version-warnings"]
self.assertLessEqual(len(header), 3800)
self.assertEqual(json.loads(unquote(header))["totalCount"], len(data["nodes"]))
def test_old_version_does_not_bypass_structural_or_parameter_errors(self):
client = AsgiClient(app)
for change in ("type", "port", "parameter", "range", "unit", "discrete"):
project = pressure_project(250000)
data = project["nodes"][0]["data"]
data["modelVersion"] = "0.0.1"
if change == "type": data["componentType"] = "tank"
if change == "port": data["ports"][0]["name"] = "unknown"
if change == "parameter": data["parameters"]["unknown"] = 1
if change == "range": data["parameters"]["p0"] = -1
if change == "unit": data["parameterUnits"]["p0"] = "psi"
if change == "discrete": data["parameters"]["gi"] = "=0"
with self.subTest(change=change):
self.assertEqual(client.post("/api/reactflow/system-xml", content=json.dumps(project).encode(), headers={"content-type": "application/json"}).status_code, 400)
def test_strict_execution_boundary_rejects_expressions_and_nonfinite(self):
for value in ("=2.5", "250000", float("inf"), float("nan"), True):
with self.subTest(value=value):
with self.assertRaisesRegex(ValueError, "finite SI number"):
build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(pressure_project(value)))
project = ReactFlowProjectPayload.model_validate(pressure_project(2.5))
project.projectSchemaVersion = 2
with self.assertRaisesRegex(ValueError, "normalized SI"):
build_reactflow_system_xml(project)
def test_simulation_expressions_are_also_normalized_before_execution(self):
project = ReactFlowProjectPayload.model_validate(pressure_project(250000))
project.simulation.t_stop = "=1/500"
normalized, _ = prepare_project(project)
self.assertEqual(normalized.simulation.t_stop, .002)
self.assertEqual(project.simulation.t_stop, "=1/500")
with self.assertRaisesRegex(ValueError, "finite SI"):
build_reactflow_system_xml(project)
def test_legacy_lmechn1_does_not_guess_a_missing_dynamic_port_count(self):
data = json.loads((ROOT / "tests/data/test-mql-8-corrected.json").read_text())
legacy = next(n for n in data["nodes"] if n["data"]["modelType"] == "amesim_lmechn1")
legacy["data"]["modelVersion"] = "0.1.0"
legacy["data"]["ports"] = legacy["data"]["ports"][:9]
del legacy["data"]["parameters"]["v1"]
normalized, notices = prepare_project(ReactFlowProjectPayload.model_validate(data))
self.assertTrue(notices)
with self.assertRaisesRegex(ValueError, "port names"):
build_reactflow_system_xml(normalized)
def test_eight_branch_legacy_project_stays_unchanged(self):
data = json.loads((ROOT / "tests/data/test-mql-8-corrected.json").read_text())
original = deepcopy(data)
normalized, warnings = prepare_project(ReactFlowProjectPayload.model_validate(data))
self.assertFalse(warnings)
self.assertEqual(data, original)
for source, target in zip(data["nodes"], normalized.nodes):
for name, value in source["data"]["parameters"].items():
if isinstance(value, (int, float)):
self.assertEqual(value, target.data.parameters[name])
self.assertIn(b'unitSystem="SI"', build_reactflow_system_xml(normalized))
+2 -2
View File
@@ -98,7 +98,7 @@ class ReactFlowProjectSchemaTests(unittest.TestCase):
execute(project)
def test_unsupported_project_version_is_rejected(self) -> None:
for version in (0, 2, -1, "1"):
for version in (0, 3, -1, "1"):
with self.subTest(version=version):
with self.assertRaises(ValueError):
medium_project(projectSchemaVersion=version)
@@ -209,7 +209,7 @@ class ReactFlowProjectSchemaTests(unittest.TestCase):
storage = Path(directory)
invalid_cases = {
"corrupt": "{not-json",
"future": json.dumps({"projectSchemaVersion": 2}),
"future": json.dumps({"projectSchemaVersion": 3}),
}
with patch("app.main.PROJECT_STORAGE_DIR", storage):
for project_id, text in invalid_cases.items():