bend_puzzle/test/performance-smoke-test.js

172 lines
17 KiB
JavaScript
Raw Normal View History

2026-07-30 15:13:53 +09:00
'use strict';
const {vm,app,css,assert,functionSource}=require('./helpers/app-source');
const flashSource=functionSource('flashConfirmedCell'),eventSource=functionSource('eventToSvg'),
dragSource=functionSource('renderDragFrame'),extendSource=functionSource('extendPointerTo'),
extendOneSource=functionSource('extendOne'),bindBoardSource=functionSource('bindBoard'),
dragCacheSource=functionSource('buildDragCache'),
minimapSource=functionSource('drawMinimap'),minimapBuildSource=functionSource('rebuildMinimapWorld'),
widthRefreshSource=functionSource('refreshRenderedLineWidths'),ensureBoardsSource=functionSource('ensureBoards');
assert(!flashSource.includes('getBoundingClientRect')&&!flashSource.includes('querySelector'),'Cell confirmation flash performs a layout read or DOM query');
assert(flashSource.includes("svgEl('rect'")&&flashSource.includes('b.dragLayer.append(node)')&&flashSource.includes("typeof node.animate==='function'"),'Cell confirmation flash does not use one temporary SVG node and WAAPI');
assert(!functionSource('makeBoard').includes('cellHits.set')&&functionSource('makeBoard').includes('cellFlashAnimations:new Map()'),'Board construction still retains one hit node per cell or lacks animation tracking');
assert(!eventSource.includes('getBoundingClientRect')&&eventSource.includes('boardScreenRect(b)'),'Pointer conversion reads live SVG layout');
assert(!functionSource('pointerLocalRadius').includes('getBoundingClientRect')&&functionSource('edgePanVelocity').includes('getViewportRect()'),'High-frequency input still reads live element geometry');
assert(extendSource.includes('renderDragFrame(b)'),'Pointer drawing does not enter the drag-only renderer');
assert(!extendSource.includes('invalidateLineGraphCaches')&&!extendSource.includes('queueLineWidthRefresh'),'Pointer frames still invalidate or traverse connected-line components');
assert(dragSource.includes('drawingLineWidth(b,index)')&&functionSource('drawingLineWidth').includes('drawing.lineWidth=width'),'Drag width is not fixed for the active gesture');
assert(dragSource.includes('pathVisualNodes')&&dragSource.includes('dragCache')&&dragSource.includes('refreshDragSpecialState')&&dragCacheSource.includes('b.dragLayer')&&dragCacheSource.includes('strokes'),'Drag-only renderer does not retain and selectively replace active visuals');
for(const forbidden of ['pathValid(','straightNumberWarningKeys(','multipleNumberWarningKeys(','queueLineWidthRefresh(','renderBoard(b)'])assert(!dragSource.includes(forbidden),`Drag-only renderer performs full-render work: ${forbidden}`);
assert(functionSource('processBoardDragFrame').includes('extendPointerTo(b,point,true)')&&!functionSource('processBoardDragFrame').includes('renderBoard('),'Pointer RAF does not defer raw-sample rendering to one drag-frame repaint');
assert(extendOneSource.includes('isCrossing=b.crossingKeySet.has(key)')&&extendOneSource.includes('currentOwner=')&&extendOneSource.includes('occ.get(exitKey)'),'Ordinary drag occupancy bypasses its retained map');
assert(extendOneSource.includes('if(!suppressMotion){flashConfirmedCell')&&(extendSource.match(/emitBatchedFeedback\(\)/g)||[]).length===2,'Batched pointer traversal emits per-cell confirmation effects');
assert((bindBoardSource.match(/b\.drawing\.keyboardActive=true/g)||[]).length>=2,'Keyboard continuation does not keep its board at interactive LOD');
assert(functionSource('renderedConnectedLineWidth').includes('currentLineGraphCaches()')&&functionSource('markStateDirty').includes('invalidateLineGraphCaches(id,pathIndex)'),'Connected-line caches are not persistent or invalidated by the touched state/path');
assert(widthRefreshSource.includes('board.pathStrokeNodes')&&widthRefreshSource.includes('board.connectorNodes'),'Line-width refresh does not prefer retained SVG node references');
assert(!widthRefreshSource.includes('querySelectorAll'),'Line-width refresh retains a DOM-query fallback');
assert(minimapBuildSource.includes('minimapCache={revision:minimapWorldRevision')&&minimapSource.includes('if(stale)rebuildMinimapWorld')&&minimapSource.includes('drawImage(minimapBase'),'Minimap does not retain and reuse an overscanned world layer');
assert(functionSource('drawMapLongLines').includes('minimapGeometryForComponent(component,caches.geometries)'),'Long-line minimap geometry is recalculated during every world-layer rebuild');
assert(functionSource('markStateDirty').includes('invalidateWorldPresentation()'),'State changes do not invalidate minimap/overview content');
assert(app.includes('globalThis.BEND_PERF=')&&functionSource('perfObserve').includes('samples.length>240'),'Performance measurements are missing or unbounded');
2026-07-31 12:54:46 +09:00
assert(!css.includes('#noiseCanvas.interaction-muted')&&css.includes('body.reduced-effects #noiseCanvas')&&functionSource('applyUiSettings').includes("uiSettings.lightweightRendering")&&functionSource('scheduleNoiseBackground').includes('noisePainted')&&!functionSource('scheduleNoiseBackground').includes('setTimeout'),'Decorative noise is not static or the explicit lightweight setting is not the only suppression path');
2026-07-30 15:13:53 +09:00
assert((ensureBoardsSource.match(/changes<LOD_CHANGES_PER_PASS/g)||[]).length>=3&&ensureBoardsSource.includes('if(pending)scheduleLodPass()'),'LOD creation or eviction bypasses the per-pass budget');
assert(functionSource('desiredInteractiveBoardIds').includes('for(const id of ids)')&&!app.includes('INTERACTIVE_DETAIL_CELL_BUDGET'),'Visible-puzzle detail selection is incomplete or still budget-culls boards');
assert(ensureBoardsSource.includes("perfGauge('visibleUnsolvedBoards'"),'LOD performance telemetry cannot prove visible unsolved-board coverage');
const visibleIdsForDetail=new Set(Array.from({length:30},(_,index)=>`B${index}`)),
visibleContext={data:{metas:Object.fromEntries([...visibleIdsForDetail].map(id=>[id,{id,x:Number(id.slice(1)),y:0,puzzle:{bounds:{w:5,h:5},valid:Array.from({length:25})}}]))}};
vm.createContext(visibleContext);
vm.runInContext(`${functionSource('desiredInteractiveBoardIds')}\nthis.desiredInteractiveBoardIds=desiredInteractiveBoardIds;`,visibleContext);
const desiredVisible=visibleContext.desiredInteractiveBoardIds(visibleIdsForDetail);
assert(desiredVisible.has('B0')&&desiredVisible.size===visibleIdsForDetail.size,'Not every visible hydrated puzzle remained detailed');
let animationStarts=0,animationCancels=0,flashRemovals=0;
function makeFlashNode(){return{animate(){animationStarts++;const animation={cancel(){animationCancels++},finished:Promise.resolve()};return animation},remove(){flashRemovals++}}}
const flashContext={
PAD:10,CELL:40,ckey:(r,c)=>`${r},${c}`,svgEl:()=>makeFlashNode(),setTimeout:callback=>{callback();return 1},clearTimeout(){}
};
vm.createContext(flashContext);
vm.runInContext(`${flashSource}\nthis.flashConfirmedCell=flashConfirmedCell;`,flashContext);
const flashBoard={svg:{isConnected:true},dragLayer:{append(){}},cellFlashAnimations:new Map()};
flashContext.flashConfirmedCell(flashBoard,[1,2]);flashContext.flashConfirmedCell(flashBoard,[1,2]);
assert(animationStarts===2&&animationCancels>=1&&flashBoard.cellFlashAnimations.size<=1,'Repeated cell confirmation did not replace one temporary animation');
let rectCalls=0;
const eventContext={boardScreenRect:()=>{rectCalls++;return{left:10,top:20,width:200,height:100}}};
vm.createContext(eventContext);
vm.runInContext(`${eventSource}\nthis.eventToSvg=eventToSvg;`,eventContext);
const eventBoard={svg:{viewBox:{baseVal:{width:400,height:200}},getBoundingClientRect(){throw new Error('event conversion forced layout')}}},
mapped=eventContext.eventToSvg(eventBoard,{clientX:110,clientY:70});
assert(rectCalls===1&&mapped[0]===200&&mapped[1]===100,'Cached board geometry produced incorrect pointer coordinates');
const cacheContext={lineGraphRevision:0,lineGraphCacheRevision:-1,lineComponentCache:new Map(),lineWidthCache:new Map(),lineMinimapGeometryCache:new WeakMap(),data:{metas:{},states:{}},linePathKey:(id,index)=>`${id}:${index}`,matchingNeighborGate:()=>null};
vm.createContext(cacheContext);
vm.runInContext(`${functionSource('invalidateLineGraphCaches')}\n${functionSource('currentLineGraphCaches')}\nthis.logic={invalidateLineGraphCaches,currentLineGraphCaches};`,cacheContext);
const firstCaches=cacheContext.logic.currentLineGraphCaches(),secondCaches=cacheContext.logic.currentLineGraphCaches();
assert(firstCaches.components===secondCaches.components&&firstCaches.widths===secondCaches.widths,'Connected-line caches do not persist across reads');
cacheContext.logic.invalidateLineGraphCaches();const thirdCaches=cacheContext.logic.currentLineGraphCaches();
assert(thirdCaches.components!==firstCaches.components&&thirdCaches.widths!==firstCaches.widths,'Connected-line invalidation reused stale cache maps');
const shared={members:[[{id:'A'},0],[{id:'B'},0]],length:10},unrelated={members:[[{id:'C'},0]],length:3};
thirdCaches.components.set('A:0',shared);thirdCaches.components.set('B:0',shared);thirdCaches.components.set('C:0',unrelated);
thirdCaches.widths.set('A:0',5);thirdCaches.widths.set('B:0',5);thirdCaches.widths.set('C:0',4);
cacheContext.logic.invalidateLineGraphCaches('A',0);
assert(!thirdCaches.components.has('A:0')&&!thirdCaches.components.has('B:0')&&thirdCaches.components.get('C:0')===unrelated&&thirdCaches.widths.has('C:0'),'Path-granular invalidation cleared an unrelated connected-line component');
const widthWrites=[],meta={id:'A',puzzle:{}},
pathNode={dataset:{pathIndex:'0'},style:{setProperty:(name,value)=>widthWrites.push(['path',name,value])}},
connectorNode={dataset:{ownerBoard:'A',pathIndex:'0'},style:{setProperty:(name,value)=>widthWrites.push(['connector',name,value])}},
widthBoard={id:'A',meta,pathStrokeNodes:[pathNode],connectorNodes:[connectorNode],pathLayer:{querySelectorAll(){throw new Error('path DOM scan')}},connectorLayer:{querySelectorAll(){throw new Error('connector DOM scan')}}};
const widthContext={
rendered:new Map([['A',widthBoard]]),data:{metas:{A:meta}},metaState:()=>({paths:[{}]}),
linePathKey:(id,index)=>`${id}:${index}`,splitLinePathKey:key=>{const split=key.lastIndexOf(':');return[key.slice(0,split),Number(key.slice(split+1))]},
currentLineGraphCaches:()=>({components:new Map(),widths:new Map()}),
collectConnectedLineComponent:()=>({members:[[meta,0]]}),renderedConnectedLineWidth:()=>8,
applyLineWidth:(node,width)=>node.style.setProperty('--line-width',width.toFixed(2)),
perfStart:()=>0,perfEnd:()=>0,perfGauge:()=>{}
};
vm.createContext(widthContext);
vm.runInContext(`${widthRefreshSource}\nthis.refreshRenderedLineWidths=refreshRenderedLineWidths;`,widthContext);
widthContext.refreshRenderedLineWidths(new Set(['A:0']));
assert(widthWrites.length===2&&widthWrites.every(([,name,value])=>name==='--line-width'&&value==='8.00'),'Retained line nodes were not updated without DOM scans');
let geometryStateReads=0,geometryBuilds=0;
const geometryMeta={id:'G',x:2,y:3,puzzle:{}},geometryComponent={length:1500,members:[[geometryMeta,0]]},geometryContext={
CHUNK:5,metaState:()=>{geometryStateReads++;return{paths:[{startGate:0,endGate:1,cells:[[0,0],[0,1],[1,1]]}]}},
puzzleOf:()=>({}),gateObj:(_p,index)=>index?{cell:[1,1],side:'S'}:{cell:[0,0],side:'W'},pathColor:()=> '#fff',perfCount:()=>geometryBuilds++,currentLineGraphCaches:()=>({geometries:new WeakMap()})
};
vm.createContext(geometryContext);vm.runInContext(`${functionSource('minimapGeometryForComponent')}\nthis.minimapGeometryForComponent=minimapGeometryForComponent;`,geometryContext);
const geometryCache=new WeakMap(),firstGeometry=geometryContext.minimapGeometryForComponent(geometryComponent,geometryCache),secondGeometry=geometryContext.minimapGeometryForComponent(geometryComponent,geometryCache);
assert(firstGeometry===secondGeometry&&geometryStateReads===1&&geometryBuilds===1&&firstGeometry.segments[0].points.length>=2,'Simplified long-line geometry was not retained per connected component');
let minimapBuilds=0,center=[1,0],drawCopies=0;
const mapContext2d={setTransform(){},clearRect(){},drawImage(){drawCopies++},beginPath(){},moveTo(){},lineTo(){},stroke(){}},
attributes=new Map(),minimapCanvas={
width:210,height:132,getBoundingClientRect:()=>({width:210,height:132}),getContext:()=>mapContext2d,
getAttribute:name=>attributes.get(name)||null,setAttribute:(name,value)=>attributes.set(name,value)
},
minimapContext={
minimapCanvas,minimapStatus:{textContent:''},minimapBase:{},minimapDirty:true,minimapLongSegments:0,minimapWorldRevision:1,
minimapCache:{revision:1,width:210,height:132,dpr:1,anchorX:0,anchorY:0,scale:5,overscanPixels:50,baseWidth:310,baseHeight:232,longSegments:0},
window:{devicePixelRatio:1},MINIMAP_VIEW_CHUNKS_X:42,data:{metas:{}},getMinimapRect:()=>({width:210,height:132}),
cameraCenterInChunks:()=>center,visibleMetaIdsForBounds:()=>new Set(),metaState:()=>({solved:false}),
2026-07-31 12:54:46 +09:00
interactionActive:()=>false,perfStart:()=>0,perfEnd:()=>0,perfCount:()=>{}
2026-07-30 15:13:53 +09:00
};
minimapContext.rebuildMinimapWorld=(width,height,dpr,x,y)=>{
minimapBuilds++;minimapContext.minimapCache={...minimapContext.minimapCache,revision:minimapContext.minimapWorldRevision,width,height,dpr,anchorX:x,anchorY:y};
};
vm.createContext(minimapContext);
vm.runInContext(`${minimapSource}\nthis.drawMinimap=drawMinimap;`,minimapContext);
minimapContext.drawMinimap();assert(minimapBuilds===0&&drawCopies===1,'Small camera movement rebuilt the cached minimap world layer');
center=[20,0];minimapContext.drawMinimap();assert(minimapBuilds===1&&drawCopies===2,'Camera movement beyond minimap overscan did not rebuild exactly once');
let overviewLodSchedules=0;
2026-07-31 12:54:46 +09:00
const overviewRendered=new Map(Array.from({length:6},(_,index)=>[`D${index}`,{id:`D${index}`,drawing:null}]));
2026-07-30 15:13:53 +09:00
const overviewLodContext={
2026-07-31 12:54:46 +09:00
LOD_CHANGES_PER_PASS:4,rendered:overviewRendered,inWorldOverview:()=>true,interactionActive:()=>false,
destroyBoard:board=>overviewRendered.delete(board.id),
2026-07-30 15:13:53 +09:00
scheduleLodPass:()=>overviewLodSchedules++,scheduleWorldOverview:()=>{},perfStart:()=>0,perfCount:()=>{},perfEnd:()=>{}
};
vm.createContext(overviewLodContext);vm.runInContext(`${ensureBoardsSource}\nthis.ensureBoards=ensureBoards;`,overviewLodContext);overviewLodContext.ensureBoards();
2026-07-31 12:54:46 +09:00
assert(overviewRendered.size===2&&overviewLodSchedules===1,'Overview eviction exceeded its four-change budget or failed to schedule continuation');
2026-07-30 15:13:53 +09:00
let creationLodSchedules=0;
2026-07-31 12:54:46 +09:00
const visibleIds=new Set(Array.from({length:10},(_,index)=>`B${index}`)),
2026-07-30 15:13:53 +09:00
creationData={metas:Object.fromEntries([...visibleIds].map(id=>[id,{id,puzzle:{}}]))};
const creationLodContext={
2026-07-31 12:54:46 +09:00
LOD_CHANGES_PER_PASS:4,rendered:new Map(),data:creationData,inWorldOverview:()=>false,interactionActive:()=>false,
2026-07-30 15:13:53 +09:00
visibleMetaIds:()=>visibleIds,desiredInteractiveBoardIds:ids=>new Set(ids),metaState:()=>({solved:false}),
2026-07-31 12:54:46 +09:00
destroyBoard:()=>{},makeBoard:meta=>creationLodContext.rendered.set(meta.id,{id:meta.id,drawing:null}),
2026-07-30 15:13:53 +09:00
scheduleLodPass:()=>creationLodSchedules++,perfStart:()=>0,perfCount:()=>{},perfGauge:()=>{},perfEnd:()=>{}
};
vm.createContext(creationLodContext);vm.runInContext(`${ensureBoardsSource}\nthis.ensureBoards=ensureBoards;`,creationLodContext);creationLodContext.ensureBoards();
2026-07-31 12:54:46 +09:00
assert(creationLodContext.rendered.size===4&&creationLodSchedules===1,'Detailed board creation exceeded its four-change budget or hid visible boards');
2026-07-30 15:13:53 +09:00
(async()=>{
let workerConstructions=0,workerNow=1000,timerId=0;
const restartCallbacks=[],restartDelays=[],workerRetryContext={
APP_VERSION:'47.36',GENERATOR_VERSION:5,puzzleWorker:null,workerRestartTimer:null,workerFailureCount:0,workerDisabledUntil:0,workerRetryAt:0,
Date:{now:()=>workerNow},Worker:function Worker(){workerConstructions++;throw new Error('synthetic worker failure')},
setTimeout:(callback,delay)=>{restartCallbacks.push(callback);restartDelays.push(delay);return++timerId},clearTimeout:()=>{},console:{warn:()=>{}}
};
vm.createContext(workerRetryContext);
vm.runInContext(`${functionSource('scheduleWorkerRestart')}\n${functionSource('createPuzzleWorker')}\nthis.createPuzzleWorker=createPuzzleWorker;`,workerRetryContext);
workerRetryContext.createPuzzleWorker();workerRetryContext.createPuzzleWorker();
assert(workerConstructions===1&&restartDelays[0]===1000,'Worker creation retried immediately while a backoff timer was armed');
workerNow+=1000;restartCallbacks.shift()();
assert(workerConstructions===2&&restartDelays[1]===2000,'Worker restart did not advance through the gated exponential backoff');
let mainThreadCalls=0;
const workerContext={
puzzleWorker:null,workerSeq:0,workerJobs:new Map(),location:{protocol:'https:'},Worker:function Worker(){},
createPuzzleWorker:()=>null,perfCount:()=>{},generatePuzzleOnMainThread:()=>{mainThreadCalls++;return Promise.resolve({})}
};
vm.createContext(workerContext);
vm.runInContext(`${functionSource('generatePuzzleAsync')}\nthis.generatePuzzleAsync=generatePuzzleAsync;`,workerContext);
let rejected=false;try{await workerContext.generatePuzzleAsync([[0,0]],1,1)}catch(_){rejected=true}
assert(rejected&&mainThreadCalls===0,'Served-mode worker failure fell back to synchronous main-thread generation');
console.log('Performance regression smoke test passed');
})().catch(error=>{console.error(error);process.exitCode=1});