import test from 'node:test'; import assert from 'node:assert/strict'; import {simulateGraph,estimateNode,validateGraph,validateDocument,DEFAULT_GLOBALS,autoLayout} from '../src/engine.js'; import {makeDocument,nodeData,PRESETS} from '../src/presets.js'; import {RECORDS,SOURCES} from '../src/catalog.js'; const g={...DEFAULT_GLOBALS,networkMs:0,vadMs:0,queueMs:0,speechSeconds:1}; const node=(id,kind,extra={})=>({id,type:'stage',position:{x:0,y:0},data:{...nodeData(kind,id),...extra}}); const edge=(source,target,release='complete',extra={})=>({id:`${source}-${target}`,source,target,data:{release,...extra}}); const near=(a,b)=>assert.ok(Math.abs(a-b)<.00001,`${a} != ${b}`); test('serial stages add their service times',()=>{ const n=[node('a','program',{durationMs:100}),node('b','program',{durationMs:200}),node('out','output',{durationMs:0,outputType:'text'})]; const s=simulateGraph(n,[edge('a','b'),edge('b','out')],g); near(s.first,300);near(s.complete,300); }); test('parallel all-join waits for longest branch rather than summing branches',()=>{ const n=[node('in','input'),node('a','program',{durationMs:100}),node('b','program',{durationMs:300}),node('join','merge',{durationMs:20}),node('out','output',{durationMs:40,outputType:'text'})]; const e=[edge('in','a'),edge('in','b'),edge('a','join'),edge('b','join'),edge('join','out')]; const s=simulateGraph(n,e,g);near(s.first,360);assert.ok(s.criticalNodes.has('b'));assert.ok(!s.criticalNodes.has('a')); const any=n.map(x=>x.id==='join'?{...x,data:{...x.data,join:'any'}}:x); near(simulateGraph(any,e,g).first,160); }); test('streaming starts early, completion remains bounded by final upstream data',()=>{ const n=[node('llm','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:101}),node('tts','tts',{mode:'custom',customFirstMs:50,customRtf:.1}),node('out','output',{durationMs:0})]; const e=[edge('llm','tts','first'),edge('tts','out','first')],s=simulateGraph(n,e,g); near(s.first,150);near(s.results.llm.end,1100);assert.ok(s.results.tts.end>=s.results.llm.end);assert.ok(s.complete>=s.first+1000); const full=simulateGraph(n,[edge('llm','tts'),edge('tts','out','first')],g); assert.ok(full.first>s.first); }); test('token gate adds k-1 decoding intervals',()=>{ const n=[node('llm','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:101}),node('out','output',{durationMs:0,outputType:'text'})]; near(simulateGraph(n,[edge('llm','out','chunk',{tokens:8})],g).first,170); }); test('loop maximum, inter-step tools, final-only output and context growth are explicit',()=>{ const d={...nodeData('llm','agent'),mode:'custom',customFirstMs:100,customTps:100,outputTokens:11,maxSteps:3,expectedSteps:2,toolMs:50,contextGrowth:20,inputTokens:100,finalOnly:true}; const r=estimateNode(d,g);near(r.end,700);near(r.first,600);assert.deepEqual(r.steps.map(x=>x.input),[100,120,140]); const partial=estimateNode({...d,finalOnly:false},g);near(partial.first,100);near(partial.end,700); const s=simulateGraph([node('agent','llm',d),node('out','output',{outputType:'text',durationMs:0})],[edge('agent','out','first')],{...g,loopPlan:'expected'}); near(s.first,350);near(s.maxFirst,600);near(s.maxComplete,700);near(s.results.agent.end,450); }); test('hidden reasoning delays the first visible token',()=>{ const d={...nodeData('llm','reasoner'),mode:'custom',customFirstMs:100,customTps:100,outputTokens:11,reasoningTokens:50}; const r=estimateNode(d,g);near(r.first,600);near(r.end,700); }); test('shared model resources serialize parallel requests and change critical dependency',()=>{ const n=[node('in','input'),node('a','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:41}),node('b','llm',{mode:'custom',customFirstMs:100,customTps:100,outputTokens:41}),node('out','output',{outputType:'text',durationMs:0})]; const e=[edge('in','a'),edge('in','b'),edge('a','out'),edge('b','out')]; near(simulateGraph(n,e,g).complete,500); const s=simulateGraph(n,e,{...g,sharedGPU:true});near(s.complete,1000);near(s.results.b.resourceWait,500); }); test('source text TTFT is not interpreted as first playable audio',()=>{ const r=estimateNode({...nodeData('speech','LFM3'),mode:'reference',profileId:'lfm3',size:2},g); assert.notEqual(r.first,50);assert.ok(r.assumptions.some(x=>x.includes('只给首token'))); }); test('RTF-only reference uses its real factor without claiming first audio',()=>{ const r=estimateNode({...nodeData('speech','MiniCPM'),mode:'reference',profileId:'minicpm',size:9},g); near(r.rtf,.21);assert.ok(r.assumptions.some(x=>x.includes('来源只给RTF')));assert.notEqual(r.first,210); }); test('program scaling is global, and disconnected work is called out',()=>{ const n=[node('a','program',{durationMs:100}),node('orphan','program',{durationMs:20}),node('out','output',{durationMs:0,outputType:'text'})]; const s=simulateGraph(n,[edge('a','out')],{...g,programScale:2});near(s.first,200);assert.ok(s.warnings.some(x=>x.includes('orphan未连到主输出'))); }); test('audio component size and hardware settings affect engineering estimate',()=>{ const a=estimateNode({...nodeData('asr','ASR'),hardware:'h20',size:.6},g); const b=estimateNode({...nodeData('asr','ASR'),hardware:'h20',size:2},g); assert.ok(b.end>a.end);const t1=estimateNode({...nodeData('tts','TTS'),cards:1},g),t2=estimateNode({...nodeData('tts','TTS'),cards:2},g);assert.ok(t2.end{ const n=[node('in','input'),node('out','output',{durationMs:60,outputType:'text'})]; near(simulateGraph(n,[edge('in','out','first')],{...g,vadMs:120,networkMs:20}).first,200); }); test('invalid cycles and dangling edges are rejected including imports',()=>{ const n=[node('a','program'),node('b','program')]; assert.ok(validateGraph(n,[edge('a','b'),edge('b','a')]).some(x=>x.includes('环'))); assert.ok(validateGraph(n,[edge('a','missing')]).length); assert.ok(validateDocument({version:1,nodes:n,edges:[],globals:{computeScale:-1}}).length); }); test('memory over-capacity is surfaced rather than silently claiming deployability',()=>{ const r=estimateNode({...nodeData('llm','405B'),size:405,hardware:'4090',cards:1},g); assert.ok(r.warnings.some(x=>x.includes('显存'))); }); test('source records have provenance and do not confuse the units of metrics',()=>{ const ids=new Set(); for(const r of RECORDS){assert.ok(!ids.has(r.id));ids.add(r.id);assert.ok(SOURCES[r.source]?.url.startsWith('https://'));assert.ok(r.scope);if(r.evidence==='unknown')assert.equal(r.value,null);else assert.ok(r.value>0);} const q=RECORDS.find(r=>r.id==='q3-8');near(q.value,150.25);assert.equal(q.metric,'tps'); }); test('all presets compute finite targets and survive JSON round trip',()=>{ for(const key of Object.keys(PRESETS)){const d=makeDocument(key);assert.deepEqual(validateDocument(d),[]);const s=simulateGraph(d.nodes,d.edges,d.globals);assert.deepEqual(s.errors,[]);assert.ok(Number.isFinite(s.first));assert.ok(Number.isFinite(s.complete));assert.deepEqual(validateDocument(JSON.parse(JSON.stringify(d))),[]);assert.equal(autoLayout(d.nodes,d.edges).length,d.nodes.length);} });