旧版前端工程文件导入时版本对比查验、审阅与仿真时部分阻挡功能实现;前端参数输入格式统一规范
This commit is contained in:
1 parent
22579e51c9
commit
44b6ea74ab
32 files changed
+2087
-322
No files matched your search
+10
@@ -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"]
|
||||
}
|
||||
@@ -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(); }
|
||||
@@ -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&¤t!==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)
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user