mandelbrot/tests/browser_smoke.mjs
2026-09-06 23:28:03 +09:00

307 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Connect to a separate Chromium/Edge instance with remote debugging enabled.
// Usage: node tests/browser_smoke.mjs [port=9333] [reset|fast|mid|legacy|axis|black|ui|all]
import assert from 'node:assert/strict';
import {setTimeout as sleep} from 'node:timers/promises';
const port=Number(process.argv[2]||9333),selection=process.argv[3]||'all';
const url=new URL('../index.html?test',import.meta.url).href;
const response=await fetch(`http://127.0.0.1:${port}/json/list`);
assert.ok(response.ok,'Remote debugging endpoint unavailable');
const pages=(await response.json()).filter(t=>t.type==='page');
assert.equal(pages.length,1,'Use one dedicated test tab');
const target=pages[0],ws=new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve,reject)=>{ws.addEventListener('open',resolve,{once:true});ws.addEventListener('error',reject,{once:true})});
let serial=0;const pending=new Map(),errors=[];
ws.addEventListener('message',event=>{
const m=JSON.parse(event.data);
if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);
const p=pending.get(m.id);if(!p)return;pending.delete(m.id);clearTimeout(p.timer);
if(m.error)p.reject(new Error(JSON.stringify(m.error)));else p.resolve(m.result);
});
function command(method,params={}){
const id=++serial;
return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{pending.delete(id);reject(new Error(method+' timeout'))},240000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}))});
}
async function evaluate(expression){
const result=await command('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});
if(result.exceptionDetails)throw new Error(result.exceptionDetails.exception?.description||result.exceptionDetails.text);
return result.result.value;
}
const cases=[
{id:'reset',re:'-0.5',im:'0',span:'3.4',renderMode:'fast',backend:'direct'},
{id:'fast',re:'-0.743643887037151',im:'0.13182590420533',span:'0.00000000000034',renderMode:'fast',backend:'fast-extended'},
{id:'mid',re:'-0.743643887037151',im:'0.13182590420533',span:'0.000001',renderMode:'fast',backend:'fast-extended'},
{id:'legacy',re:'-0.743643887037151',im:'0.13182590420533',span:'0.00000000000034',renderMode:'accurate',backend:'fast-extended'},
{id:'axis',re:'-0.75',im:'0',span:'0.02',renderMode:'fast',backend:'direct'},
{id:'black',re:'-29466147000382485924219538765424656674342940730553342389512209954887705938978413587',im:'5179018789925933872641942859702187326563010594882200888329237785122225251258352',span:'250637324516887125119843167990565159991251418774166207381437546036409',bits:273,baseIter:350,adaptive:true,fixed:true,renderMode:'accurate',backend:'fast-extended'}
];
assert.ok(['all','kernels','cancel','bench','budget','ui'].includes(selection)||cases.some(c=>c.id===selection),'Unknown case');
// Execute the real production kernels on small, fully specified states.
// This isolates terminal and queue behavior from render convergence heuristics.
async function kernelCases(){
const {renderer:r}=globalThis.__MANDEL_TEST__.kernelAccess();
await r.ensureDeepActivePipelines();
const d=r.device,B=GPUBufferUsage,created=[],reports=[];
const check=(value,message)=>{if(!value)throw new Error(message)};
const buffer=(size,usage)=>{const b=d.createBuffer({size:Math.max(4,size),usage});created.push(b);return b};
async function read(b,bytes){const staging=buffer(bytes,B.COPY_DST|B.MAP_READ),e=d.createCommandEncoder();e.copyBufferToBuffer(b,0,staging,0,bytes);d.queue.submit([e.finish()]);await staging.mapAsync(GPUMapMode.READ);const out=staging.getMappedRange().slice(0);staging.unmap();return out}
async function run({count=1,target=3,chunk=1,passes=3,orbit=[0,0,0,0],error=0,m=0,n=0}){
const capacity=Math.max(1,count),params=new ArrayBuffer(96),p=new DataView(params);
[capacity,1,capacity,1,0,0,150000,orbit.length-1,0,0,capacity,0].forEach((v,i)=>p.setUint32(i*4,v,true));
p.setInt32(52,0,true);p.setUint32(84,target,true);p.setUint32(88,chunk,true);
const pb=buffer(96,B.UNIFORM|B.COPY_DST);d.queue.writeBuffer(pb,0,params);
const refs=buffer(orbit.length*16,B.STORAGE|B.COPY_DST),refData=new Float32Array(orbit.length*4);orbit.forEach((v,i)=>refData[i*4]=v);d.queue.writeBuffer(refs,0,refData);
const states=buffer(capacity*32,B.STORAGE|B.COPY_DST|B.COPY_SRC),initial=new ArrayBuffer(capacity*32),sv=new DataView(initial);
for(let i=0;i<count;i++){sv.setUint32(i*32+20,n,true);sv.setUint32(i*32+24,m,true);sv.setFloat32(i*32+28,error,true)}d.queue.writeBuffer(states,0,initial);
const queues=[0,1].map(()=>buffer(capacity*4,B.STORAGE|B.COPY_DST|B.COPY_SRC)),counts=[0,1].map(()=>buffer(4,B.STORAGE|B.COPY_DST|B.COPY_SRC)),indirect=buffer(12,B.STORAGE|B.INDIRECT|B.COPY_DST);
if(count)d.queue.writeBuffer(queues[0],0,Uint32Array.from({length:count},(_,i)=>i));d.queue.writeBuffer(counts[0],0,Uint32Array.of(count));
const meta=buffer(capacity*4,B.STORAGE|B.COPY_SRC|B.COPY_DST),smooth=buffer(capacity*4,B.STORAGE|B.COPY_SRC);
const e=d.createCommandEncoder();let input=r.encodeDeepActiveChunks(e,{pbuf:pb,stateB:states,queues,counts,indirect,meta,smooth,input:0,passes,refsB:refs});d.queue.submit([e.finish()]);
const active=new Uint32Array(await read(counts[input],4))[0],field=new Uint32Array(await read(meta,capacity*4));
const live=active?Array.from(new Uint32Array(await read(queues[input],active*4))).sort((a,b)=>a-b):[];
return{active,field,live,states:new DataView(await read(states,capacity*32)),async resume(nextTarget,nextPasses){p.setUint32(84,nextTarget,true);d.queue.writeBuffer(pb,0,params);const e=d.createCommandEncoder();input=r.encodeDeepActiveChunks(e,{pbuf:pb,stateB:states,queues,counts,indirect,meta,smooth,input,passes:nextPasses,refsB:refs});d.queue.submit([e.finish()]);return{active:new Uint32Array(await read(counts[input],4))[0],field:new Uint32Array(await read(meta,capacity*4))}}};
}
try{
let x=await run({orbit:[0,1,2,5]});check(x.active===0&&x.field[0]===(1<<28|3),'escape exactly at target');
x=await run({target:1,n:1,m:1,passes:1,error:.002});check(x.active===0&&x.field[0]===(1<<20|1),'terminal error guard');
x=await run({target:2,passes:2,orbit:[0]});check(x.active===0&&x.field[0]===(3<<20),'reference exhaustion');
x=await run({target:1,passes:1,orbit:[0],m:1});check(x.active===0&&x.field[0]===(3<<20),'reference index guard');
for(const count of [0,1,63,64,65])for(const passes of [1,2,3,4]){
const target=passes;x=await run({count,target,passes,orbit:Array(10).fill(0)});
check(x.active===count,'queue count');check(x.live.every((v,i)=>v===i),'queue membership');
for(let i=0;i<count;i++){check(x.field[i]===(6<<20|target),'target metadata');check(x.states.getUint32(i*32+20,true)===target,'retained iteration')}
const next=await x.resume(target+1,1);check(next.active===count,'target resubmission');
for(let i=0;i<count;i++)check(next.field[i]===(6<<20|target+1),'next target metadata');
}
reports.push('target escape/error/reference bounds; 0/1/63/64/65 queues; 14 passes; retained states');
await r.ensureCorrectionPipelines();await r.ensureCompactActivePipelines();
{
const width=17,height=5,n=width*height,initial=Uint32Array.from({length:n},(_,i)=>i%7===6?1<<28:(i%7)<<20|i);
const p=new Uint32Array(24);p.set([9,3,width,height,3,1,100,0,0,2,width,width+3]);
const pb=buffer(96,B.UNIFORM|B.COPY_DST),meta=buffer(n*4,B.STORAGE|B.COPY_SRC|B.COPY_DST),buckets=buffer(96,B.STORAGE|B.COPY_DST),stats=buffer(32,B.STORAGE|B.COPY_SRC|B.COPY_DST),queue=buffer(n*4,B.STORAGE|B.COPY_SRC),indirect=buffer(16,B.STORAGE|B.INDIRECT);
d.queue.writeBuffer(pb,0,p);d.queue.writeBuffer(meta,0,initial);const e=d.createCommandEncoder();
r.encodeDeepBucketHistogram(e,{pbuf:pb,meta,bucketState:buckets,w:9,h:3});r.encodeDeepBucketPrefix(e,{bucketState:buckets,queueStats:stats,indirect});r.encodeDeepBucketScatter(e,{pbuf:pb,meta,bucketState:buckets,queueStats:stats,queue,w:9,h:3});d.queue.submit([e.finish()]);
const q=new Uint32Array(await read(stats,32)),expected=[];
for(let y=1;y<4;y++)for(let x=3;x<12;x++){const i=y*width+x;if(i%7>=1&&i%7<=5)expected.push(i)}
check(q[0]===expected.length&&q[1]===0&&q[2]===expected.length,'tile queue counts');
const actual=Array.from(new Uint32Array(await read(queue,q[0]*4))).sort((a,b)=>a-b);check(JSON.stringify(actual)===JSON.stringify(expected),'tile queue exact membership');
if(!r.precisionScatter)r.precisionScatter=await r.makeCompute(await r.module('precision-scatter-test',globalThis.MANDEL_WEBGPU_KERNELS.PRECISION_SCATTER_WGSL));
const results=new Uint32Array(n*4);for(let i=0;i<n;i++)results.set([i,1<<28|7,0,i%2],i*4);
const input=buffer(results.byteLength,B.STORAGE|B.COPY_DST),sm=buffer(n*4,B.STORAGE);d.queue.writeBuffer(input,0,results);
const enc=d.createCommandEncoder(),bg=d.createBindGroup({layout:r.precisionScatter.getBindGroupLayout(0),entries:[{binding:0,resource:{buffer:input}},{binding:1,resource:{buffer:meta}},{binding:2,resource:{buffer:sm}}]}),pass=enc.beginComputePass();pass.setPipeline(r.precisionScatter);pass.setBindGroup(0,bg);pass.dispatchWorkgroups(2);pass.end();d.queue.submit([enc.finish()]);
const applied=new Uint32Array(await read(meta,n*4));for(let i=0;i<n;i++)check(applied[i]===(i%2&&i%7>=1&&i%7<=5?(1<<28|7):initial[i]),'scatter preserves completed/operation-limit pixels');
}
{
const pb=buffer(96,B.UNIFORM|B.COPY_DST),p=new Uint32Array(24);p.set([9,1,9,1,0,0,150000,4,0,3,9,0]);p[21]=3;p[22]=1;d.queue.writeBuffer(pb,0,p);
const stateB=buffer(3*40,B.STORAGE|B.COPY_SRC),queues=[0,1].map(()=>buffer(12,B.STORAGE|B.COPY_SRC|B.COPY_DST)),counts=[0,1].map(()=>buffer(4,B.STORAGE|B.COPY_SRC|B.COPY_DST)),indirect=buffer(12,B.STORAGE|B.INDIRECT),refsB=buffer(5*16,B.STORAGE),meta=buffer(9*4,B.STORAGE|B.COPY_SRC),sm=buffer(9*4,B.STORAGE);
d.queue.writeBuffer(queues[0],0,Uint32Array.of(8,2,5));d.queue.writeBuffer(counts[0],0,Uint32Array.of(3));
const e=d.createCommandEncoder();r.encodeDeepActiveResumeInit(e,{pbuf:pb,stateB,queue:queues[0],count:3,pipeline:r.deepCompactInit});
const output=r.encodeDeepActiveChunks(e,{pbuf:pb,stateB,queues,counts,indirect,meta,smooth:sm,input:0,passes:3,refsB,pipeline:r.deepCompactContinue});d.queue.submit([e.finish()]);
const field=new Uint32Array(await read(meta,36)),states=new DataView(await read(stateB,120));
for(let i=0;i<9;i++)check(field[i]===([8,2,5].includes(i)?6<<20|3:0),'compact pixel mapping');
for(let i=0;i<3;i++){check(states.getUint32(i*40+32,true)===[8,2,5][i],'compact slot identity');check(states.getUint32(i*40+20,true)===3,'compact state retained')}
check(new Uint32Array(await read(counts[output],4))[0]===3,'compact queue retained');
}
reports.push('tile failure queue exact membership; scatter ownership; compact slot identity');
{
const width=17,height=5,n=width*height,whole=buffer(n*4,B.STORAGE|B.COPY_SRC),pieces=buffer(n*4,B.STORAGE|B.COPY_SRC),sm=buffer(n*4,B.STORAGE);
const unit=1n<<256n,snap={bits:256,re:-unit/2n,im:0n,span:34n*unit/10n},e=d.createCommandEncoder();
const encode=(meta,x,y,w,h)=>{const pb=buffer(96,B.UNIFORM|B.COPY_DST);d.queue.writeBuffer(pb,0,r.directParams(w,h,width,height,x,y,100,snap,.5,.5,0,width,y*width+x));r.encodeDirectNumeric(e,{pbuf:pb,meta,smooth:sm,w,h})};
encode(whole,0,0,width,height);
for(let y=0;y<height;y+=2)for(let x=0;x<width;x+=6)encode(pieces,x,y,Math.min(6,width-x),Math.min(2,height-y));
d.queue.submit([e.finish()]);const expected=new Uint32Array(await read(whole,n*4)),actual=new Uint32Array(await read(pieces,n*4));
check(expected.every((v,i)=>v!==0&&v===actual[i]),'direct tiles reproduce whole-frame coordinates');
}
reports.push('direct tile coordinates and full pixel coverage');
return reports;
}finally{await d.queue.onSubmittedWorkDone();for(const b of created)b.destroy()}
}
// Read the actual texture selected by presentation, including animation during
// numeric work. UI text alone cannot prove that colors are changing.
async function displayedColorHash(){
const r=globalThis.__MANDEL_TEST__.kernelAccess().renderer,f=r.frame,d=r.device;
const texture=r.colorSourceVisible?r.colorSource.texture:f.front;
const pitch=Math.ceil(f.w*4/256)*256,b=d.createBuffer({size:pitch*f.h,usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ});
try{
const e=d.createCommandEncoder();e.copyTextureToBuffer({texture},{buffer:b,bytesPerRow:pitch},[f.w,f.h]);d.queue.submit([e.finish()]);
await b.mapAsync(GPUMapMode.READ);const data=new Uint8Array(b.getMappedRange()),pixels=new Uint8Array(f.n*4);
for(let y=0;y<f.h;y++)pixels.set(data.subarray(y*pitch,y*pitch+f.w*4),y*f.w*4);
const digest=await crypto.subtle.digest('SHA-256',pixels);b.unmap();return Array.from(new Uint8Array(digest),x=>x.toString(16).padStart(2,'0')).join('');
}finally{b.destroy()}
}
try{
await command('Runtime.enable');
await command('Emulation.setDeviceMetricsOverride',{width:320,height:240,deviceScaleFactor:1,mobile:false});
await command('Page.navigate',{url});
for(let i=0;i<100;i++){if(await evaluate('!!globalThis.__MANDEL_TEST__'))break;await sleep(100)}
const ready=await evaluate('globalThis.__MANDEL_TEST__.ensureGpuReady()');
assert.equal(ready.ready,true,JSON.stringify(ready));
if(selection==='ui'||selection==='all'){
const initial=await evaluate('__MANDEL_TEST__.waitForNumericComplete()');
assert.equal(initial.renderClock.status,'complete');
assert.ok(initial.lastRender>=initial.previewMs+initial.refineMs);
if(initial.previewMs)assert.ok(initial.lastRender>=initial.previewMs+initial.refineMs+100,'include refinement delay');
assert.equal(await evaluate('!!document.querySelector("#renderMode")'),false);
const original=await evaluate('__MANDEL_TEST__.fieldHashes()');
const originalColor=await evaluate(`(${displayedColorHash.toString()})()`);
await evaluate('document.querySelector("#colorAuto").click()');
await sleep(350);
assert.notEqual(await evaluate(`(${displayedColorHash.toString()})()`),originalColor,'animation changes GPU pixels');
assert.deepEqual(await evaluate('__MANDEL_TEST__.fieldHashes()'),original,'animation preserves numeric data');
const idle=await evaluate('__MANDEL_TEST__.state()');
assert.equal(idle.cycle,initial.cycle);assert.equal(idle.lastRender,initial.lastRender);
await evaluate('document.querySelector("#colorAuto").click()');
await sleep(150);
const stopped=await evaluate('__MANDEL_TEST__.state().shift');await sleep(150);
assert.equal(await evaluate('__MANDEL_TEST__.state().shift'),stopped);
assert.equal(await evaluate('!!__MANDEL_TEST__.kernelAccess().renderer.colorSource'),false,'release animation memory');
await evaluate('document.querySelector("#colorAuto").click()');
const ongoing=evaluate(`__MANDEL_TEST__.setView(${JSON.stringify({...cases[1],bits:448,baseIter:512,adaptive:false})})`);
let active=false;
for(let i=0;i<1000;i++){
const state=await evaluate('__MANDEL_TEST__.state()');
if(state.rendering&&state.provisional&&state.engine.includes('数値補修')){active=true;break}
await sleep(20);
}
assert.ok(active,'animate during expensive repair');
const before=await evaluate(`(${displayedColorHash.toString()})()`);
await sleep(350);
assert.equal(await evaluate('__MANDEL_TEST__.state().rendering'),true);
assert.equal(await evaluate('!!__MANDEL_TEST__.kernelAccess().renderer.colorSourceVisible'),true);
assert.notEqual(await evaluate(`(${displayedColorHash.toString()})()`),before,'color changes while numeric work is running');
assert.match(await evaluate('document.querySelector("#render").textContent'),/^描画中… /);
// A manual change made while rendering must be drained at completion.
await evaluate('document.querySelector("#colorAuto").click();const shift=document.querySelector("#shift");shift.value="0.65";shift.dispatchEvent(new Event("input",{bubbles:true}))');
await ongoing;const final=await evaluate('__MANDEL_TEST__.waitForNumericComplete()');
for(let i=0;i<100;i++){if(await evaluate('!__MANDEL_TEST__.state().recolorPending&&!__MANDEL_TEST__.state().recoloring'))break;await sleep(20)}
assert.equal(final.renderClock.status,'complete');assert.ok(final.lastRender>2000);
assert.ok(final.lastRender>=final.refineMs);
const timing=await evaluate('({label:document.querySelector("#render").textContent,now:performance.now(),state:__MANDEL_TEST__.state(),front:__MANDEL_TEST__.kernelAccess().renderer.frontColorKey})');
assert.equal(timing.label,(final.lastRender/1000).toFixed(2)+' s');
assert.ok(timing.now-final.renderClock.startedAt-final.lastRender<1000,'time is current without starting another render');
assert.equal(Number(timing.front.split(':')[2]),0.65);assert.equal(timing.state.recolorPending,false);
const hash=await evaluate('__MANDEL_TEST__.fieldHashes()');
const view={...cases[1],bits:448,baseIter:512,adaptive:false};
await evaluate(`__MANDEL_TEST__.setView(${JSON.stringify(view)})`);await evaluate('__MANDEL_TEST__.waitForNumericComplete()');
assert.deepEqual(await evaluate('__MANDEL_TEST__.fieldHashes()'),hash,'animated and ordinary renders agree');
// Old preferences and shared URLs migrate to the sole supported renderer.
const legacyUrl=await evaluate('localStorage.setItem("mandelbrot.renderMode","accurate");const u=new URL(location.href),p=new URLSearchParams(u.hash.slice(1));p.set("rm","accurate");u.hash=p.toString();u.href');
await command('Page.navigate',{url:legacyUrl});await command('Page.reload');await sleep(150);
await evaluate('__MANDEL_TEST__.waitForNumericComplete()');
assert.equal(await evaluate('__MANDEL_TEST__.state().renderMode'),'fast');
assert.equal(await evaluate('localStorage.getItem("mandelbrot.renderMode")'),null);
console.log(JSON.stringify({ui:'PASS: animation idle/during repair, stop/manual color, numeric equality, total time, legacy migration',renderMs:final.lastRender}));
}
if(selection==='kernels'||selection==='all'){
await evaluate('globalThis.__MANDEL_TEST__.waitForNumericComplete()');
console.log(JSON.stringify({kernels:await evaluate(`(${kernelCases.toString()})()`)}));
}
for(const scenario of cases.filter(c=>selection==='all'||c.id===selection)){
const small=['axis','black'].includes(scenario.id);
await command('Emulation.setDeviceMetricsOverride',{width:small?64:320,height:small?48:240,deviceScaleFactor:1,mobile:false});
const view={bits:448,baseIter:512,adaptive:false,processMode:'standard',...scenario};
const result=await evaluate(`(async()=>{const t=globalThis.__MANDEL_TEST__;await t.setView(${JSON.stringify(view)});const state=await t.waitForNumericComplete({timeout:180000});return {state,hashes:await t.fieldHashes(),diagnostics:t.gpuDiagnostics()}})()`);
assert.equal(result.state.numericalFailures,0);
assert.equal(result.state.frontierConverged,true);
assert.equal(result.state.historyReady,true);
assert.ok(result.state.backend.endsWith(scenario.backend),result.state.backend);
assert.ok(result.state.frontierMaxDispatchWork<=4000000);
assert.equal(result.diagnostics.lossReason,'');
assert.deepEqual(result.diagnostics.uncapturedErrors,[]);
assert.equal(result.diagnostics.compilation.flatMap(c=>c.messages).filter(m=>m.type==='error').length,0);
console.log(JSON.stringify({case:scenario.id,backend:result.state.backend,iter:result.state.effectiveIter,ms:result.state.lastRender,hashes:result.hashes}));
if(scenario.id!=='reset'){
const n=result.hashes.pixels,indices=Array.from(new Set([0,n-1,Math.floor(n/2),...Array.from({length:9},(_,i)=>(i*7919+127)%n)]));
const samples=await evaluate(`globalThis.__MANDEL_TEST__.independentSamples(${JSON.stringify(indices)})`);
let maxSmoothError=0;
for(const p of samples){
assert.equal(p.accepted,1,'independent sample inconclusive');
assert.equal((p.packed>>>28)===1,(p.oracle>>>28)===1,JSON.stringify(p));
if((p.oracle>>>28)===1){
assert.equal(p.packed&0xfffff,p.oracle&0xfffff,JSON.stringify(p));
// WGSL reconstructs magnitude and evaluates log2 in f32. Compare
// smoothing in representable units, separately from exact escape n.
const ulp=2**(Math.floor(Math.log2(Math.max(1,Math.abs(p.oracleSmooth))))-23);
const error=Math.abs(p.smoothed-p.oracleSmooth);maxSmoothError=Math.max(maxSmoothError,error);
// Keep ULP checks for the established FAST/axis samples. The new
// cap case and former Deep-only black case use approximate smoothing
// in the unified renderer; classification and escape n remain exact
// checks, and their smoothing deviations are explicitly reported.
if(!['mid','black'].includes(scenario.id))assert.ok(error<=8*ulp,JSON.stringify(p));
}
}
console.log(JSON.stringify({case:scenario.id,independentSamples:samples.length,maxSmoothError}));
if(scenario.id==='mid'){
const cap=await evaluate('(async()=>{const r=__MANDEL_TEST__.kernelAccess().renderer,field=await r.readFieldAll();let below=0,remaining=0;for(const p of field.meta){if((p>>>28)===0){remaining++;if((p>>>20)!==6||(p&0xfffff)!==150000)below++}}return {below,remaining,png:await __MANDEL_TEST__.pngRoundTrip()}})()');
assert.equal(result.state.effectiveIter,150000);assert.equal(cap.below,0);assert.ok(cap.remaining>0);assert.equal(cap.png.mismatches,0);
}
}
if(scenario.id==='reset'){
// Returning to the same view must reproduce both numerical buffers.
await evaluate('(async()=>{const t=globalThis.__MANDEL_TEST__;await t.panPixels(8,0);await t.waitForNumericComplete();await t.panPixels(-8,0);await t.waitForNumericComplete()})()');
assert.deepEqual(await evaluate('globalThis.__MANDEL_TEST__.fieldHashes()'),result.hashes);
const exports=await evaluate('(async()=>{const t=globalThis.__MANDEL_TEST__;return [await t.smokeExportTile({w:32,h:24}),await t.smokeExportTile({w:32,h:24,ss:2})]})()');
for(const item of exports){assert.equal(item.length,item.expected);assert.ok(item.checksum)}
const png=await evaluate('globalThis.__MANDEL_TEST__.pngRoundTrip()');
assert.equal(png.mismatches,0);assert.equal(png.width,320);assert.equal(png.height,240);
console.log(JSON.stringify({png}));
}
}
if(selection==='all'||selection==='budget'){
await command('Emulation.setDeviceMetricsOverride',{width:64,height:48,deviceScaleFactor:1,mobile:false});
for(const baseIter of [512,4096]){
const view={...cases[3],bits:448,baseIter,adaptive:false,continuationBudget:4096};
const result=await evaluate(`(async()=>{const t=globalThis.__MANDEL_TEST__;await t.setView(${JSON.stringify(view)});const state=await t.waitForNumericComplete();const r=t.kernelAccess().renderer,field=await r.readFieldAll();let below=0,above=0;for(const p of field.meta){if((p>>>28)===0&&(p>>>20)===6&&(p&0xfffff)!==4096)below++;if((p&0xfffff)>4096)above++}return {state,below,above,png:await t.pngRoundTrip()}})()`);
assert.equal(result.state.effectiveIter,4096);assert.equal(result.state.continuationBudget,4096);assert.equal(result.below,0);assert.equal(result.above,0);assert.equal(result.png.mismatches,0);assert.equal(result.png.iter,4096);
}
console.log('PASS: explicit finite budget, all retained pixels reach target, PNG uses that budget');
}
if(selection==='all'||selection==='cancel'){
await command('Emulation.setDeviceMetricsOverride',{width:320,height:240,deviceScaleFactor:1,mobile:false});
const normal={...cases[0],bits:448,baseIter:512,adaptive:false};
await evaluate(`globalThis.__MANDEL_TEST__.setView(${JSON.stringify(normal)})`);
const expected=await evaluate('globalThis.__MANDEL_TEST__.fieldHashes()');
const ongoing=evaluate(`globalThis.__MANDEL_TEST__.setView(${JSON.stringify({...cases[1],bits:448,baseIter:512,adaptive:false})})`).then(value=>({value}),error=>({error:String(error)}));
let during=false;
for(let i=0;i<1000;i++){
const s=await evaluate('globalThis.__MANDEL_TEST__.state()');
if(s.provisional&&(s.engine.includes('数値補修')||s.gpuStage==='precision scatter')){during=true;break}
await sleep(20);
}
assert.ok(during,'exercise cancellation during CPU repair, not after completion');
const provisionalPng=await evaluate('globalThis.__MANDEL_TEST__.pngRoundTrip().then(()=>false,()=>true)');
assert.equal(provisionalPng,true,'unfinished field must not enter complete PNG path');
await evaluate(`globalThis.__MANDEL_TEST__.setView(${JSON.stringify(normal)})`);
await ongoing;
await evaluate('globalThis.__MANDEL_TEST__.waitForNumericComplete()');
assert.deepEqual(await evaluate('globalThis.__MANDEL_TEST__.fieldHashes()'),expected);
assert.deepEqual((await evaluate('globalThis.__MANDEL_TEST__.gpuDiagnostics()')).uncapturedErrors,[]);
console.log('PASS: cancel during CPU repair, preserve newest frame, reject provisional PNG');
}
assert.deepEqual(errors,[]);
if(selection==='bench'){
await evaluate('globalThis.__MANDEL_TEST__.waitForNumericComplete()');
for(const scenario of cases.filter(c=>['reset','fast'].includes(c.id))){
let expected;
for(let run=0;run<4;run++){
const view={bits:448,baseIter:512,adaptive:false,processMode:'standard',...scenario};
const record=await evaluate(`(async()=>{const t=globalThis.__MANDEL_TEST__,before=t.efficiency();await t.setView(${JSON.stringify(view)});const s=await t.waitForNumericComplete(),after=t.efficiency(),metrics={};for(const k of Object.keys(after))metrics[k]=k==='activeGpuBytes'?after[k]:after[k]-before[k];return {ms:s.lastRender,iter:s.effectiveIter,numericalFailures:s.numericalFailures,operationLimit:s.unknownReasons.operationLimit,firstDisplayMs:s.generationMetrics.at(-1)?.firstDisplayMs,metrics,hashes:await t.fieldHashes()}})()`);
if(expected)assert.deepEqual(record.hashes,expected,'repeated view changed numeric output');expected=record.hashes;
console.log(JSON.stringify({benchmark:scenario.id,run,warmup:run===0,...record}));
}
}
}
// Ordinary visits should neither expose diagnostics nor invoke legacy gates.
await command('Page.navigate',{url:new URL('../index.html',import.meta.url).href});
for(let i=0;i<100;i++){if(await evaluate("!!globalThis.MANDEL_WEBGPU_KERNELS && !!document.querySelector('#engine')"))break;await sleep(100)}
assert.equal(await evaluate("typeof globalThis.__MANDEL_TEST__"),'undefined');
console.log(`PASS: ${selection}; normal startup exposes no diagnostics`);
}catch(error){
try{console.error(JSON.stringify(await evaluate('({state:globalThis.__MANDEL_TEST__?.state(),gpu:globalThis.__MANDEL_TEST__?.gpuDiagnostics()})')))}catch{}
throw error;
}finally{
try{await command('Page.navigate',{url:'about:blank'})}catch{}
for(const p of pending.values())clearTimeout(p.timer);
ws.close();
}