Files
SystemSimulationApp/tests/manual/browser_project_contract.mjs
T

123 lines
9.3 KiB
JavaScript

// 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();}